Merge branch 'auth2' into main
This commit is contained in:
379
AUDIT_LOGGING_IMPLEMENTATION.md
Normal file
379
AUDIT_LOGGING_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
# Audit Logging Implementation
|
||||||
|
|
||||||
|
This document describes the audit logging system implemented for the panel application. The system provides comprehensive tracking of user actions and system events for security, compliance, and monitoring purposes.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Comprehensive Action Tracking**: Logs all CRUD operations on projects, tasks, contracts, notes, and user management
|
||||||
|
- **Authentication Events**: Tracks login attempts, successes, and failures
|
||||||
|
- **Detailed Context**: Captures IP addresses, user agents, and request details
|
||||||
|
- **Flexible Filtering**: Query logs by user, action, resource type, date range, and more
|
||||||
|
- **Statistics Dashboard**: Provides insights into system usage patterns
|
||||||
|
- **Role-based Access**: Only admins and project managers can view audit logs
|
||||||
|
- **Performance Optimized**: Uses database indexes for efficient querying
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **Audit Log Utility** (`src/lib/auditLog.js`)
|
||||||
|
|
||||||
|
- Core logging functions
|
||||||
|
- Query and statistics functions
|
||||||
|
- Action and resource type constants
|
||||||
|
|
||||||
|
2. **API Endpoints** (`src/app/api/audit-logs/`)
|
||||||
|
|
||||||
|
- `/api/audit-logs` - Query audit logs with filtering
|
||||||
|
- `/api/audit-logs/stats` - Get audit log statistics
|
||||||
|
|
||||||
|
3. **UI Components** (`src/components/AuditLogViewer.js`)
|
||||||
|
|
||||||
|
- Interactive audit log viewer
|
||||||
|
- Advanced filtering interface
|
||||||
|
- Statistics dashboard
|
||||||
|
|
||||||
|
4. **Admin Pages** (`src/app/admin/audit-logs/`)
|
||||||
|
- Admin interface for viewing audit logs
|
||||||
|
- Role-based access control
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
The audit logs are stored in the `audit_logs` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT, -- User who performed the action
|
||||||
|
action TEXT NOT NULL, -- Action performed (see AUDIT_ACTIONS)
|
||||||
|
resource_type TEXT, -- Type of resource affected
|
||||||
|
resource_id TEXT, -- ID of the affected resource
|
||||||
|
ip_address TEXT, -- IP address of the user
|
||||||
|
user_agent TEXT, -- Browser/client information
|
||||||
|
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
details TEXT, -- Additional details (JSON)
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Basic Logging
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { logAuditEvent, AUDIT_ACTIONS, RESOURCE_TYPES } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
// Log a simple action
|
||||||
|
logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
userId: "user123",
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "proj-456",
|
||||||
|
ipAddress: req.ip,
|
||||||
|
userAgent: req.headers["user-agent"],
|
||||||
|
details: {
|
||||||
|
project_name: "New Project",
|
||||||
|
project_number: "NP-001",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Route Integration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { logApiAction, AUDIT_ACTIONS, RESOURCE_TYPES } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
export async function POST(req) {
|
||||||
|
const data = await req.json();
|
||||||
|
|
||||||
|
// Perform the operation
|
||||||
|
const result = createProject(data);
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
logApiAction(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
result.id.toString(),
|
||||||
|
req.session,
|
||||||
|
{ projectData: data }
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, id: result.id });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Querying Audit Logs
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { getAuditLogs, getAuditLogStats } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
// Get recent logs
|
||||||
|
const recentLogs = getAuditLogs({
|
||||||
|
limit: 50,
|
||||||
|
orderBy: "timestamp",
|
||||||
|
orderDirection: "DESC",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get logs for a specific user
|
||||||
|
const userLogs = getAuditLogs({
|
||||||
|
userId: "user123",
|
||||||
|
startDate: "2025-01-01T00:00:00Z",
|
||||||
|
endDate: "2025-12-31T23:59:59Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get statistics
|
||||||
|
const stats = getAuditLogStats({
|
||||||
|
startDate: "2025-01-01T00:00:00Z",
|
||||||
|
endDate: "2025-12-31T23:59:59Z",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Actions
|
||||||
|
|
||||||
|
### Authentication Actions
|
||||||
|
|
||||||
|
- `login` - Successful user login
|
||||||
|
- `logout` - User logout
|
||||||
|
- `login_failed` - Failed login attempt
|
||||||
|
|
||||||
|
### Project Actions
|
||||||
|
|
||||||
|
- `project_create` - Project creation
|
||||||
|
- `project_update` - Project modification
|
||||||
|
- `project_delete` - Project deletion
|
||||||
|
- `project_view` - Project viewing
|
||||||
|
|
||||||
|
### Task Actions
|
||||||
|
|
||||||
|
- `task_create` - Task creation
|
||||||
|
- `task_update` - Task modification
|
||||||
|
- `task_delete` - Task deletion
|
||||||
|
- `task_status_change` - Task status modification
|
||||||
|
|
||||||
|
### Project Task Actions
|
||||||
|
|
||||||
|
- `project_task_create` - Project task assignment
|
||||||
|
- `project_task_update` - Project task modification
|
||||||
|
- `project_task_delete` - Project task removal
|
||||||
|
- `project_task_status_change` - Project task status change
|
||||||
|
|
||||||
|
### Contract Actions
|
||||||
|
|
||||||
|
- `contract_create` - Contract creation
|
||||||
|
- `contract_update` - Contract modification
|
||||||
|
- `contract_delete` - Contract deletion
|
||||||
|
|
||||||
|
### Note Actions
|
||||||
|
|
||||||
|
- `note_create` - Note creation
|
||||||
|
- `note_update` - Note modification
|
||||||
|
- `note_delete` - Note deletion
|
||||||
|
|
||||||
|
### Admin Actions
|
||||||
|
|
||||||
|
- `user_create` - User account creation
|
||||||
|
- `user_update` - User account modification
|
||||||
|
- `user_delete` - User account deletion
|
||||||
|
- `user_role_change` - User role modification
|
||||||
|
|
||||||
|
### System Actions
|
||||||
|
|
||||||
|
- `data_export` - Data export operations
|
||||||
|
- `bulk_operation` - Bulk data operations
|
||||||
|
|
||||||
|
## Resource Types
|
||||||
|
|
||||||
|
- `project` - Project resources
|
||||||
|
- `task` - Task templates
|
||||||
|
- `project_task` - Project-specific tasks
|
||||||
|
- `contract` - Contracts
|
||||||
|
- `note` - Notes and comments
|
||||||
|
- `user` - User accounts
|
||||||
|
- `session` - Authentication sessions
|
||||||
|
- `system` - System-level operations
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### GET /api/audit-logs
|
||||||
|
|
||||||
|
Query audit logs with optional filtering.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `userId` - Filter by user ID
|
||||||
|
- `action` - Filter by action type
|
||||||
|
- `resourceType` - Filter by resource type
|
||||||
|
- `resourceId` - Filter by resource ID
|
||||||
|
- `startDate` - Filter from date (ISO string)
|
||||||
|
- `endDate` - Filter to date (ISO string)
|
||||||
|
- `limit` - Maximum results (default: 100)
|
||||||
|
- `offset` - Results offset (default: 0)
|
||||||
|
- `orderBy` - Order by field (default: timestamp)
|
||||||
|
- `orderDirection` - ASC or DESC (default: DESC)
|
||||||
|
- `includeStats` - Include statistics (true/false)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"user_id": "user123",
|
||||||
|
"user_name": "John Doe",
|
||||||
|
"user_email": "john@example.com",
|
||||||
|
"action": "project_create",
|
||||||
|
"resource_type": "project",
|
||||||
|
"resource_id": "proj-456",
|
||||||
|
"ip_address": "192.168.1.100",
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"timestamp": "2025-07-09T10:30:00Z",
|
||||||
|
"details": {
|
||||||
|
"project_name": "New Project",
|
||||||
|
"project_number": "NP-001"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"total": 150,
|
||||||
|
"actionBreakdown": [...],
|
||||||
|
"userBreakdown": [...],
|
||||||
|
"resourceBreakdown": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/audit-logs/stats
|
||||||
|
|
||||||
|
Get audit log statistics.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
|
||||||
|
- `startDate` - Filter from date (ISO string)
|
||||||
|
- `endDate` - Filter to date (ISO string)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"total": 150,
|
||||||
|
"actionBreakdown": [
|
||||||
|
{ "action": "project_view", "count": 45 },
|
||||||
|
{ "action": "login", "count": 23 }
|
||||||
|
],
|
||||||
|
"userBreakdown": [
|
||||||
|
{ "user_id": "user123", "user_name": "John Doe", "count": 67 }
|
||||||
|
],
|
||||||
|
"resourceBreakdown": [{ "resource_type": "project", "count": 89 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Access Control
|
||||||
|
|
||||||
|
Audit logs are restricted to users with the following roles:
|
||||||
|
|
||||||
|
- `admin` - Full access to all audit logs
|
||||||
|
- `project_manager` - Full access to all audit logs
|
||||||
|
|
||||||
|
Other users cannot access audit logs.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the audit logging test script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test-audit-logging.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
|
||||||
|
1. Create sample audit events
|
||||||
|
2. Test querying and filtering
|
||||||
|
3. Verify statistics generation
|
||||||
|
4. Test date range filtering
|
||||||
|
|
||||||
|
## Integration Status
|
||||||
|
|
||||||
|
The audit logging system has been integrated into the following API routes:
|
||||||
|
|
||||||
|
✅ **Authentication** (`src/lib/auth.js`)
|
||||||
|
|
||||||
|
- Login success/failure tracking
|
||||||
|
- Account lockout logging
|
||||||
|
|
||||||
|
✅ **Projects** (`src/app/api/projects/`)
|
||||||
|
|
||||||
|
- Project CRUD operations
|
||||||
|
- List view access
|
||||||
|
|
||||||
|
✅ **Notes** (`src/app/api/notes/`)
|
||||||
|
|
||||||
|
- Note creation, updates, and deletion
|
||||||
|
|
||||||
|
🔄 **Pending Integration:**
|
||||||
|
|
||||||
|
- Tasks API
|
||||||
|
- Project Tasks API
|
||||||
|
- Contracts API
|
||||||
|
- User management API
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- Database indexes are created on frequently queried fields
|
||||||
|
- Large result sets are paginated
|
||||||
|
- Statistics queries are optimized for common use cases
|
||||||
|
- Failed operations are logged to prevent data loss
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- IP address tracking for forensic analysis
|
||||||
|
- User agent logging for client identification
|
||||||
|
- Failed authentication attempt tracking
|
||||||
|
- Detailed change logging for sensitive operations
|
||||||
|
- Role-based access control for audit log viewing
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Log Retention
|
||||||
|
|
||||||
|
Consider implementing log retention policies:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Delete audit logs older than 1 year
|
||||||
|
DELETE FROM audit_logs
|
||||||
|
WHERE timestamp < datetime('now', '-1 year');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
Monitor audit log growth and performance:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check audit log table size
|
||||||
|
SELECT COUNT(*) as total_logs,
|
||||||
|
MIN(timestamp) as oldest_log,
|
||||||
|
MAX(timestamp) as newest_log
|
||||||
|
FROM audit_logs;
|
||||||
|
|
||||||
|
-- Check most active users
|
||||||
|
SELECT user_id, COUNT(*) as activity_count
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE timestamp > datetime('now', '-30 days')
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY activity_count DESC
|
||||||
|
LIMIT 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Real-time audit log streaming
|
||||||
|
- Advanced analytics and reporting
|
||||||
|
- Integration with external SIEM systems
|
||||||
|
- Automatic anomaly detection
|
||||||
|
- Compliance reporting templates
|
||||||
|
- Log export functionality
|
||||||
File diff suppressed because it is too large
Load Diff
176
EDGE_RUNTIME_FIX.md
Normal file
176
EDGE_RUNTIME_FIX.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# Edge Runtime Compatibility Fix - Final Solution
|
||||||
|
|
||||||
|
## Problem Resolved
|
||||||
|
|
||||||
|
The audit logging system was causing "Edge runtime does not support Node.js 'fs' module" errors because the `better-sqlite3` database module was being loaded in Edge Runtime contexts through static imports.
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The middleware imports `auth.js` → which imported `auditLog.js` → which had a static import of `db.js` → which imports `better-sqlite3`. This caused the entire SQLite module to be loaded even in Edge Runtime where it's not supported.
|
||||||
|
|
||||||
|
## Final Solution
|
||||||
|
|
||||||
|
### 1. Created Safe Audit Logging Module
|
||||||
|
|
||||||
|
**File: `src/lib/auditLogSafe.js`**
|
||||||
|
|
||||||
|
This module provides:
|
||||||
|
|
||||||
|
- ✅ **No static database imports** - completely safe for Edge Runtime
|
||||||
|
- ✅ **Runtime detection** - automatically detects Edge vs Node.js
|
||||||
|
- ✅ **Graceful fallbacks** - console logging in Edge, database in Node.js
|
||||||
|
- ✅ **Constants always available** - `AUDIT_ACTIONS` and `RESOURCE_TYPES`
|
||||||
|
- ✅ **Async/await support** - works with modern API patterns
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Safe import that never causes Edge Runtime errors
|
||||||
|
import {
|
||||||
|
logAuditEventSafe,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "./auditLogSafe.js";
|
||||||
|
|
||||||
|
// Works in any runtime
|
||||||
|
await logAuditEventSafe({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN,
|
||||||
|
userId: "user123",
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Updated All Imports
|
||||||
|
|
||||||
|
**Files Updated:**
|
||||||
|
|
||||||
|
- `src/lib/auth.js` - Authentication logging
|
||||||
|
- `src/app/api/projects/route.js` - Project operations
|
||||||
|
- `src/app/api/projects/[id]/route.js` - Individual project operations
|
||||||
|
- `src/app/api/notes/route.js` - Note operations
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { logApiAction, AUDIT_ACTIONS } from "@/lib/auditLog.js"; // ❌ Causes Edge Runtime errors
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { logApiActionSafe, AUDIT_ACTIONS } from "@/lib/auditLogSafe.js"; // ✅ Edge Runtime safe
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Runtime Behavior
|
||||||
|
|
||||||
|
#### Edge Runtime
|
||||||
|
|
||||||
|
- **Detection**: Automatic via `typeof EdgeRuntime !== 'undefined'`
|
||||||
|
- **Logging**: Console output only
|
||||||
|
- **Performance**: Zero database overhead
|
||||||
|
- **Errors**: None - completely safe
|
||||||
|
|
||||||
|
#### Node.js Runtime
|
||||||
|
|
||||||
|
- **Detection**: Automatic fallback when Edge Runtime not detected
|
||||||
|
- **Logging**: Full database functionality via dynamic import
|
||||||
|
- **Performance**: Full audit trail with database persistence
|
||||||
|
- **Errors**: Graceful handling with console fallback
|
||||||
|
|
||||||
|
### 4. Migration Pattern
|
||||||
|
|
||||||
|
The safe module uses a smart delegation pattern:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// In Edge Runtime: Console logging only
|
||||||
|
console.log(`[Audit] ${action} by user ${userId}`);
|
||||||
|
|
||||||
|
// In Node.js Runtime: Try database, fallback to console
|
||||||
|
try {
|
||||||
|
const auditModule = await import("./auditLog.js");
|
||||||
|
auditModule.logAuditEvent({ ...params });
|
||||||
|
} catch (dbError) {
|
||||||
|
console.log("[Audit] Database logging failed, using console fallback");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/lib/
|
||||||
|
├── auditLog.js # Original - Node.js only (database operations)
|
||||||
|
├── auditLogSafe.js # New - Universal (Edge + Node.js compatible)
|
||||||
|
├── auditLogEdge.js # Alternative - Edge-specific with API calls
|
||||||
|
└── auth.js # Updated to use safe imports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Run the compatibility test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node test-safe-audit-logging.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Output:**
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Safe module imported successfully
|
||||||
|
✅ Edge Runtime logging successful (console only)
|
||||||
|
✅ Node.js Runtime logging successful (database + console)
|
||||||
|
✅ Constants accessible
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification Checklist
|
||||||
|
|
||||||
|
✅ **No more Edge Runtime errors**
|
||||||
|
✅ **Middleware works without database dependencies**
|
||||||
|
✅ **Authentication logging works in all contexts**
|
||||||
|
✅ **API routes maintain full audit functionality**
|
||||||
|
✅ **Constants available everywhere**
|
||||||
|
✅ **Graceful degradation in Edge Runtime**
|
||||||
|
✅ **Full functionality in Node.js Runtime**
|
||||||
|
|
||||||
|
## Performance Impact
|
||||||
|
|
||||||
|
- **Edge Runtime**: Minimal - only console logging
|
||||||
|
- **Node.js Runtime**: Same as before - full database operations
|
||||||
|
- **Import cost**: Near zero - no static database imports
|
||||||
|
- **Memory usage**: Significantly reduced in Edge Runtime
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
To update existing code:
|
||||||
|
|
||||||
|
1. **Replace imports:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Old
|
||||||
|
import { logApiAction } from "@/lib/auditLog.js";
|
||||||
|
|
||||||
|
// New
|
||||||
|
import { logApiActionSafe } from "@/lib/auditLogSafe.js";
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update function calls:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Old
|
||||||
|
logApiAction(req, action, type, id, session, details);
|
||||||
|
|
||||||
|
// New
|
||||||
|
await logApiActionSafe(req, action, type, id, session, details);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add runtime exports** (for API routes):
|
||||||
|
```javascript
|
||||||
|
export const runtime = "nodejs"; // For database-heavy routes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices Applied
|
||||||
|
|
||||||
|
1. **Separation of Concerns**: Safe module for universal use, full module for Node.js
|
||||||
|
2. **Dynamic Imports**: Database modules loaded only when needed
|
||||||
|
3. **Runtime Detection**: Automatic environment detection
|
||||||
|
4. **Graceful Degradation**: Meaningful fallbacks in constrained environments
|
||||||
|
5. **Error Isolation**: Audit failures don't break main application flow
|
||||||
|
|
||||||
|
The application now handles both Edge and Node.js runtimes seamlessly with zero Edge Runtime errors! 🎉
|
||||||
161
EDGE_RUNTIME_FIX_FINAL.md
Normal file
161
EDGE_RUNTIME_FIX_FINAL.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Final Edge Runtime Fix - Audit Logging System
|
||||||
|
|
||||||
|
## ✅ **Issue Resolved**
|
||||||
|
|
||||||
|
The Edge Runtime error has been completely fixed! The audit logging system now works seamlessly across all Next.js runtime environments.
|
||||||
|
|
||||||
|
## 🔧 **Final Implementation**
|
||||||
|
|
||||||
|
### **Problem Summary**
|
||||||
|
|
||||||
|
- Edge Runtime was trying to load `better-sqlite3` (Node.js fs module)
|
||||||
|
- Static imports in middleware caused the entire dependency chain to load
|
||||||
|
- `middleware.js` → `auth.js` → `auditLog.js` → `db.js` → `better-sqlite3`
|
||||||
|
|
||||||
|
### **Solution Implemented**
|
||||||
|
|
||||||
|
#### 1. **Made All Functions Async**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Before: Synchronous with require()
|
||||||
|
export function logAuditEvent() {
|
||||||
|
const { default: db } = require("./db.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
// After: Async with dynamic import
|
||||||
|
export async function logAuditEvent() {
|
||||||
|
const { default: db } = await import("./db.js");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. **Runtime Detection & Graceful Fallbacks**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export async function logAuditEvent(params) {
|
||||||
|
try {
|
||||||
|
// Edge Runtime detection
|
||||||
|
if (
|
||||||
|
typeof EdgeRuntime !== "undefined" ||
|
||||||
|
process.env.NEXT_RUNTIME === "edge"
|
||||||
|
) {
|
||||||
|
console.log(`[Audit Log - Edge Runtime] ${action} by user ${userId}`);
|
||||||
|
return; // Graceful exit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node.js Runtime: Full database functionality
|
||||||
|
const { default: db } = await import("./db.js");
|
||||||
|
// ... database operations
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to log audit event:", error);
|
||||||
|
// Non-breaking error handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. **Safe Wrapper Module (`auditLogSafe.js`)**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
export async function logAuditEventSafe(params) {
|
||||||
|
console.log(`[Audit] ${action} by user ${userId}`); // Always log to console
|
||||||
|
|
||||||
|
if (typeof EdgeRuntime !== "undefined") {
|
||||||
|
return; // Edge Runtime: Console only
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auditModule = await import("./auditLog.js");
|
||||||
|
await auditModule.logAuditEvent(params); // Node.js: Database + console
|
||||||
|
} catch (error) {
|
||||||
|
console.log("[Audit] Database logging failed, using console fallback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎯 **Runtime Behavior**
|
||||||
|
|
||||||
|
| Runtime | Behavior | Database | Console | Errors |
|
||||||
|
| ----------- | ------------------------ | -------- | ------- | ---------------------- |
|
||||||
|
| **Edge** | Console logging only | ❌ | ✅ | ❌ Zero errors |
|
||||||
|
| **Node.js** | Full audit functionality | ✅ | ✅ | ❌ Full error handling |
|
||||||
|
|
||||||
|
## ✅ **Test Results**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ node test-safe-audit-logging.mjs
|
||||||
|
|
||||||
|
Testing Safe Audit Logging...
|
||||||
|
|
||||||
|
1. Testing safe module import...
|
||||||
|
✅ Safe module imported successfully
|
||||||
|
Available actions: 27
|
||||||
|
Available resource types: 8
|
||||||
|
|
||||||
|
2. Testing in simulated Edge Runtime...
|
||||||
|
[Audit] project_view by user anonymous on project:test-123
|
||||||
|
[Audit] Edge Runtime detected - console logging only
|
||||||
|
✅ Edge Runtime logging successful (console only)
|
||||||
|
|
||||||
|
3. Testing in simulated Node.js Runtime...
|
||||||
|
[Audit] project_create by user anonymous on project:test-456
|
||||||
|
Audit log: project_create by user anonymous on project:test-456
|
||||||
|
✅ Node.js Runtime logging successful (database + console)
|
||||||
|
|
||||||
|
4. Testing constants accessibility...
|
||||||
|
✅ Constants accessible:
|
||||||
|
LOGIN action: login
|
||||||
|
PROJECT resource: project
|
||||||
|
NOTE_CREATE action: note_create
|
||||||
|
|
||||||
|
✅ Safe Audit Logging test completed!
|
||||||
|
|
||||||
|
Key features verified:
|
||||||
|
- ✅ No static database imports
|
||||||
|
- ✅ Edge Runtime compatibility
|
||||||
|
- ✅ Graceful fallbacks
|
||||||
|
- ✅ Constants always available
|
||||||
|
- ✅ Async/await support
|
||||||
|
|
||||||
|
The middleware should now work without Edge Runtime errors!
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 **Files Updated**
|
||||||
|
|
||||||
|
### **Core Audit System**
|
||||||
|
|
||||||
|
- ✅ `src/lib/auditLog.js` - Made all functions async, removed static imports
|
||||||
|
- ✅ `src/lib/auditLogSafe.js` - New Edge-compatible wrapper module
|
||||||
|
|
||||||
|
### **Authentication**
|
||||||
|
|
||||||
|
- ✅ `src/lib/auth.js` - Updated to use safe audit logging
|
||||||
|
|
||||||
|
### **API Routes**
|
||||||
|
|
||||||
|
- ✅ `src/app/api/audit-logs/route.js` - Updated for async functions
|
||||||
|
- ✅ `src/app/api/audit-logs/stats/route.js` - Updated for async functions
|
||||||
|
- ✅ `src/app/api/audit-logs/log/route.js` - Updated for async functions
|
||||||
|
- ✅ `src/app/api/projects/route.js` - Using safe audit logging
|
||||||
|
- ✅ `src/app/api/projects/[id]/route.js` - Using safe audit logging
|
||||||
|
- ✅ `src/app/api/notes/route.js` - Using safe audit logging
|
||||||
|
|
||||||
|
## 🚀 **Benefits Achieved**
|
||||||
|
|
||||||
|
1. **✅ Zero Edge Runtime Errors** - No more fs module conflicts
|
||||||
|
2. **✅ Universal Compatibility** - Works in any Next.js runtime environment
|
||||||
|
3. **✅ No Functionality Loss** - Full audit trail in production (Node.js runtime)
|
||||||
|
4. **✅ Graceful Degradation** - Meaningful console logging in Edge Runtime
|
||||||
|
5. **✅ Performance Optimized** - No unnecessary database loads in Edge Runtime
|
||||||
|
6. **✅ Developer Friendly** - Clear logging shows what's happening in each runtime
|
||||||
|
|
||||||
|
## 🎉 **Final Status**
|
||||||
|
|
||||||
|
**The audit logging system is now production-ready and Edge Runtime compatible!**
|
||||||
|
|
||||||
|
- **Middleware**: ✅ Works without errors
|
||||||
|
- **Authentication**: ✅ Logs login/logout events
|
||||||
|
- **API Routes**: ✅ Full audit trail for CRUD operations
|
||||||
|
- **Admin Interface**: ✅ View audit logs at `/admin/audit-logs`
|
||||||
|
- **Edge Runtime**: ✅ Zero errors, console fallbacks
|
||||||
|
- **Node.js Runtime**: ✅ Full database functionality
|
||||||
|
|
||||||
|
Your application should now run perfectly without any Edge Runtime errors while maintaining comprehensive audit logging! 🎊
|
||||||
90
MERGE_PREPARATION_SUMMARY.md
Normal file
90
MERGE_PREPARATION_SUMMARY.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Branch Merge Preparation Summary
|
||||||
|
|
||||||
|
## ✅ Completed Tasks
|
||||||
|
|
||||||
|
### 1. Build Issues Fixed
|
||||||
|
- **SSR Issues**: Fixed server-side rendering issues with Leaflet map components
|
||||||
|
- **useSearchParams**: Added Suspense boundaries to all pages using useSearchParams
|
||||||
|
- **Dynamic Imports**: Implemented proper dynamic imports for map components
|
||||||
|
- **Build Success**: Project now builds successfully without errors
|
||||||
|
|
||||||
|
### 2. Code Quality Improvements
|
||||||
|
- **README Updated**: Comprehensive documentation reflecting current project state
|
||||||
|
- **Project Structure**: Updated project structure documentation
|
||||||
|
- **API Documentation**: Added complete API endpoint documentation
|
||||||
|
- **Clean Build**: All pages compile and build correctly
|
||||||
|
|
||||||
|
### 3. Debug Pages Management
|
||||||
|
- **Temporary Relocation**: Moved debug/test pages to `debug-disabled/` folder
|
||||||
|
- **Build Optimization**: Removed non-production pages from build process
|
||||||
|
- **Development Tools**: Preserved debug functionality for future development
|
||||||
|
|
||||||
|
### 4. Authentication & Authorization
|
||||||
|
- **Auth Pages Fixed**: All authentication pages now build correctly
|
||||||
|
- **Suspense Boundaries**: Proper error boundaries for auth components
|
||||||
|
- **Session Management**: Maintained existing auth functionality
|
||||||
|
|
||||||
|
## 🔍 Current State
|
||||||
|
|
||||||
|
### Build Status
|
||||||
|
- ✅ **npm run build**: Successful
|
||||||
|
- ✅ **34 pages**: All pages compile
|
||||||
|
- ✅ **Static Generation**: Working correctly
|
||||||
|
- ⚠️ **ESLint Warning**: Parser serialization issue (non-blocking)
|
||||||
|
|
||||||
|
### Branch Status
|
||||||
|
- **Branch**: `auth2`
|
||||||
|
- **Status**: Ready for merge to main
|
||||||
|
- **Commit**: `faeb1ca` - "Prepare branch for merge to main"
|
||||||
|
- **Files Changed**: 13 files modified/moved
|
||||||
|
|
||||||
|
## 🚀 Next Steps for Merge
|
||||||
|
|
||||||
|
### 1. Pre-merge Checklist
|
||||||
|
- [x] All build errors resolved
|
||||||
|
- [x] Documentation updated
|
||||||
|
- [x] Non-production code moved
|
||||||
|
- [x] Changes committed
|
||||||
|
- [ ] Final testing (recommended)
|
||||||
|
- [ ] Merge to main branch
|
||||||
|
|
||||||
|
### 2. Post-merge Tasks
|
||||||
|
- [ ] Re-enable debug pages if needed (move back from `debug-disabled/`)
|
||||||
|
- [ ] Fix ESLint parser configuration
|
||||||
|
- [ ] Add integration tests
|
||||||
|
- [ ] Deploy to production
|
||||||
|
|
||||||
|
### 3. Optional Improvements
|
||||||
|
- [ ] Fix ESLint configuration for better linting
|
||||||
|
- [ ] Add more comprehensive error handling
|
||||||
|
- [ ] Optimize bundle size
|
||||||
|
- [ ] Add more unit tests
|
||||||
|
|
||||||
|
## 📝 Files Modified
|
||||||
|
|
||||||
|
### Core Changes
|
||||||
|
- `README.md` - Updated comprehensive documentation
|
||||||
|
- `src/app/auth/error/page.js` - Added Suspense boundary
|
||||||
|
- `src/app/auth/signin/page.js` - Added Suspense boundary
|
||||||
|
- `src/app/projects/[id]/page.js` - Fixed dynamic import
|
||||||
|
- `src/app/projects/map/page.js` - Added Suspense boundary
|
||||||
|
- `src/components/ui/ClientProjectMap.js` - New client component wrapper
|
||||||
|
|
||||||
|
### Debug Pages (Temporarily Moved)
|
||||||
|
- `debug-disabled/debug-polish-orthophoto/` - Polish orthophoto debug
|
||||||
|
- `debug-disabled/test-polish-orthophoto/` - Polish orthophoto test
|
||||||
|
- `debug-disabled/test-polish-map/` - Polish map test
|
||||||
|
- `debug-disabled/test-improved-wmts/` - WMTS test
|
||||||
|
- `debug-disabled/comprehensive-polish-map/` - Comprehensive map test
|
||||||
|
|
||||||
|
## 🎯 Recommendation
|
||||||
|
|
||||||
|
**The branch is now ready for merge to main.** All critical build issues have been resolved, and the project builds successfully. The debug pages have been temporarily moved to prevent build issues while preserving their functionality for future development.
|
||||||
|
|
||||||
|
To proceed with the merge:
|
||||||
|
1. Switch to main branch: `git checkout main`
|
||||||
|
2. Merge auth2 branch: `git merge auth2`
|
||||||
|
3. Push to origin: `git push origin main`
|
||||||
|
4. Deploy if needed
|
||||||
|
|
||||||
|
The project is now in a stable state with comprehensive authentication, project management, and mapping functionality.
|
||||||
37
README.md
37
README.md
@@ -100,18 +100,27 @@ The application uses SQLite database which will be automatically initialized on
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── app/ # Next.js app router pages
|
├── app/ # Next.js app router pages
|
||||||
|
│ ├── admin/ # Admin dashboard and user management
|
||||||
│ ├── api/ # API routes
|
│ ├── api/ # API routes
|
||||||
|
│ │ ├── admin/ # Admin-related endpoints (e.g., user management)
|
||||||
│ │ ├── all-project-tasks/ # Get all project tasks endpoint
|
│ │ ├── all-project-tasks/ # Get all project tasks endpoint
|
||||||
|
│ │ ├── audit-logs/ # Audit log endpoints
|
||||||
|
│ │ ├── auth/ # Authentication endpoints
|
||||||
│ │ ├── contracts/ # Contract management endpoints
|
│ │ ├── contracts/ # Contract management endpoints
|
||||||
│ │ ├── notes/ # Notes management endpoints
|
│ │ ├── notes/ # Notes management endpoints
|
||||||
│ │ ├── projects/ # Project management endpoints
|
│ │ ├── projects/ # Project management endpoints
|
||||||
│ │ ├── project-tasks/ # Task management endpoints
|
│ │ ├── project-tasks/ # Task management endpoints
|
||||||
|
│ │ ├── task-notes/ # Task-specific notes endpoints
|
||||||
│ │ └── tasks/ # Task template endpoints
|
│ │ └── tasks/ # Task template endpoints
|
||||||
|
│ ├── auth/ # Authentication pages (login, etc.)
|
||||||
│ ├── contracts/ # Contract pages
|
│ ├── contracts/ # Contract pages
|
||||||
│ ├── projects/ # Project pages
|
│ ├── projects/ # Project pages
|
||||||
|
│ ├── project-tasks/ # Project-specific task pages
|
||||||
│ └── tasks/ # Task management pages
|
│ └── tasks/ # Task management pages
|
||||||
├── components/ # Reusable React components
|
├── components/ # Reusable React components
|
||||||
|
│ ├── auth/ # Authentication-related components
|
||||||
│ ├── ui/ # UI components (Button, Card, etc.)
|
│ ├── ui/ # UI components (Button, Card, etc.)
|
||||||
|
│ ├── AuditLogViewer.js # Component to view audit logs
|
||||||
│ ├── ContractForm.js # Contract form component
|
│ ├── ContractForm.js # Contract form component
|
||||||
│ ├── NoteForm.js # Note form component
|
│ ├── NoteForm.js # Note form component
|
||||||
│ ├── ProjectForm.js # Project form component
|
│ ├── ProjectForm.js # Project form component
|
||||||
@@ -119,10 +128,14 @@ src/
|
|||||||
│ ├── ProjectTasksSection.js # Project tasks section component
|
│ ├── ProjectTasksSection.js # Project tasks section component
|
||||||
│ ├── TaskForm.js # Task form component
|
│ ├── TaskForm.js # Task form component
|
||||||
│ └── TaskTemplateForm.js # Task template form component
|
│ └── TaskTemplateForm.js # Task template form component
|
||||||
└── lib/ # Utility functions
|
├── lib/ # Utility functions
|
||||||
├── queries/ # Database query functions
|
│ ├── queries/ # Database query functions
|
||||||
├── db.js # Database connection
|
│ ├── auditLog.js # Audit logging utilities
|
||||||
└── init-db.js # Database initialization
|
│ ├── auth.js # Authentication helpers
|
||||||
|
│ ├── db.js # Database connection
|
||||||
|
│ ├── init-db.js # Database initialization
|
||||||
|
│ └── userManagement.js # User management functions
|
||||||
|
└── middleware.js # Next.js middleware for auth and routing
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
@@ -147,6 +160,9 @@ The application uses the following main tables:
|
|||||||
- **tasks** - Task templates
|
- **tasks** - Task templates
|
||||||
- **project_tasks** - Tasks assigned to specific projects
|
- **project_tasks** - Tasks assigned to specific projects
|
||||||
- **notes** - Project notes and updates
|
- **notes** - Project notes and updates
|
||||||
|
- **users** - User accounts and roles for authentication
|
||||||
|
- **sessions** - User session management
|
||||||
|
- **audit_logs** - Detailed logs for security and tracking
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -188,6 +204,19 @@ The application uses the following main tables:
|
|||||||
- `POST /api/notes` - Create new note
|
- `POST /api/notes` - Create new note
|
||||||
- `DELETE /api/notes` - Delete note
|
- `DELETE /api/notes` - Delete note
|
||||||
|
|
||||||
|
### Audit Logs
|
||||||
|
|
||||||
|
- `GET /api/audit-logs` - Get all audit logs
|
||||||
|
- `POST /api/audit-logs/log` - Create a new audit log entry
|
||||||
|
- `GET /api/audit-logs/stats` - Get audit log statistics
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
- `GET /api/admin/users` - Get all users
|
||||||
|
- `POST /api/admin/users` - Create a new user
|
||||||
|
- `PUT /api/admin/users/[id]` - Update a user
|
||||||
|
- `DELETE /api/admin/users/[id]` - Delete a user
|
||||||
|
|
||||||
## Advanced Map Features
|
## Advanced Map Features
|
||||||
|
|
||||||
This project includes a powerful map system for project locations, supporting multiple dynamic base layers:
|
This project includes a powerful map system for project locations, supporting multiple dynamic base layers:
|
||||||
|
|||||||
56
check-audit-db.mjs
Normal file
56
check-audit-db.mjs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { readFileSync } from "fs";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
// Check database directly
|
||||||
|
const dbPath = "./data/database.sqlite";
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
console.log("Checking audit logs table...\n");
|
||||||
|
|
||||||
|
// Check table schema
|
||||||
|
const schema = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type='table' AND name='audit_logs'"
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
console.log("Table schema:");
|
||||||
|
console.log(schema?.sql || "Table not found");
|
||||||
|
|
||||||
|
console.log("\n" + "=".repeat(50) + "\n");
|
||||||
|
|
||||||
|
// Get some audit logs
|
||||||
|
const logs = db
|
||||||
|
.prepare("SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 5")
|
||||||
|
.all();
|
||||||
|
console.log(`Found ${logs.length} audit log entries:`);
|
||||||
|
|
||||||
|
logs.forEach((log, index) => {
|
||||||
|
console.log(`\n${index + 1}. ID: ${log.id}`);
|
||||||
|
console.log(` Timestamp: ${log.timestamp}`);
|
||||||
|
console.log(` User ID: ${log.user_id || "NULL"}`);
|
||||||
|
console.log(` Action: ${log.action}`);
|
||||||
|
console.log(` Resource Type: ${log.resource_type}`);
|
||||||
|
console.log(` Resource ID: ${log.resource_id || "N/A"}`);
|
||||||
|
console.log(` IP Address: ${log.ip_address || "N/A"}`);
|
||||||
|
console.log(` User Agent: ${log.user_agent || "N/A"}`);
|
||||||
|
console.log(` Details: ${log.details || "NULL"}`);
|
||||||
|
console.log(` Details type: ${typeof log.details}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count null user_ids
|
||||||
|
const nullUserCount = db
|
||||||
|
.prepare("SELECT COUNT(*) as count FROM audit_logs WHERE user_id IS NULL")
|
||||||
|
.get();
|
||||||
|
const totalCount = db.prepare("SELECT COUNT(*) as count FROM audit_logs").get();
|
||||||
|
|
||||||
|
console.log(`\n${"=".repeat(50)}`);
|
||||||
|
console.log(`Total audit logs: ${totalCount.count}`);
|
||||||
|
console.log(`Logs with NULL user_id: ${nullUserCount.count}`);
|
||||||
|
console.log(
|
||||||
|
`Percentage with NULL user_id: ${(
|
||||||
|
(nullUserCount.count / totalCount.count) *
|
||||||
|
100
|
||||||
|
).toFixed(2)}%`
|
||||||
|
);
|
||||||
|
|
||||||
|
db.close();
|
||||||
13
check-columns.mjs
Normal file
13
check-columns.mjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import db from "./src/lib/db.js";
|
||||||
|
|
||||||
|
console.log("Checking projects table structure:");
|
||||||
|
const tableInfo = db.prepare("PRAGMA table_info(projects)").all();
|
||||||
|
console.log(JSON.stringify(tableInfo, null, 2));
|
||||||
|
|
||||||
|
// Check if created_at and updated_at columns exist
|
||||||
|
const hasCreatedAt = tableInfo.some((col) => col.name === "created_at");
|
||||||
|
const hasUpdatedAt = tableInfo.some((col) => col.name === "updated_at");
|
||||||
|
|
||||||
|
console.log("\nColumn existence check:");
|
||||||
|
console.log("created_at exists:", hasCreatedAt);
|
||||||
|
console.log("updated_at exists:", hasUpdatedAt);
|
||||||
5
check-projects-table.mjs
Normal file
5
check-projects-table.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import db from "./src/lib/db.js";
|
||||||
|
|
||||||
|
console.log("Current projects table structure:");
|
||||||
|
const tableInfo = db.prepare("PRAGMA table_info(projects)").all();
|
||||||
|
console.log(JSON.stringify(tableInfo, null, 2));
|
||||||
32
check-projects.mjs
Normal file
32
check-projects.mjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const db = new Database("./data/database.sqlite");
|
||||||
|
|
||||||
|
// Check table structures first
|
||||||
|
console.log("Users table structure:");
|
||||||
|
const usersSchema = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
console.log(usersSchema);
|
||||||
|
|
||||||
|
console.log("\nProjects table structure:");
|
||||||
|
const projectsSchema = db.prepare("PRAGMA table_info(projects)").all();
|
||||||
|
console.log(projectsSchema);
|
||||||
|
|
||||||
|
// Check if there are any projects
|
||||||
|
const projects = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT p.*,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
assignee.name as assigned_to_name
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN users creator ON p.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON p.assigned_to = assignee.id
|
||||||
|
LIMIT 5
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
console.log("\nProjects in database:");
|
||||||
|
console.log(JSON.stringify(projects, null, 2));
|
||||||
|
|
||||||
|
db.close();
|
||||||
25
check-task-schema.mjs
Normal file
25
check-task-schema.mjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const db = new Database("./data/database.sqlite");
|
||||||
|
|
||||||
|
console.log("Project Tasks table structure:");
|
||||||
|
const projectTasksSchema = db.prepare("PRAGMA table_info(project_tasks)").all();
|
||||||
|
console.table(projectTasksSchema);
|
||||||
|
|
||||||
|
console.log("\nSample project tasks with user tracking:");
|
||||||
|
const tasks = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT pt.*,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
assignee.name as assigned_to_name
|
||||||
|
FROM project_tasks pt
|
||||||
|
LEFT JOIN users creator ON pt.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
|
||||||
|
LIMIT 3
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
console.table(tasks);
|
||||||
|
|
||||||
|
db.close();
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ComprehensivePolishMap from '../../components/ui/ComprehensivePolishMap';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const ComprehensivePolishMap = dynamic(
|
||||||
|
() => import('../../components/ui/ComprehensivePolishMap'),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default function ComprehensivePolishMapPage() {
|
export default function ComprehensivePolishMapPage() {
|
||||||
const [selectedLocation, setSelectedLocation] = useState('krakow');
|
const [selectedLocation, setSelectedLocation] = useState('krakow');
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Temporarily disabled debug pages during build
|
||||||
|
// These pages are for development/testing purposes only
|
||||||
|
// To re-enable, rename this file to layout.js
|
||||||
|
|
||||||
|
export default function DebugLayout({ children }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import DebugPolishOrthophotoMap from '../../components/ui/DebugPolishOrthophotoMap';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const DebugPolishOrthophotoMap = dynamic(
|
||||||
|
() => import('../../components/ui/DebugPolishOrthophotoMap'),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const dynamicParams = true;
|
||||||
|
|
||||||
export default function DebugPolishOrthophotoPage() {
|
export default function DebugPolishOrthophotoPage() {
|
||||||
// Test marker in Poland
|
// Test marker in Poland
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ImprovedPolishOrthophotoMap from '../../components/ui/ImprovedPolishOrthophotoMap';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const ImprovedPolishOrthophotoMap = dynamic(
|
||||||
|
() => import('../../components/ui/ImprovedPolishOrthophotoMap'),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default function ImprovedPolishOrthophotoPage() {
|
export default function ImprovedPolishOrthophotoPage() {
|
||||||
const testMarkers = [
|
const testMarkers = [
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import PolishOrthophotoMap from '../../components/ui/PolishOrthophotoMap';
|
import dynamic from 'next/dynamic';
|
||||||
import AdvancedPolishOrthophotoMap from '../../components/ui/AdvancedPolishOrthophotoMap';
|
|
||||||
|
const PolishOrthophotoMap = dynamic(
|
||||||
|
() => import('../../components/ui/PolishOrthophotoMap'),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const AdvancedPolishOrthophotoMap = dynamic(
|
||||||
|
() => import('../../components/ui/AdvancedPolishOrthophotoMap'),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default function PolishOrthophotoTestPage() {
|
export default function PolishOrthophotoTestPage() {
|
||||||
const [activeMap, setActiveMap] = useState('basic');
|
const [activeMap, setActiveMap] = useState('basic');
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import PolishOrthophotoMap from '../../components/ui/PolishOrthophotoMap';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
|
const PolishOrthophotoMap = dynamic(
|
||||||
|
() => import('../../components/ui/PolishOrthophotoMap'),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default function TestPolishOrthophotoPage() {
|
export default function TestPolishOrthophotoPage() {
|
||||||
// Test markers - various locations in Poland
|
// Test markers - various locations in Poland
|
||||||
49
debug-task-insert.mjs
Normal file
49
debug-task-insert.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const db = new Database("./data/database.sqlite");
|
||||||
|
|
||||||
|
console.log("Project Tasks table columns:");
|
||||||
|
const projectTasksSchema = db.prepare("PRAGMA table_info(project_tasks)").all();
|
||||||
|
projectTasksSchema.forEach((col) => {
|
||||||
|
console.log(
|
||||||
|
`${col.name}: ${col.type} (${col.notnull ? "NOT NULL" : "NULL"})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nChecking if created_at and updated_at columns exist...");
|
||||||
|
const hasCreatedAt = projectTasksSchema.some(
|
||||||
|
(col) => col.name === "created_at"
|
||||||
|
);
|
||||||
|
const hasUpdatedAt = projectTasksSchema.some(
|
||||||
|
(col) => col.name === "updated_at"
|
||||||
|
);
|
||||||
|
console.log("created_at exists:", hasCreatedAt);
|
||||||
|
console.log("updated_at exists:", hasUpdatedAt);
|
||||||
|
|
||||||
|
// Let's try a simple insert to see what happens
|
||||||
|
console.log("\nTesting manual insert...");
|
||||||
|
try {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO project_tasks (
|
||||||
|
project_id, task_template_id, status, priority,
|
||||||
|
created_by, assigned_to, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.run(1, 1, "pending", "normal", "test-user", "test-user");
|
||||||
|
|
||||||
|
console.log("Insert successful, ID:", result.lastInsertRowid);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
db.prepare("DELETE FROM project_tasks WHERE id = ?").run(
|
||||||
|
result.lastInsertRowid
|
||||||
|
);
|
||||||
|
console.log("Test record cleaned up");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Insert failed:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
60
fix-notes-columns.mjs
Normal file
60
fix-notes-columns.mjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const db = new Database("./data/database.sqlite");
|
||||||
|
|
||||||
|
console.log("Adding user tracking columns to notes table...\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Adding created_by column...");
|
||||||
|
db.exec(`ALTER TABLE notes ADD COLUMN created_by TEXT;`);
|
||||||
|
console.log("✓ created_by column added");
|
||||||
|
} catch (e) {
|
||||||
|
console.log("created_by column already exists or error:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Adding is_system column...");
|
||||||
|
db.exec(`ALTER TABLE notes ADD COLUMN is_system INTEGER DEFAULT 0;`);
|
||||||
|
console.log("✓ is_system column added");
|
||||||
|
} catch (e) {
|
||||||
|
console.log("is_system column already exists or error:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nVerifying columns were added...");
|
||||||
|
const schema = db.prepare("PRAGMA table_info(notes)").all();
|
||||||
|
const hasCreatedBy = schema.some((col) => col.name === "created_by");
|
||||||
|
const hasIsSystem = schema.some((col) => col.name === "is_system");
|
||||||
|
|
||||||
|
console.log("created_by exists:", hasCreatedBy);
|
||||||
|
console.log("is_system exists:", hasIsSystem);
|
||||||
|
|
||||||
|
if (hasCreatedBy && hasIsSystem) {
|
||||||
|
console.log("\n✅ All columns are now present!");
|
||||||
|
|
||||||
|
// Test a manual insert
|
||||||
|
console.log("\nTesting manual note insert...");
|
||||||
|
try {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO notes (project_id, note, created_by, is_system, note_date)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.run(1, "Test note with user tracking", "test-user-id", 0);
|
||||||
|
|
||||||
|
console.log("Insert successful, ID:", result.lastInsertRowid);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
db.prepare("DELETE FROM notes WHERE note_id = ?").run(
|
||||||
|
result.lastInsertRowid
|
||||||
|
);
|
||||||
|
console.log("Test record cleaned up");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Insert failed:", error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("\n❌ Some columns are still missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
37
fix-task-columns.mjs
Normal file
37
fix-task-columns.mjs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const db = new Database("./data/database.sqlite");
|
||||||
|
|
||||||
|
console.log("Adding missing columns to project_tasks table...\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Adding created_at column...");
|
||||||
|
db.exec(`ALTER TABLE project_tasks ADD COLUMN created_at TEXT;`);
|
||||||
|
console.log("✓ created_at column added");
|
||||||
|
} catch (e) {
|
||||||
|
console.log("created_at column already exists or error:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Adding updated_at column...");
|
||||||
|
db.exec(`ALTER TABLE project_tasks ADD COLUMN updated_at TEXT;`);
|
||||||
|
console.log("✓ updated_at column added");
|
||||||
|
} catch (e) {
|
||||||
|
console.log("updated_at column already exists or error:", e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nVerifying columns were added...");
|
||||||
|
const schema = db.prepare("PRAGMA table_info(project_tasks)").all();
|
||||||
|
const hasCreatedAt = schema.some((col) => col.name === "created_at");
|
||||||
|
const hasUpdatedAt = schema.some((col) => col.name === "updated_at");
|
||||||
|
|
||||||
|
console.log("created_at exists:", hasCreatedAt);
|
||||||
|
console.log("updated_at exists:", hasUpdatedAt);
|
||||||
|
|
||||||
|
if (hasCreatedAt && hasUpdatedAt) {
|
||||||
|
console.log("\n✅ All columns are now present!");
|
||||||
|
} else {
|
||||||
|
console.log("\n❌ Some columns are still missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
307
package-lock.json
generated
307
package-lock.json
generated
@@ -8,16 +8,20 @@
|
|||||||
"name": "panel",
|
"name": "panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "15.1.8",
|
"next": "15.1.8",
|
||||||
|
"next-auth": "^5.0.0-beta.29",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
"proj4": "^2.19.3",
|
"proj4": "^2.19.3",
|
||||||
"proj4leaflet": "^1.0.2",
|
"proj4leaflet": "^1.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3",
|
||||||
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
@@ -68,6 +72,35 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@auth/core": {
|
||||||
|
"version": "0.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz",
|
||||||
|
"integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@panva/hkdf": "^1.2.1",
|
||||||
|
"jose": "^6.0.6",
|
||||||
|
"oauth4webapi": "^3.3.0",
|
||||||
|
"preact": "10.24.3",
|
||||||
|
"preact-render-to-string": "6.5.11"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"nodemailer": "^6.8.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -1912,6 +1945,15 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@petamoriken/float16": {
|
"node_modules/@petamoriken/float16": {
|
||||||
"version": "3.9.2",
|
"version": "3.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
||||||
@@ -3396,6 +3438,14 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "11.10.0",
|
"version": "11.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||||
@@ -4114,6 +4164,14 @@
|
|||||||
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
|
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/data-uri-to-buffer": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
|
||||||
@@ -5262,6 +5320,28 @@
|
|||||||
"bser": "2.1.1"
|
"bser": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fetch-blob": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20 || >= 14.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -5374,6 +5454,17 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/formdata-polyfill": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs-constants": {
|
"node_modules/fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
@@ -7176,6 +7267,15 @@
|
|||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz",
|
||||||
|
"integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -7720,6 +7820,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-auth": {
|
||||||
|
"version": "5.0.0-beta.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.29.tgz",
|
||||||
|
"integrity": "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/core": "0.40.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@simplewebauthn/browser": "^9.0.1",
|
||||||
|
"@simplewebauthn/server": "^9.0.2",
|
||||||
|
"next": "^14.0.0-0 || ^15.0.0-0",
|
||||||
|
"nodemailer": "^6.6.5",
|
||||||
|
"react": "^18.2.0 || ^19.0.0-0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@simplewebauthn/browser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@simplewebauthn/server": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"nodemailer": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next/node_modules/postcss": {
|
"node_modules/next/node_modules/postcss": {
|
||||||
"version": "8.4.31",
|
"version": "8.4.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||||
@@ -7758,6 +7885,42 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
|
||||||
|
"deprecated": "Use your platform's native DOMException instead",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/jimmywarting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://paypal.me/jimmywarting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"dependencies": {
|
||||||
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
"fetch-blob": "^3.1.4",
|
||||||
|
"formdata-polyfill": "^4.0.10"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/node-fetch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-int64": {
|
"node_modules/node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -7801,6 +7964,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/oauth4webapi": {
|
||||||
|
"version": "3.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.3.tgz",
|
||||||
|
"integrity": "sha512-2bnHosmBLAQpXNBLOvaJMyMkr4Yya5ohE5Q9jqyxiN+aa7GFCzvDN1RRRMrp0NkfqRR2MTaQNkcSUCCjILD9oQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -8404,6 +8576,25 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.24.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/preact-render-to-string": {
|
||||||
|
"version": "6.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
|
||||||
|
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -10382,6 +10573,14 @@
|
|||||||
"makeerror": "1.0.12"
|
"makeerror": "1.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-streams-polyfill": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/web-worker": {
|
"node_modules/web-worker": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||||
@@ -10826,6 +11025,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "3.25.67",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
|
||||||
|
"integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zstddec": {
|
"node_modules/zstddec": {
|
||||||
"version": "0.2.0-alpha.3",
|
"version": "0.2.0-alpha.3",
|
||||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
|
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
|
||||||
@@ -10857,6 +11064,18 @@
|
|||||||
"@jridgewell/trace-mapping": "^0.3.24"
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@auth/core": {
|
||||||
|
"version": "0.40.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz",
|
||||||
|
"integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==",
|
||||||
|
"requires": {
|
||||||
|
"@panva/hkdf": "^1.2.1",
|
||||||
|
"jose": "^6.0.6",
|
||||||
|
"oauth4webapi": "^3.3.0",
|
||||||
|
"preact": "10.24.3",
|
||||||
|
"preact-render-to-string": "6.5.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@babel/code-frame": {
|
"@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
@@ -12031,6 +12250,11 @@
|
|||||||
"integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
|
"integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@panva/hkdf": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="
|
||||||
|
},
|
||||||
"@petamoriken/float16": {
|
"@petamoriken/float16": {
|
||||||
"version": "3.9.2",
|
"version": "3.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
|
||||||
@@ -13077,6 +13301,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
||||||
},
|
},
|
||||||
|
"bcryptjs": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="
|
||||||
|
},
|
||||||
"better-sqlite3": {
|
"better-sqlite3": {
|
||||||
"version": "11.10.0",
|
"version": "11.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
|
||||||
@@ -13577,6 +13806,11 @@
|
|||||||
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
|
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"data-uri-to-buffer": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
|
||||||
|
},
|
||||||
"data-urls": {
|
"data-urls": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
|
||||||
@@ -14419,6 +14653,15 @@
|
|||||||
"bser": "2.1.1"
|
"bser": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"fetch-blob": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
|
||||||
|
"requires": {
|
||||||
|
"node-domexception": "^1.0.0",
|
||||||
|
"web-streams-polyfill": "^3.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"file-entry-cache": {
|
"file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -14500,6 +14743,14 @@
|
|||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"formdata-polyfill": {
|
||||||
|
"version": "4.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||||
|
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
|
||||||
|
"requires": {
|
||||||
|
"fetch-blob": "^3.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fs-constants": {
|
"fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
@@ -15738,6 +15989,11 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"jose": {
|
||||||
|
"version": "6.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz",
|
||||||
|
"integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="
|
||||||
|
},
|
||||||
"js-tokens": {
|
"js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -16128,6 +16384,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"next-auth": {
|
||||||
|
"version": "5.0.0-beta.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.29.tgz",
|
||||||
|
"integrity": "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==",
|
||||||
|
"requires": {
|
||||||
|
"@auth/core": "0.40.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node-abi": {
|
"node-abi": {
|
||||||
"version": "3.75.0",
|
"version": "3.75.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
|
||||||
@@ -16136,6 +16400,21 @@
|
|||||||
"semver": "^7.3.5"
|
"semver": "^7.3.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node-domexception": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="
|
||||||
|
},
|
||||||
|
"node-fetch": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||||
|
"requires": {
|
||||||
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
"fetch-blob": "^3.1.4",
|
||||||
|
"formdata-polyfill": "^4.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node-int64": {
|
"node-int64": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
@@ -16169,6 +16448,11 @@
|
|||||||
"integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
|
"integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"oauth4webapi": {
|
||||||
|
"version": "3.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.3.tgz",
|
||||||
|
"integrity": "sha512-2bnHosmBLAQpXNBLOvaJMyMkr4Yya5ohE5Q9jqyxiN+aa7GFCzvDN1RRRMrp0NkfqRR2MTaQNkcSUCCjILD9oQ=="
|
||||||
|
},
|
||||||
"object-assign": {
|
"object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -16559,6 +16843,17 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"preact": {
|
||||||
|
"version": "10.24.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
||||||
|
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="
|
||||||
|
},
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"version": "6.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
|
||||||
|
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
|
||||||
|
"requires": {}
|
||||||
|
},
|
||||||
"prebuild-install": {
|
"prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -17931,6 +18226,11 @@
|
|||||||
"makeerror": "1.0.12"
|
"makeerror": "1.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"web-streams-polyfill": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
|
||||||
|
},
|
||||||
"web-worker": {
|
"web-worker": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
|
||||||
@@ -18240,6 +18540,11 @@
|
|||||||
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"zod": {
|
||||||
|
"version": "3.25.67",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
|
||||||
|
"integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="
|
||||||
|
},
|
||||||
"zstddec": {
|
"zstddec": {
|
||||||
"version": "0.2.0-alpha.3",
|
"version": "0.2.0-alpha.3",
|
||||||
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
|
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"name": "panel",
|
"name": "panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -14,16 +15,20 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"next": "15.1.8",
|
"next": "15.1.8",
|
||||||
|
"next-auth": "^5.0.0-beta.29",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
"proj4": "^2.19.3",
|
"proj4": "^2.19.3",
|
||||||
"proj4leaflet": "^1.0.2",
|
"proj4leaflet": "^1.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3",
|
||||||
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
|||||||
142
public/test-auth.html
Normal file
142
public/test-auth.html
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Authentication Test Page</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
.test-section { margin: 20px 0; padding: 20px; border: 1px solid #ccc; border-radius: 5px; }
|
||||||
|
.result { margin: 10px 0; padding: 10px; border-radius: 3px; }
|
||||||
|
.success { background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; }
|
||||||
|
.error { background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; }
|
||||||
|
.info { background-color: #d1ecf1; border: 1px solid #bee5eb; color: #0c5460; }
|
||||||
|
button { padding: 10px 20px; margin: 5px; cursor: pointer; }
|
||||||
|
pre { background: #f8f9fa; padding: 10px; border-radius: 3px; overflow-x: auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Authentication & API Test Page</h1>
|
||||||
|
|
||||||
|
<div class="test-section">
|
||||||
|
<h2>Authentication Status</h2>
|
||||||
|
<button onclick="checkAuthStatus()">Check Authentication Status</button>
|
||||||
|
<div id="authStatus"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-section">
|
||||||
|
<h2>API Endpoint Tests</h2>
|
||||||
|
<button onclick="testAllEndpoints()">Test All API Endpoints</button>
|
||||||
|
<div id="apiResults"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-section">
|
||||||
|
<h2>Manual Login Instructions</h2>
|
||||||
|
<div class="info">
|
||||||
|
<p><strong>Test Credentials:</strong></p>
|
||||||
|
<p>Email: <code>admin@localhost.com</code></p>
|
||||||
|
<p>Password: <code>admin123456</code></p>
|
||||||
|
<p><a href="/auth/signin" target="_blank">Open Sign-in Page</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function checkAuthStatus() {
|
||||||
|
const statusDiv = document.getElementById('authStatus');
|
||||||
|
statusDiv.innerHTML = '<div class="info">Checking authentication status...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/session');
|
||||||
|
const session = await response.json();
|
||||||
|
|
||||||
|
if (session && session.user) {
|
||||||
|
statusDiv.innerHTML = `
|
||||||
|
<div class="success">
|
||||||
|
<h3>✅ Authenticated</h3>
|
||||||
|
<pre>${JSON.stringify(session, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
statusDiv.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<h3>❌ Not Authenticated</h3>
|
||||||
|
<p>Please <a href="/auth/signin">sign in</a> first.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusDiv.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
<h3>❌ Error checking authentication</h3>
|
||||||
|
<p>${error.message}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAllEndpoints() {
|
||||||
|
const resultsDiv = document.getElementById('apiResults');
|
||||||
|
resultsDiv.innerHTML = '<div class="info">Testing API endpoints...</div>';
|
||||||
|
|
||||||
|
const endpoints = [
|
||||||
|
{ url: '/api/debug-auth', method: 'GET', name: 'Debug Auth' },
|
||||||
|
{ url: '/api/projects', method: 'GET', name: 'Projects' },
|
||||||
|
{ url: '/api/contracts', method: 'GET', name: 'Contracts' },
|
||||||
|
{ url: '/api/tasks', method: 'GET', name: 'Tasks' },
|
||||||
|
{ url: '/api/tasks/templates', method: 'GET', name: 'Task Templates' },
|
||||||
|
{ url: '/api/project-tasks', method: 'GET', name: 'Project Tasks' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let results = '';
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint.url, {
|
||||||
|
method: endpoint.method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const count = Array.isArray(data) ? data.length : 'object';
|
||||||
|
results += `
|
||||||
|
<div class="success">
|
||||||
|
<strong>✅ ${endpoint.name}</strong> (${endpoint.method} ${endpoint.url})
|
||||||
|
<br>Status: ${response.status} | Data: ${count} items
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
results += `
|
||||||
|
<div class="error">
|
||||||
|
<strong>🔒 ${endpoint.name}</strong> (${endpoint.method} ${endpoint.url})
|
||||||
|
<br>Status: ${response.status} - Unauthorized (Please sign in)
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
results += `
|
||||||
|
<div class="error">
|
||||||
|
<strong>❌ ${endpoint.name}</strong> (${endpoint.method} ${endpoint.url})
|
||||||
|
<br>Status: ${response.status} - ${response.statusText}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
results += `
|
||||||
|
<div class="error">
|
||||||
|
<strong>💥 ${endpoint.name}</strong> (${endpoint.method} ${endpoint.url})
|
||||||
|
<br>Error: ${error.message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-check authentication status on page load
|
||||||
|
window.addEventListener('load', checkAuthStatus);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
34
scripts/create-admin.js
Normal file
34
scripts/create-admin.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createUser } from "../src/lib/userManagement.js"
|
||||||
|
import initializeDatabase from "../src/lib/init-db.js"
|
||||||
|
|
||||||
|
async function createInitialAdmin() {
|
||||||
|
try {
|
||||||
|
// Initialize database first
|
||||||
|
initializeDatabase()
|
||||||
|
|
||||||
|
console.log("Creating initial admin user...")
|
||||||
|
|
||||||
|
const adminUser = await createUser({
|
||||||
|
name: "Administrator",
|
||||||
|
email: "admin@localhost.com",
|
||||||
|
password: "admin123456", // Change this in production!
|
||||||
|
role: "admin"
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log("✅ Initial admin user created successfully!")
|
||||||
|
console.log("📧 Email: admin@localhost.com")
|
||||||
|
console.log("🔑 Password: admin123456")
|
||||||
|
console.log("⚠️ Please change the password after first login!")
|
||||||
|
console.log("👤 User ID:", adminUser.id)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("already exists")) {
|
||||||
|
console.log("ℹ️ Admin user already exists. Skipping creation.")
|
||||||
|
} else {
|
||||||
|
console.error("❌ Error creating admin user:", error.message)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createInitialAdmin()
|
||||||
55
src/app/admin/audit-logs/page.js
Normal file
55
src/app/admin/audit-logs/page.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import AuditLogViewer from "@/components/AuditLogViewer";
|
||||||
|
|
||||||
|
export default function AuditLogsPage() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "loading") return; // Still loading
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
router.push("/auth/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow admins and project managers to view audit logs
|
||||||
|
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||||
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [session, status, router]);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || !["admin", "project_manager"].includes(session.user.role)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
|
Access Denied
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
You don't have permission to view this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<AuditLogViewer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
336
src/app/admin/users/[id]/edit/page.js
Normal file
336
src/app/admin/users/[id]/edit/page.js
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter, useParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
|
import { LoadingState } from "@/components/ui/States";
|
||||||
|
|
||||||
|
export default function EditUserPage() {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
role: "user",
|
||||||
|
is_active: true,
|
||||||
|
password: ""
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "loading") return;
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [session, status, router]);
|
||||||
|
|
||||||
|
// Fetch user data
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user?.role === "admin" && params.id) {
|
||||||
|
fetchUser();
|
||||||
|
}
|
||||||
|
}, [session, params.id]);
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(`/api/admin/users/${params.id}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
setError("User not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Failed to fetch user");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await response.json();
|
||||||
|
setUser(userData);
|
||||||
|
setFormData({
|
||||||
|
name: userData.name,
|
||||||
|
email: userData.email,
|
||||||
|
role: userData.role,
|
||||||
|
is_active: userData.is_active,
|
||||||
|
password: "" // Never populate password field
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSaving(true);
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare update data (exclude empty password)
|
||||||
|
const updateData = {
|
||||||
|
name: formData.name,
|
||||||
|
email: formData.email,
|
||||||
|
role: formData.role,
|
||||||
|
is_active: formData.is_active
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include password if it's provided
|
||||||
|
if (formData.password.trim()) {
|
||||||
|
if (formData.password.length < 6) {
|
||||||
|
throw new Error("Password must be at least 6 characters long");
|
||||||
|
}
|
||||||
|
updateData.password = formData.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/users/${params.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to update user");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await response.json();
|
||||||
|
setUser(updatedUser);
|
||||||
|
setSuccess("User updated successfully");
|
||||||
|
|
||||||
|
// Clear password field after successful update
|
||||||
|
setFormData(prev => ({ ...prev, password: "" }));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading" || !session) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||||
|
<p className="text-gray-600 mb-6">You need admin privileges to access this page.</p>
|
||||||
|
<Link href="/">
|
||||||
|
<Button>Go Home</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !user) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">Error</h2>
|
||||||
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
|
<Link href="/admin/users">
|
||||||
|
<Button>Back to Users</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={`Edit User: ${user?.name}`}
|
||||||
|
description="Update user information and permissions"
|
||||||
|
>
|
||||||
|
<Link href="/admin/users">
|
||||||
|
<Button variant="outline">
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Back to Users
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-md">
|
||||||
|
<p className="text-green-600">{success}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">User Information</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Role *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="read_only">Read Only</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="project_manager">Project Manager</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
placeholder="Leave blank to keep current password"
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Leave blank to keep the current password
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_active"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
disabled={user?.id === session?.user?.id}
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-900">
|
||||||
|
Active User
|
||||||
|
{user?.id === session?.user?.id && (
|
||||||
|
<span className="text-gray-500 ml-1">(Cannot deactivate your own account)</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4 pt-6 border-t border-gray-200">
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
<Link href="/admin/users">
|
||||||
|
<Button type="button" variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* User Details Card */}
|
||||||
|
{user && (
|
||||||
|
<Card className="mt-6">
|
||||||
|
<CardHeader>
|
||||||
|
<h3 className="text-lg font-semibold">Account Details</h3>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Created</p>
|
||||||
|
<p className="text-sm text-gray-900">{new Date(user.created_at).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Last Updated</p>
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{user.updated_at ? new Date(user.updated_at).toLocaleDateString() : "Never"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Last Login</p>
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{user.last_login ? new Date(user.last_login).toLocaleDateString() : "Never"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Failed Login Attempts</p>
|
||||||
|
<p className="text-sm text-gray-900">{user.failed_login_attempts || 0}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Account Status</p>
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{user.is_active ? "Active" : "Inactive"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Account Locked</p>
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{user.locked_until && new Date(user.locked_until) > new Date()
|
||||||
|
? `Until ${new Date(user.locked_until).toLocaleDateString()}`
|
||||||
|
: "No"
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
418
src/app/admin/users/page.js
Normal file
418
src/app/admin/users/page.js
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import Badge from "@/components/ui/Badge";
|
||||||
|
import { Input } from "@/components/ui/Input";
|
||||||
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
|
import { LoadingState } from "@/components/ui/States";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function UserManagementPage() {
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "loading") return;
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [session, status, router]);
|
||||||
|
|
||||||
|
// Fetch users
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.user?.role === "admin") {
|
||||||
|
fetchUsers();
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch("/api/admin/users");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch users");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId) => {
|
||||||
|
if (!confirm("Are you sure you want to delete this user?")) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to delete user");
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsers(users.filter(user => user.id !== userId));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleUser = async (userId, isActive) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ is_active: !isActive }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to update user");
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsers(users.map(user =>
|
||||||
|
user.id === userId
|
||||||
|
? { ...user, is_active: !isActive }
|
||||||
|
: user
|
||||||
|
));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleColor = (role) => {
|
||||||
|
switch (role) {
|
||||||
|
case "admin":
|
||||||
|
return "red";
|
||||||
|
case "project_manager":
|
||||||
|
return "blue";
|
||||||
|
case "user":
|
||||||
|
return "green";
|
||||||
|
case "read_only":
|
||||||
|
return "gray";
|
||||||
|
default:
|
||||||
|
return "gray";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleDisplay = (role) => {
|
||||||
|
switch (role) {
|
||||||
|
case "project_manager":
|
||||||
|
return "Project Manager";
|
||||||
|
case "read_only":
|
||||||
|
return "Read Only";
|
||||||
|
default:
|
||||||
|
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "loading" || !session) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.user.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
|
||||||
|
<p className="text-gray-600 mb-6">You need admin privileges to access this page.</p>
|
||||||
|
<Link href="/">
|
||||||
|
<Button>Go Home</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader title="User Management" description="Manage system users and permissions">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Users List */}
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<svg className="w-16 h-16 mx-auto text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No Users Found</h3>
|
||||||
|
<p className="text-gray-500">Start by creating your first user.</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<Card key={user.id}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{user.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Badge color={getRoleColor(user.role)}>
|
||||||
|
{getRoleDisplay(user.role)}
|
||||||
|
</Badge>
|
||||||
|
<Badge color={user.is_active ? "green" : "red"}>
|
||||||
|
{user.is_active ? "Active" : "Inactive"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Created</p>
|
||||||
|
<p className="text-sm text-gray-900">{formatDate(user.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Last Login</p>
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
{user.last_login ? formatDate(user.last_login) : "Never"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">Failed Attempts</p>
|
||||||
|
<p className="text-sm text-gray-900">{user.failed_login_attempts || 0}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.locked_until && new Date(user.locked_until) > new Date() && (
|
||||||
|
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
Account locked until {formatDate(user.locked_until)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleToggleUser(user.id, user.is_active)}
|
||||||
|
>
|
||||||
|
{user.is_active ? "Deactivate" : "Activate"}
|
||||||
|
</Button>
|
||||||
|
<Link href={`/admin/users/${user.id}/edit`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteUser(user.id)}
|
||||||
|
disabled={user.id === session?.user?.id}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create User Modal/Form */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<CreateUserModal
|
||||||
|
onClose={() => setShowCreateForm(false)}
|
||||||
|
onUserCreated={(newUser) => {
|
||||||
|
setUsers([...users, newUser]);
|
||||||
|
setShowCreateForm(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create User Modal Component
|
||||||
|
function CreateUserModal({ onClose, onUserCreated }) {
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
role: "user",
|
||||||
|
is_active: true
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || "Failed to create user");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = await response.json();
|
||||||
|
onUserCreated(newUser);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Create New User</h3>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<p className="text-red-600 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="read_only">Read Only</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="project_manager">Project Manager</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="is_active"
|
||||||
|
checked={formData.is_active}
|
||||||
|
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-900">
|
||||||
|
Active User
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-3 pt-4">
|
||||||
|
<Button type="submit" disabled={loading} className="flex-1">
|
||||||
|
{loading ? "Creating..." : "Create User"}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} className="flex-1">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
src/app/api/admin/users/[id]/route.js
Normal file
129
src/app/api/admin/users/[id]/route.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { getUserById, updateUser, deleteUser } from "@/lib/userManagement.js";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withAdminAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// GET: Get user by ID (admin only)
|
||||||
|
async function getUserHandler(req, { params }) {
|
||||||
|
try {
|
||||||
|
const user = getUserById(params.id);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "User not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove password hash from response
|
||||||
|
const { password_hash, ...safeUser } = user;
|
||||||
|
return NextResponse.json(safeUser);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching user:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch user" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT: Update user (admin only)
|
||||||
|
async function updateUserHandler(req, { params }) {
|
||||||
|
try {
|
||||||
|
const data = await req.json();
|
||||||
|
const userId = params.id;
|
||||||
|
|
||||||
|
// Prevent admin from deactivating themselves
|
||||||
|
if (data.is_active === false && userId === req.user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "You cannot deactivate your own account" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role if provided
|
||||||
|
if (data.role) {
|
||||||
|
const validRoles = ["read_only", "user", "project_manager", "admin"];
|
||||||
|
if (!validRoles.includes(data.role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid role specified" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length if provided
|
||||||
|
if (data.password && data.password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Password must be at least 6 characters long" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await updateUser(userId, data);
|
||||||
|
|
||||||
|
if (!updatedUser) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "User not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove password hash from response
|
||||||
|
const { password_hash, ...safeUser } = updatedUser;
|
||||||
|
return NextResponse.json(safeUser);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating user:", error);
|
||||||
|
|
||||||
|
if (error.message.includes("already exists")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "A user with this email already exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update user" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: Delete user (admin only)
|
||||||
|
async function deleteUserHandler(req, { params }) {
|
||||||
|
try {
|
||||||
|
const userId = params.id;
|
||||||
|
|
||||||
|
// Prevent admin from deleting themselves
|
||||||
|
if (userId === req.user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "You cannot delete your own account" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await deleteUser(userId);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "User not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "User deleted successfully" });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting user:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to delete user" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require admin authentication
|
||||||
|
export const GET = withAdminAuth(getUserHandler);
|
||||||
|
export const PUT = withAdminAuth(updateUserHandler);
|
||||||
|
export const DELETE = withAdminAuth(deleteUserHandler);
|
||||||
85
src/app/api/admin/users/route.js
Normal file
85
src/app/api/admin/users/route.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { getAllUsers, createUser } from "@/lib/userManagement.js";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withAdminAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// GET: Get all users (admin only)
|
||||||
|
async function getUsersHandler(req) {
|
||||||
|
try {
|
||||||
|
const users = getAllUsers();
|
||||||
|
// Remove password hashes from response
|
||||||
|
const safeUsers = users.map(user => {
|
||||||
|
const { password_hash, ...safeUser } = user;
|
||||||
|
return safeUser;
|
||||||
|
});
|
||||||
|
return NextResponse.json(safeUsers);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch users" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Create new user (admin only)
|
||||||
|
async function createUserHandler(req) {
|
||||||
|
try {
|
||||||
|
const data = await req.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!data.name || !data.email || !data.password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Name, email, and password are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (data.password.length < 6) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Password must be at least 6 characters long" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role
|
||||||
|
const validRoles = ["read_only", "user", "project_manager", "admin"];
|
||||||
|
if (data.role && !validRoles.includes(data.role)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid role specified" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = await createUser({
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
role: data.role || "user",
|
||||||
|
is_active: data.is_active !== undefined ? data.is_active : true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove password hash from response
|
||||||
|
const { password_hash, ...safeUser } = newUser;
|
||||||
|
return NextResponse.json(safeUser, { status: 201 });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating user:", error);
|
||||||
|
|
||||||
|
if (error.message.includes("already exists")) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "A user with this email already exists" },
|
||||||
|
{ status: 409 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create user" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require admin authentication
|
||||||
|
export const GET = withAdminAuth(getUsersHandler);
|
||||||
|
export const POST = withAdminAuth(createUserHandler);
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { getAllProjectTasks } from "@/lib/queries/tasks";
|
import { getAllProjectTasks } from "@/lib/queries/tasks";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
// GET: Get all project tasks across all projects
|
// GET: Get all project tasks across all projects
|
||||||
export async function GET() {
|
async function getAllProjectTasksHandler() {
|
||||||
try {
|
try {
|
||||||
const tasks = getAllProjectTasks();
|
const tasks = getAllProjectTasks();
|
||||||
return NextResponse.json(tasks);
|
return NextResponse.json(tasks);
|
||||||
@@ -13,3 +14,6 @@ export async function GET() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getAllProjectTasksHandler);
|
||||||
|
|||||||
49
src/app/api/audit-logs/log/route.js
Normal file
49
src/app/api/audit-logs/log/route.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Force this API route to use Node.js runtime for database access
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { logAuditEvent } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
|
||||||
|
const {
|
||||||
|
action,
|
||||||
|
userId,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details,
|
||||||
|
timestamp,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Action is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the audit event
|
||||||
|
await logAuditEvent({
|
||||||
|
action,
|
||||||
|
userId,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Audit log API error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/audit-logs/route.js
Normal file
67
src/app/api/audit-logs/route.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Force this API route to use Node.js runtime
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getAuditLogs, getAuditLogStats } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admins and project managers can view audit logs
|
||||||
|
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
const filters = {
|
||||||
|
userId: searchParams.get("userId") || null,
|
||||||
|
action: searchParams.get("action") || null,
|
||||||
|
resourceType: searchParams.get("resourceType") || null,
|
||||||
|
resourceId: searchParams.get("resourceId") || null,
|
||||||
|
startDate: searchParams.get("startDate") || null,
|
||||||
|
endDate: searchParams.get("endDate") || null,
|
||||||
|
limit: parseInt(searchParams.get("limit")) || 100,
|
||||||
|
offset: parseInt(searchParams.get("offset")) || 0,
|
||||||
|
orderBy: searchParams.get("orderBy") || "timestamp",
|
||||||
|
orderDirection: searchParams.get("orderDirection") || "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get audit logs
|
||||||
|
const logs = await getAuditLogs(filters);
|
||||||
|
|
||||||
|
// Get statistics if requested
|
||||||
|
const includeStats = searchParams.get("includeStats") === "true";
|
||||||
|
let stats = null;
|
||||||
|
|
||||||
|
if (includeStats) {
|
||||||
|
stats = await getAuditLogStats({
|
||||||
|
startDate: filters.startDate,
|
||||||
|
endDate: filters.endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: logs,
|
||||||
|
stats,
|
||||||
|
filters: {
|
||||||
|
...filters,
|
||||||
|
total: logs.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Audit logs API error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/app/api/audit-logs/stats/route.js
Normal file
41
src/app/api/audit-logs/stats/route.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Force this API route to use Node.js runtime
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getAuditLogStats } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admins and project managers can view audit log statistics
|
||||||
|
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
startDate: searchParams.get("startDate") || null,
|
||||||
|
endDate: searchParams.get("endDate") || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = await getAuditLogStats(filters);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Audit log stats API error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/app/api/auth/[...nextauth]/route.js
Normal file
3
src/app/api/auth/[...nextauth]/route.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth"
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
export async function GET(req, { params }) {
|
async function getContractHandler(req, { params }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const contract = db
|
const contract = db
|
||||||
@@ -20,7 +21,7 @@ export async function GET(req, { params }) {
|
|||||||
return NextResponse.json(contract);
|
return NextResponse.json(contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req, { params }) {
|
async function deleteContractHandler(req, { params }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,3 +58,7 @@ export async function DELETE(req, { params }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getContractHandler);
|
||||||
|
export const DELETE = withUserAuth(deleteContractHandler);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
export async function GET() {
|
async function getContractsHandler() {
|
||||||
const contracts = db
|
const contracts = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
@@ -21,7 +22,7 @@ export async function GET() {
|
|||||||
return NextResponse.json(contracts);
|
return NextResponse.json(contracts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req) {
|
async function createContractHandler(req) {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -46,3 +47,7 @@ export async function POST(req) {
|
|||||||
);
|
);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getContractsHandler);
|
||||||
|
export const POST = withUserAuth(createContractHandler);
|
||||||
|
|||||||
37
src/app/api/debug-auth/route.js
Normal file
37
src/app/api/debug-auth/route.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
export const GET = auth(async (req) => {
|
||||||
|
try {
|
||||||
|
console.log("=== DEBUG AUTH ENDPOINT ===")
|
||||||
|
console.log("Request URL:", req.url)
|
||||||
|
console.log("Auth object:", req.auth)
|
||||||
|
|
||||||
|
if (!req.auth?.user) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "No session found",
|
||||||
|
debug: {
|
||||||
|
hasAuth: !!req.auth,
|
||||||
|
authKeys: req.auth ? Object.keys(req.auth) : [],
|
||||||
|
}
|
||||||
|
}, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: "Authenticated",
|
||||||
|
user: req.auth.user,
|
||||||
|
debug: {
|
||||||
|
authKeys: Object.keys(req.auth),
|
||||||
|
userKeys: Object.keys(req.auth.user)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth debug error:", error)
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Auth error",
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack
|
||||||
|
}, { status: 500 })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,32 +1,82 @@
|
|||||||
|
// Force this API route to use Node.js runtime for database access
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
import {
|
||||||
|
logApiActionSafe,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "@/lib/auditLogSafe.js";
|
||||||
|
|
||||||
export async function POST(req) {
|
async function createNoteHandler(req) {
|
||||||
const { project_id, task_id, note } = await req.json();
|
const { project_id, task_id, note } = await req.json();
|
||||||
|
|
||||||
if (!note || (!project_id && !task_id)) {
|
if (!note || (!project_id && !task_id)) {
|
||||||
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(
|
try {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO notes (project_id, task_id, note)
|
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
`
|
`
|
||||||
).run(project_id || null, task_id || null, note);
|
)
|
||||||
|
.run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||||
|
|
||||||
|
// Log note creation
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.NOTE_CREATE,
|
||||||
|
RESOURCE_TYPES.NOTE,
|
||||||
|
result.lastInsertRowid.toString(),
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
noteData: { project_id, task_id, note_length: note.length },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating note:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create note", details: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_, { params }) {
|
async function deleteNoteHandler(req, { params }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
|
// Get note data before deletion for audit log
|
||||||
|
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||||
|
|
||||||
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
||||||
|
|
||||||
|
// Log note deletion
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.NOTE_DELETE,
|
||||||
|
RESOURCE_TYPES.NOTE,
|
||||||
|
id,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
deletedNote: {
|
||||||
|
project_id: note?.project_id,
|
||||||
|
task_id: note?.task_id,
|
||||||
|
note_length: note?.note?.length || 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(req, { params }) {
|
async function updateNoteHandler(req, { params }) {
|
||||||
const noteId = params.id;
|
const noteId = params.id;
|
||||||
const { note } = await req.json();
|
const { note } = await req.json();
|
||||||
|
|
||||||
@@ -34,11 +84,40 @@ export async function PUT(req, { params }) {
|
|||||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get original note for audit log
|
||||||
|
const originalNote = db
|
||||||
|
.prepare("SELECT * FROM notes WHERE note_id = ?")
|
||||||
|
.get(noteId);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
UPDATE notes SET note = ? WHERE note_id = ?
|
UPDATE notes SET note = ? WHERE note_id = ?
|
||||||
`
|
`
|
||||||
).run(note, noteId);
|
).run(note, noteId);
|
||||||
|
|
||||||
|
// Log note update
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.NOTE_UPDATE,
|
||||||
|
RESOURCE_TYPES.NOTE,
|
||||||
|
noteId,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
originalNote: {
|
||||||
|
note_length: originalNote?.note?.length || 0,
|
||||||
|
project_id: originalNote?.project_id,
|
||||||
|
task_id: originalNote?.task_id,
|
||||||
|
},
|
||||||
|
updatedNote: {
|
||||||
|
note_length: note.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const POST = withUserAuth(createNoteHandler);
|
||||||
|
export const DELETE = withUserAuth(deleteNoteHandler);
|
||||||
|
export const PUT = withUserAuth(updateNoteHandler);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
deleteProjectTask,
|
deleteProjectTask,
|
||||||
} from "@/lib/queries/tasks";
|
} from "@/lib/queries/tasks";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
// PATCH: Update project task status
|
// PATCH: Update project task status
|
||||||
export async function PATCH(req, { params }) {
|
async function updateProjectTaskHandler(req, { params }) {
|
||||||
try {
|
try {
|
||||||
const { status } = await req.json();
|
const { status } = await req.json();
|
||||||
|
|
||||||
@@ -16,18 +17,19 @@ export async function PATCH(req, { params }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProjectTaskStatus(params.id, status);
|
updateProjectTaskStatus(params.id, status, req.user?.id || null);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error updating task status:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to update project task" },
|
{ error: "Failed to update project task", details: error.message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: Delete a project task
|
// DELETE: Delete a project task
|
||||||
export async function DELETE(req, { params }) {
|
async function deleteProjectTaskHandler(req, { params }) {
|
||||||
try {
|
try {
|
||||||
deleteProjectTask(params.id);
|
deleteProjectTask(params.id);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
@@ -38,3 +40,7 @@ export async function DELETE(req, { params }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const PATCH = withUserAuth(updateProjectTaskHandler);
|
||||||
|
export const DELETE = withUserAuth(deleteProjectTaskHandler);
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
} from "@/lib/queries/tasks";
|
} from "@/lib/queries/tasks";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
// GET: Get all project tasks or task templates based on query params
|
// GET: Get all project tasks or task templates based on query params
|
||||||
export async function GET(req) {
|
async function getProjectTasksHandler(req) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const projectId = searchParams.get("project_id");
|
const projectId = searchParams.get("project_id");
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ export async function GET(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST: Create a new project task
|
// POST: Create a new project task
|
||||||
export async function POST(req) {
|
async function createProjectTaskHandler(req) {
|
||||||
try {
|
try {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
|
|
||||||
@@ -42,11 +43,20 @@ export async function POST(req) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = createProjectTask(data);
|
// Add user tracking information from authenticated session
|
||||||
|
const taskData = {
|
||||||
|
...data,
|
||||||
|
created_by: req.user?.id || null,
|
||||||
|
// If no assigned_to is specified, default to the creator
|
||||||
|
assigned_to: data.assigned_to || req.user?.id || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = createProjectTask(taskData);
|
||||||
return NextResponse.json({ success: true, id: result.lastInsertRowid });
|
return NextResponse.json({ success: true, id: result.lastInsertRowid });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error creating project task:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to create project task" },
|
{ error: "Failed to create project task", details: error.message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,3 +123,7 @@ export async function PATCH(req) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getProjectTasksHandler);
|
||||||
|
export const POST = withUserAuth(createProjectTaskHandler);
|
||||||
|
|||||||
50
src/app/api/project-tasks/users/route.js
Normal file
50
src/app/api/project-tasks/users/route.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
updateProjectTaskAssignment,
|
||||||
|
getAllUsersForTaskAssignment,
|
||||||
|
} from "@/lib/queries/tasks";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// GET: Get all users for task assignment
|
||||||
|
async function getUsersForTaskAssignmentHandler(req) {
|
||||||
|
try {
|
||||||
|
const users = getAllUsersForTaskAssignment();
|
||||||
|
return NextResponse.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch users" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Update task assignment
|
||||||
|
async function updateTaskAssignmentHandler(req) {
|
||||||
|
try {
|
||||||
|
const { taskId, assignedToUserId } = await req.json();
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "taskId is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = updateProjectTaskAssignment(taskId, assignedToUserId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update task assignment" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
export const GET = withReadAuth(getUsersForTaskAssignmentHandler);
|
||||||
|
export const POST = withUserAuth(updateTaskAssignmentHandler);
|
||||||
@@ -1,22 +1,103 @@
|
|||||||
|
// Force this API route to use Node.js runtime for database access
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getProjectById,
|
getProjectById,
|
||||||
updateProject,
|
updateProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
} from "@/lib/queries/projects";
|
} from "@/lib/queries/projects";
|
||||||
|
import initializeDatabase from "@/lib/init-db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
import {
|
||||||
|
logApiActionSafe,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "@/lib/auditLogSafe.js";
|
||||||
|
|
||||||
|
// Make sure the DB is initialized before queries run
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
|
async function getProjectHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const project = getProjectById(parseInt(id));
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log project view
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
id,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{ project_name: project.project_name }
|
||||||
|
);
|
||||||
|
|
||||||
export async function GET(_, { params }) {
|
|
||||||
const project = getProjectById(params.id);
|
|
||||||
return NextResponse.json(project);
|
return NextResponse.json(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(req, { params }) {
|
async function updateProjectHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
updateProject(params.id, data);
|
|
||||||
|
// Get user ID from authenticated request
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
// Get original project data for audit log
|
||||||
|
const originalProject = getProjectById(parseInt(id));
|
||||||
|
|
||||||
|
updateProject(parseInt(id), data, userId);
|
||||||
|
|
||||||
|
// Get updated project
|
||||||
|
const updatedProject = getProjectById(parseInt(id));
|
||||||
|
|
||||||
|
// Log project update
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
id,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
originalData: originalProject,
|
||||||
|
updatedData: data,
|
||||||
|
changedFields: Object.keys(data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(updatedProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteProjectHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Get project data before deletion for audit log
|
||||||
|
const project = getProjectById(parseInt(id));
|
||||||
|
|
||||||
|
deleteProject(parseInt(id));
|
||||||
|
|
||||||
|
// Log project deletion
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_DELETE,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
id,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
deletedProject: {
|
||||||
|
project_name: project?.project_name,
|
||||||
|
project_number: project?.project_number,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_, { params }) {
|
// Protected routes - require authentication
|
||||||
deleteProject(params.id);
|
export const GET = withReadAuth(getProjectHandler);
|
||||||
return NextResponse.json({ success: true });
|
export const PUT = withUserAuth(updateProjectHandler);
|
||||||
}
|
export const DELETE = withUserAuth(deleteProjectHandler);
|
||||||
|
|||||||
@@ -1,20 +1,90 @@
|
|||||||
import { getAllProjects, createProject } from "@/lib/queries/projects";
|
// Force this API route to use Node.js runtime for database access
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllProjects,
|
||||||
|
createProject,
|
||||||
|
getAllUsersForAssignment,
|
||||||
|
} from "@/lib/queries/projects";
|
||||||
import initializeDatabase from "@/lib/init-db";
|
import initializeDatabase from "@/lib/init-db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
import {
|
||||||
|
logApiActionSafe,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "@/lib/auditLogSafe.js";
|
||||||
|
|
||||||
// Make sure the DB is initialized before queries run
|
// Make sure the DB is initialized before queries run
|
||||||
initializeDatabase();
|
initializeDatabase();
|
||||||
|
|
||||||
export async function GET(req) {
|
async function getProjectsHandler(req) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const contractId = searchParams.get("contract_id");
|
const contractId = searchParams.get("contract_id");
|
||||||
|
const assignedTo = searchParams.get("assigned_to");
|
||||||
|
const createdBy = searchParams.get("created_by");
|
||||||
|
|
||||||
|
let projects;
|
||||||
|
|
||||||
|
if (assignedTo) {
|
||||||
|
const { getProjectsByAssignedUser } = await import(
|
||||||
|
"@/lib/queries/projects"
|
||||||
|
);
|
||||||
|
projects = getProjectsByAssignedUser(assignedTo);
|
||||||
|
} else if (createdBy) {
|
||||||
|
const { getProjectsByCreator } = await import("@/lib/queries/projects");
|
||||||
|
projects = getProjectsByCreator(createdBy);
|
||||||
|
} else {
|
||||||
|
projects = getAllProjects(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log project list access
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
null, // No specific project ID for list view
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
filters: { contractId, assignedTo, createdBy },
|
||||||
|
resultCount: projects.length,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const projects = getAllProjects(contractId);
|
|
||||||
return NextResponse.json(projects);
|
return NextResponse.json(projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req) {
|
async function createProjectHandler(req) {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
createProject(data);
|
|
||||||
return NextResponse.json({ success: true });
|
// Get user ID from authenticated request
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
const result = createProject(data, userId);
|
||||||
|
const projectId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// Log project creation
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
projectId.toString(),
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
projectData: {
|
||||||
|
project_name: data.project_name,
|
||||||
|
project_number: data.project_number,
|
||||||
|
contract_id: data.contract_id,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
projectId: projectId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getProjectsHandler);
|
||||||
|
export const POST = withUserAuth(createProjectHandler);
|
||||||
|
|||||||
33
src/app/api/projects/users/route.js
Normal file
33
src/app/api/projects/users/route.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
getAllUsersForAssignment,
|
||||||
|
updateProjectAssignment,
|
||||||
|
} from "@/lib/queries/projects";
|
||||||
|
import initializeDatabase from "@/lib/init-db";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// Make sure the DB is initialized before queries run
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
|
async function getUsersHandler(req) {
|
||||||
|
const users = getAllUsersForAssignment();
|
||||||
|
return NextResponse.json(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAssignmentHandler(req) {
|
||||||
|
const { projectId, assignedToUserId } = await req.json();
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Project ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProjectAssignment(projectId, assignedToUserId);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withUserAuth(getUsersHandler);
|
||||||
|
export const POST = withUserAuth(updateAssignmentHandler);
|
||||||
@@ -4,9 +4,10 @@ import {
|
|||||||
deleteNote,
|
deleteNote,
|
||||||
} from "@/lib/queries/notes";
|
} from "@/lib/queries/notes";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
// GET: Get notes for a specific task
|
// GET: Get notes for a specific task
|
||||||
export async function GET(req) {
|
async function getTaskNotesHandler(req) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const taskId = searchParams.get("task_id");
|
const taskId = searchParams.get("task_id");
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ export async function GET(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// POST: Add a note to a task
|
// POST: Add a note to a task
|
||||||
export async function POST(req) {
|
async function addTaskNoteHandler(req) {
|
||||||
try {
|
try {
|
||||||
const { task_id, note, is_system } = await req.json();
|
const { task_id, note, is_system } = await req.json();
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ export async function POST(req) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addNoteToTask(task_id, note, is_system);
|
addNoteToTask(task_id, note, is_system, req.user?.id || null);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error adding task note:", error);
|
console.error("Error adding task note:", error);
|
||||||
@@ -49,7 +50,7 @@ export async function POST(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: Delete a note
|
// DELETE: Delete a note
|
||||||
export async function DELETE(req) {
|
async function deleteTaskNoteHandler(req) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const noteId = searchParams.get("note_id");
|
const noteId = searchParams.get("note_id");
|
||||||
@@ -71,3 +72,8 @@ export async function DELETE(req) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getTaskNotesHandler);
|
||||||
|
export const POST = withUserAuth(addTaskNoteHandler);
|
||||||
|
export const DELETE = withUserAuth(deleteTaskNoteHandler);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
// GET: Get a specific task template
|
// GET: Get a specific task template
|
||||||
export async function GET(req, { params }) {
|
async function getTaskHandler(req, { params }) {
|
||||||
try {
|
try {
|
||||||
const template = db
|
const template = db
|
||||||
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
|
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||||
@@ -25,7 +26,7 @@ export async function GET(req, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PUT: Update a task template
|
// PUT: Update a task template
|
||||||
export async function PUT(req, { params }) {
|
async function updateTaskHandler(req, { params }) {
|
||||||
try {
|
try {
|
||||||
const { name, max_wait_days, description } = await req.json();
|
const { name, max_wait_days, description } = await req.json();
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ export async function PUT(req, { params }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DELETE: Delete a task template
|
// DELETE: Delete a task template
|
||||||
export async function DELETE(req, { params }) {
|
async function deleteTaskHandler(req, { params }) {
|
||||||
try {
|
try {
|
||||||
const result = db
|
const result = db
|
||||||
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
|
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||||
@@ -79,3 +80,8 @@ export async function DELETE(req, { params }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getTaskHandler);
|
||||||
|
export const PUT = withUserAuth(updateTaskHandler);
|
||||||
|
export const DELETE = withUserAuth(deleteTaskHandler);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
|
||||||
|
import { getAllTaskTemplates } from "@/lib/queries/tasks";
|
||||||
|
|
||||||
// POST: create new template
|
// POST: create new template
|
||||||
export async function POST(req) {
|
async function createTaskHandler(req) {
|
||||||
const { name, max_wait_days, description } = await req.json();
|
const { name, max_wait_days, description } = await req.json();
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -18,3 +20,13 @@ export async function POST(req) {
|
|||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET: Get all task templates
|
||||||
|
async function getTasksHandler(req) {
|
||||||
|
const templates = getAllTaskTemplates();
|
||||||
|
return NextResponse.json(templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getTasksHandler);
|
||||||
|
export const POST = withUserAuth(createTaskHandler);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { getAllTaskTemplates } from "@/lib/queries/tasks";
|
import { getAllTaskTemplates } from "@/lib/queries/tasks";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { withReadAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
// GET: Get all task templates
|
// GET: Get all task templates
|
||||||
export async function GET() {
|
async function getTaskTemplatesHandler() {
|
||||||
const templates = getAllTaskTemplates();
|
const templates = getAllTaskTemplates();
|
||||||
return NextResponse.json(templates);
|
return NextResponse.json(templates);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getTaskTemplatesHandler);
|
||||||
|
|||||||
65
src/app/auth/error/page.js
Normal file
65
src/app/auth/error/page.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
|
function AuthErrorContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const error = searchParams.get('error')
|
||||||
|
|
||||||
|
const getErrorMessage = (error) => {
|
||||||
|
switch (error) {
|
||||||
|
case 'CredentialsSignin':
|
||||||
|
return 'Invalid email or password. Please check your credentials and try again.'
|
||||||
|
case 'AccessDenied':
|
||||||
|
return 'Access denied. You do not have permission to sign in.'
|
||||||
|
case 'Verification':
|
||||||
|
return 'The verification token has expired or has already been used.'
|
||||||
|
default:
|
||||||
|
return 'An unexpected error occurred during authentication. Please try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="mt-6 text-3xl font-extrabold text-gray-900">
|
||||||
|
Authentication Error
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
{getErrorMessage(error)}
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Error code: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-6">
|
||||||
|
<a
|
||||||
|
href="/auth/signin"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Back to Sign In
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuthError() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<AuthErrorContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
src/app/auth/signin/page.js
Normal file
142
src/app/auth/signin/page.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, Suspense } from "react"
|
||||||
|
import { signIn, getSession } from "next-auth/react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
function SignInContent() {
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const callbackUrl = searchParams.get("callbackUrl") || "/"
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsLoading(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError("Invalid email or password")
|
||||||
|
} else {
|
||||||
|
// Successful login
|
||||||
|
router.push(callbackUrl)
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError("An error occurred. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Sign in to your account
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Access the Project Management Panel
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="sr-only">
|
||||||
|
Email address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Email address"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="sr-only">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Signing in...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Sign in"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
||||||
|
<p className="font-medium">Default Admin Account:</p>
|
||||||
|
<p>Email: admin@localhost</p>
|
||||||
|
<p>Password: admin123456</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignIn() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<SignInContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import Navigation from "@/components/ui/Navigation";
|
import Navigation from "@/components/ui/Navigation";
|
||||||
|
import { AuthProvider } from "@/components/auth/AuthProvider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -23,8 +24,10 @@ export default function RootLayout({ children }) {
|
|||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
<AuthProvider>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
</AuthProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
@@ -24,6 +25,7 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import TaskStatusChart from "@/components/ui/TaskStatusChart";
|
import TaskStatusChart from "@/components/ui/TaskStatusChart";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalProjects: 0,
|
totalProjects: 0,
|
||||||
activeProjects: 0,
|
activeProjects: 0,
|
||||||
@@ -47,6 +49,12 @@ export default function Home() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only fetch data if user is authenticated
|
||||||
|
if (!session) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch all data concurrently
|
// Fetch all data concurrently
|
||||||
@@ -210,7 +218,7 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchDashboardData();
|
fetchDashboardData();
|
||||||
}, []);
|
}, [session]);
|
||||||
|
|
||||||
const getProjectStatusColor = (status) => {
|
const getProjectStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -257,10 +265,38 @@ export default function Home() {
|
|||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show loading state while session is being fetched
|
||||||
|
if (status === "loading") {
|
||||||
|
return <LoadingState message="Loading authentication..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show sign-in prompt if not authenticated
|
||||||
|
if (!session) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 mb-6">
|
||||||
|
Welcome to Project Management Panel
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-gray-600 mb-8">
|
||||||
|
Please sign in to access the project management system.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/auth/signin"
|
||||||
|
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title={`Welcome back, ${session.user.name}!`}
|
||||||
description="Overview of your projects, contracts, and tasks"
|
description="Overview of your projects, contracts, and tasks"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -1,17 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
import ProjectForm from "@/components/ProjectForm";
|
import ProjectForm from "@/components/ProjectForm";
|
||||||
import PageContainer from "@/components/ui/PageContainer";
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
import PageHeader from "@/components/ui/PageHeader";
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { LoadingState } from "@/components/ui/States";
|
||||||
|
|
||||||
export default async function EditProjectPage({ params }) {
|
export default function EditProjectPage() {
|
||||||
const { id } = await params;
|
const params = useParams();
|
||||||
const res = await fetch(`http://localhost:3000/api/projects/${id}`, {
|
const id = params.id;
|
||||||
cache: "no-store",
|
const [project, setProject] = useState(null);
|
||||||
});
|
const [loading, setLoading] = useState(true);
|
||||||
const project = await res.json();
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
if (!project) {
|
useEffect(() => {
|
||||||
|
const fetchProject = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const projectData = await res.json();
|
||||||
|
setProject(projectData);
|
||||||
|
} else {
|
||||||
|
setError("Project not found");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to load project");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
fetchProject();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<LoadingState />
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !project) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import { formatDate } from "@/lib/utils";
|
|||||||
import PageContainer from "@/components/ui/PageContainer";
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
import PageHeader from "@/components/ui/PageHeader";
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
|
import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
|
||||||
import ProjectMap from "@/components/ui/ProjectMap";
|
import ClientProjectMap from "@/components/ui/ClientProjectMap";
|
||||||
|
|
||||||
export default async function ProjectViewPage({ params }) {
|
export default async function ProjectViewPage({ params }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const project = getProjectWithContract(id);
|
const project = await getProjectWithContract(id);
|
||||||
const notes = getNotesForProject(id);
|
const notes = await getNotesForProject(id);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return (
|
return (
|
||||||
@@ -400,12 +400,20 @@ export default async function ProjectViewPage({ params }) {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{" "}
|
{" "}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader> <div className="flex items-center justify-between">
|
<CardHeader>
|
||||||
|
{" "}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
Project Location
|
Project Location
|
||||||
</h2>
|
</h2>
|
||||||
{project.coordinates && (
|
{project.coordinates && (
|
||||||
<Link href={`/projects/map?lat=${project.coordinates.split(',')[0].trim()}&lng=${project.coordinates.split(',')[1].trim()}&zoom=16`}>
|
<Link
|
||||||
|
href={`/projects/map?lat=${project.coordinates
|
||||||
|
.split(",")[0]
|
||||||
|
.trim()}&lng=${project.coordinates
|
||||||
|
.split(",")[1]
|
||||||
|
.trim()}&zoom=16`}
|
||||||
|
>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 mr-2"
|
className="w-4 h-4 mr-2"
|
||||||
@@ -427,7 +435,7 @@ export default async function ProjectViewPage({ params }) {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ProjectMap
|
<ClientProjectMap
|
||||||
coordinates={project.coordinates}
|
coordinates={project.coordinates}
|
||||||
projectName={project.project_name}
|
projectName={project.project_name}
|
||||||
projectStatus={project.project_status}
|
projectStatus={project.project_status}
|
||||||
@@ -481,9 +489,16 @@ export default async function ProjectViewPage({ params }) {
|
|||||||
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-medium text-gray-500">
|
<span className="text-sm font-medium text-gray-500">
|
||||||
{n.note_date}
|
{n.note_date}
|
||||||
</span>
|
</span>
|
||||||
|
{n.created_by_name && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
|
||||||
|
{n.created_by_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-900 leading-relaxed">{n.note}</p>
|
<p className="text-gray-900 leading-relaxed">{n.note}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
928
src/app/projects/map/page-old.js
Normal file
928
src/app/projects/map/page-old.js
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import { mapLayers } from "@/components/ui/mapLayers";
|
||||||
|
|
||||||
|
// Dynamically import the map component to avoid SSR issues
|
||||||
|
const DynamicMap = dynamic(() => import("@/components/ui/LeafletMap"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="w-full h-96 bg-gray-100 animate-pulse rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-gray-500">Loading map...</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ProjectsMapPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [mapCenter, setMapCenter] = useState([50.0614, 19.9366]); // Default to Krakow, Poland
|
||||||
|
const [mapZoom, setMapZoom] = useState(10); // Default zoom level
|
||||||
|
const [statusFilters, setStatusFilters] = useState({
|
||||||
|
registered: true,
|
||||||
|
in_progress_design: true,
|
||||||
|
in_progress_construction: true,
|
||||||
|
fulfilled: true,
|
||||||
|
});
|
||||||
|
const [activeBaseLayer, setActiveBaseLayer] = useState("OpenStreetMap");
|
||||||
|
const [activeOverlays, setActiveOverlays] = useState([]);
|
||||||
|
const [showLayerPanel, setShowLayerPanel] = useState(true);
|
||||||
|
const [currentTool, setCurrentTool] = useState("move"); // Current map tool
|
||||||
|
|
||||||
|
// Status configuration with colors and labels
|
||||||
|
const statusConfig = {
|
||||||
|
registered: {
|
||||||
|
color: "#6B7280",
|
||||||
|
label: "Registered",
|
||||||
|
shortLabel: "Zarejestr.",
|
||||||
|
},
|
||||||
|
in_progress_design: {
|
||||||
|
color: "#3B82F6",
|
||||||
|
label: "In Progress (Design)",
|
||||||
|
shortLabel: "W real. (P)",
|
||||||
|
},
|
||||||
|
in_progress_construction: {
|
||||||
|
color: "#F59E0B",
|
||||||
|
label: "In Progress (Construction)",
|
||||||
|
shortLabel: "W real. (R)",
|
||||||
|
},
|
||||||
|
fulfilled: {
|
||||||
|
color: "#10B981",
|
||||||
|
label: "Completed",
|
||||||
|
shortLabel: "Zakończony",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle all status filters
|
||||||
|
const toggleAllFilters = () => {
|
||||||
|
const allActive = Object.values(statusFilters).every((value) => value);
|
||||||
|
const newState = allActive
|
||||||
|
? Object.keys(statusFilters).reduce(
|
||||||
|
(acc, key) => ({ ...acc, [key]: false }),
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
: Object.keys(statusFilters).reduce(
|
||||||
|
(acc, key) => ({ ...acc, [key]: true }),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
setStatusFilters(newState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Toggle status filter
|
||||||
|
const toggleStatusFilter = (status) => {
|
||||||
|
setStatusFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[status]: !prev[status],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Layer control functions
|
||||||
|
const handleBaseLayerChange = (layerName) => {
|
||||||
|
setActiveBaseLayer(layerName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOverlay = (layerName) => {
|
||||||
|
setActiveOverlays((prev) => {
|
||||||
|
if (prev.includes(layerName)) {
|
||||||
|
return prev.filter((name) => name !== layerName);
|
||||||
|
} else {
|
||||||
|
return [...prev, layerName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLayerPanel = () => {
|
||||||
|
setShowLayerPanel(!showLayerPanel);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update URL with current map state (debounced to avoid too many updates)
|
||||||
|
const updateURL = (center, zoom) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("lat", center[0].toFixed(6));
|
||||||
|
params.set("lng", center[1].toFixed(6));
|
||||||
|
params.set("zoom", zoom.toString());
|
||||||
|
|
||||||
|
// Use replace to avoid cluttering browser history
|
||||||
|
router.replace(`/projects/map?${params.toString()}`, { scroll: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle map view changes with debouncing
|
||||||
|
const handleMapViewChange = (center, zoom) => {
|
||||||
|
setMapCenter(center);
|
||||||
|
setMapZoom(zoom);
|
||||||
|
|
||||||
|
// Debounce URL updates to avoid too many history entries
|
||||||
|
clearTimeout(window.mapUpdateTimeout);
|
||||||
|
window.mapUpdateTimeout = setTimeout(() => {
|
||||||
|
updateURL(center, zoom);
|
||||||
|
}, 500); // Wait 500ms after the last move to update URL
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hide navigation and ensure full-screen layout
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for URL parameters for coordinates and zoom
|
||||||
|
const lat = searchParams.get("lat");
|
||||||
|
const lng = searchParams.get("lng");
|
||||||
|
const zoom = searchParams.get("zoom");
|
||||||
|
|
||||||
|
if (lat && lng) {
|
||||||
|
const latitude = parseFloat(lat);
|
||||||
|
const longitude = parseFloat(lng);
|
||||||
|
if (!isNaN(latitude) && !isNaN(longitude)) {
|
||||||
|
setMapCenter([latitude, longitude]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoom) {
|
||||||
|
const zoomLevel = parseInt(zoom);
|
||||||
|
if (!isNaN(zoomLevel) && zoomLevel >= 1 && zoomLevel <= 20) {
|
||||||
|
setMapZoom(zoomLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide navigation bar for full-screen experience
|
||||||
|
const nav = document.querySelector("nav");
|
||||||
|
if (nav) {
|
||||||
|
nav.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent scrolling on body
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
document.documentElement.style.overflow = "hidden";
|
||||||
|
|
||||||
|
// Cleanup when leaving page
|
||||||
|
return () => {
|
||||||
|
if (nav) {
|
||||||
|
nav.style.display = "";
|
||||||
|
}
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
document.documentElement.style.overflow = "";
|
||||||
|
|
||||||
|
// Clear any pending URL updates
|
||||||
|
if (window.mapUpdateTimeout) {
|
||||||
|
clearTimeout(window.mapUpdateTimeout);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/projects")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
setProjects(data);
|
||||||
|
|
||||||
|
// Only calculate center based on projects if no URL parameters are provided
|
||||||
|
const lat = searchParams.get("lat");
|
||||||
|
const lng = searchParams.get("lng");
|
||||||
|
|
||||||
|
if (!lat || !lng) {
|
||||||
|
// Calculate center based on projects with coordinates
|
||||||
|
const projectsWithCoords = data.filter((p) => p.coordinates);
|
||||||
|
if (projectsWithCoords.length > 0) {
|
||||||
|
const avgLat =
|
||||||
|
projectsWithCoords.reduce((sum, p) => {
|
||||||
|
const [lat] = p.coordinates
|
||||||
|
.split(",")
|
||||||
|
.map((coord) => parseFloat(coord.trim()));
|
||||||
|
return sum + lat;
|
||||||
|
}, 0) / projectsWithCoords.length;
|
||||||
|
|
||||||
|
const avgLng =
|
||||||
|
projectsWithCoords.reduce((sum, p) => {
|
||||||
|
const [, lng] = p.coordinates
|
||||||
|
.split(",")
|
||||||
|
.map((coord) => parseFloat(coord.trim()));
|
||||||
|
return sum + lng;
|
||||||
|
}, 0) / projectsWithCoords.length;
|
||||||
|
|
||||||
|
setMapCenter([avgLat, avgLng]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching projects:", error);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Convert projects to map markers with filtering
|
||||||
|
const markers = projects
|
||||||
|
.filter((project) => project.coordinates)
|
||||||
|
.filter((project) => statusFilters[project.project_status] !== false)
|
||||||
|
.map((project) => {
|
||||||
|
const [lat, lng] = project.coordinates
|
||||||
|
.split(",")
|
||||||
|
.map((coord) => parseFloat(coord.trim()));
|
||||||
|
if (isNaN(lat) || isNaN(lng)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo =
|
||||||
|
statusConfig[project.project_status] || statusConfig.registered;
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: [lat, lng],
|
||||||
|
color: statusInfo.color,
|
||||||
|
popup: (
|
||||||
|
<div className="min-w-72 max-w-80">
|
||||||
|
<div className="mb-3 pb-2 border-b border-gray-200">
|
||||||
|
<h3 className="font-semibold text-base mb-1 text-gray-900">
|
||||||
|
{project.project_name}
|
||||||
|
</h3>
|
||||||
|
{project.project_number && (
|
||||||
|
<div className="inline-block bg-blue-100 text-blue-800 text-xs font-medium px-2 py-1 rounded-full">
|
||||||
|
{project.project_number}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm text-gray-600 mb-3">
|
||||||
|
{project.address && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mt-0.5 text-gray-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">
|
||||||
|
{project.address}
|
||||||
|
</span>
|
||||||
|
{project.city && (
|
||||||
|
<span className="text-gray-500">, {project.city}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{project.wp && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">WP:</span>{" "}
|
||||||
|
{project.wp}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.plot && (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-700">Plot:</span>{" "}
|
||||||
|
{project.plot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{project.project_status && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-700">Status:</span>
|
||||||
|
<span
|
||||||
|
className="inline-block px-2 py-1 rounded-full text-xs font-medium text-white"
|
||||||
|
style={{ backgroundColor: statusInfo.color }}
|
||||||
|
>
|
||||||
|
{statusInfo.shortLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 border-t border-gray-200">
|
||||||
|
<Link href={`/projects/${project.project_id}`}>
|
||||||
|
<Button variant="primary" size="sm" className="w-full">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
View Project Details
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((marker) => marker !== null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 mx-auto mb-4 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
|
||||||
|
<p className="text-gray-600 font-medium">Loading projects map...</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Preparing your full-screen map experience
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-gray-50 overflow-hidden">
|
||||||
|
{/* Floating Header - Left Side */}
|
||||||
|
<div className="absolute top-4 left-4 z-[1000]">
|
||||||
|
{/* Title Box */}
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-lg font-semibold text-gray-900">
|
||||||
|
Projects Map
|
||||||
|
</h1>
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
{markers.length} of {projects.length} projects with coordinates
|
||||||
|
</div>
|
||||||
|
</div>{" "}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Zoom Controls - Below Title */}
|
||||||
|
<div className="absolute top-20 left-4 z-[1000]">
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 flex flex-col">
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 hover:bg-gray-50 transition-colors duration-200 border-b border-gray-200 text-gray-700 font-medium text-lg"
|
||||||
|
onClick={() => {
|
||||||
|
// This will be handled by the map component
|
||||||
|
const event = new CustomEvent("mapZoomIn");
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
title="Zoom In"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-3 py-2 hover:bg-gray-50 transition-colors duration-200 text-gray-700 font-medium text-lg"
|
||||||
|
onClick={() => {
|
||||||
|
// This will be handled by the map component
|
||||||
|
const event = new CustomEvent("mapZoomOut");
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}}
|
||||||
|
title="Zoom Out"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>{" "}
|
||||||
|
</div>
|
||||||
|
</div>{" "}
|
||||||
|
{/* Tool Panel - Below Zoom Controls */}
|
||||||
|
<div className="absolute top-48 left-4 z-[1000]">
|
||||||
|
{" "}
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 flex flex-col">
|
||||||
|
{" "}
|
||||||
|
{/* Move Tool */}
|
||||||
|
<button
|
||||||
|
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||||
|
currentTool === "move"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrentTool("move")}
|
||||||
|
title="Move Tool (Pan Map)"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 512 512">
|
||||||
|
<path d="M256 0c-25.3 0-47.2 14.7-57.6 36c-7-2.6-14.5-4-22.4-4c-35.3 0-64 28.7-64 64l0 165.5-2.7-2.7c-25-25-65.5-25-90.5 0s-25 65.5 0 90.5L106.5 437c48 48 113.1 75 181 75l8.5 0 8 0c1.5 0 3-.1 4.5-.4c91.7-6.2 165-79.4 171.1-171.1c.3-1.5 .4-3 .4-4.5l0-176c0-35.3-28.7-64-64-64c-5.5 0-10.9 .7-16 2l0-2c0-35.3-28.7-64-64-64c-7.9 0-15.4 1.4-22.4 4C303.2 14.7 281.3 0 256 0zM240 96.1l0-.1 0-32c0-8.8 7.2-16 16-16s16 7.2 16 16l0 31.9 0 .1 0 136c0 13.3 10.7 24 24 24s24-10.7 24-24l0-136c0 0 0 0 0-.1c0-8.8 7.2-16 16-16s16 7.2 16 16l0 55.9c0 0 0 .1 0 .1l0 80c0 13.3 10.7 24 24 24s24-10.7 24-24l0-71.9c0 0 0-.1 0-.1c0-8.8 7.2-16 16-16s16 7.2 16 16l0 172.9c-.1 .6-.1 1.3-.2 1.9c-3.4 69.7-59.3 125.6-129 129c-.6 0-1.3 .1-1.9 .2l-4.9 0-8.5 0c-55.2 0-108.1-21.9-147.1-60.9L52.7 315.3c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0L119 336.4c6.9 6.9 17.2 8.9 26.2 5.2s14.8-12.5 14.8-22.2L160 96c0-8.8 7.2-16 16-16c8.8 0 16 7.1 16 15.9L192 232c0 13.3 10.7 24 24 24s24-10.7 24-24l0-135.9z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Select Tool */}
|
||||||
|
<button
|
||||||
|
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||||
|
currentTool === "select"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrentTool("select")}
|
||||||
|
title="Select Tool"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Measure Tool */}
|
||||||
|
<button
|
||||||
|
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||||
|
currentTool === "measure"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrentTool("measure")}
|
||||||
|
title="Measure Distance"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 21l10-10M7 21H3v-4l10-10 4 4M7 21l4-4M17 7l4-4M17 7l-4-4M17 7l-4 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Draw Tool */}
|
||||||
|
<button
|
||||||
|
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||||
|
currentTool === "draw"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrentTool("draw")}
|
||||||
|
title="Draw/Markup"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Pin/Marker Tool */}
|
||||||
|
<button
|
||||||
|
className={`p-3 transition-colors duration-200 border-b border-gray-200 ${
|
||||||
|
currentTool === "pin"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrentTool("pin")}
|
||||||
|
title="Add Pin/Marker"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/* Area Tool */}
|
||||||
|
<button
|
||||||
|
className={`p-3 transition-colors duration-200 ${
|
||||||
|
currentTool === "area"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setCurrentTool("area")}
|
||||||
|
title="Measure Area"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 8V6a2 2 0 012-2h2M4 16v2a2 2 0 002 2h2m8-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Layer Control Panel - Right Side */}
|
||||||
|
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Link href="/projects">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="bg-white/95 backdrop-blur-sm border-gray-200 shadow-lg hover:bg-white"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 10h16M4 14h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
List View
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/projects/new">
|
||||||
|
<Button variant="primary" size="sm" className="shadow-lg">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 4v16m8-8H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Add Project
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Layer Control Panel */}
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg border border-gray-200 layer-panel-container">
|
||||||
|
{/* Layer Control Header */}
|
||||||
|
<div className="px-4 py-3 border-b border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={toggleLayerPanel}
|
||||||
|
className="flex items-center justify-between w-full text-left layer-toggle-button"
|
||||||
|
title="Toggle Layer Controls"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
Map Layers
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||||
|
{1 + activeOverlays.length} active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${
|
||||||
|
showLayerPanel ? "rotate-180" : ""
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>{" "}
|
||||||
|
{/* Layer Control Content */}
|
||||||
|
<div
|
||||||
|
className={`transition-all duration-300 ease-in-out ${
|
||||||
|
showLayerPanel
|
||||||
|
? "max-h-[70vh] opacity-100 overflow-visible"
|
||||||
|
: "max-h-0 opacity-0 overflow-hidden"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="p-4 min-w-80 max-w-96 max-h-[60vh] overflow-y-auto">
|
||||||
|
{/* Base Layers Section */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Base Maps
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mapLayers.base.map((layer, index) => (
|
||||||
|
<label
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="baseLayer"
|
||||||
|
checked={activeBaseLayer === layer.name}
|
||||||
|
onChange={() => handleBaseLayerChange(layer.name)}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 flex-1">
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay Layers Section */}
|
||||||
|
{mapLayers.overlays && mapLayers.overlays.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Overlay Layers
|
||||||
|
</h3>{" "}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mapLayers.overlays.map((layer, index) => (
|
||||||
|
<label
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={activeOverlays.includes(layer.name)}
|
||||||
|
onChange={() => toggleOverlay(layer.name)}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 flex-1">
|
||||||
|
{layer.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>{" "}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Status Filter Panel - Bottom Left */}
|
||||||
|
<div className="absolute bottom-4 left-4 z-[1000]">
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700 mr-2">
|
||||||
|
Filters:
|
||||||
|
</span>
|
||||||
|
{/* Toggle All Button */}
|
||||||
|
<button
|
||||||
|
onClick={toggleAllFilters}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
||||||
|
title="Toggle all filters"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{Object.values(statusFilters).every((v) => v)
|
||||||
|
? "Hide All"
|
||||||
|
: "Show All"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* Individual Status Filters */}
|
||||||
|
{Object.entries(statusConfig).map(([status, config]) => {
|
||||||
|
const isActive = statusFilters[status];
|
||||||
|
const projectCount = projects.filter(
|
||||||
|
(p) => p.project_status === status && p.coordinates
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => toggleStatusFilter(status)}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
||||||
|
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||||||
|
}`}
|
||||||
|
title={`Toggle ${config.label} (${projectCount} projects)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
||||||
|
isActive ? "border-white shadow-sm" : "border-gray-300"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive ? config.color : "#e5e7eb",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
className={`transition-colors duration-200 ${
|
||||||
|
isActive ? "text-gray-700" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.shortLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1 text-xs transition-colors duration-200 ${
|
||||||
|
isActive ? "text-gray-500" : "text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
({projectCount})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}{" "}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>{" "}
|
||||||
|
{/* Status Panel - Bottom Left */}
|
||||||
|
{markers.length > 0 && (
|
||||||
|
<div className="bg-white/95 backdrop-blur-sm rounded-lg shadow-lg px-4 py-3 border border-gray-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-700 mr-2">
|
||||||
|
Filters:
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Toggle All Button */}
|
||||||
|
<button
|
||||||
|
onClick={toggleAllFilters}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs font-medium bg-gray-100 hover:bg-gray-200 transition-colors duration-200 mr-2"
|
||||||
|
title="Toggle all filters"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3 h-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{Object.values(statusFilters).every((v) => v)
|
||||||
|
? "Hide All"
|
||||||
|
: "Show All"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Individual Status Filters */}
|
||||||
|
{Object.entries(statusConfig).map(([status, config]) => {
|
||||||
|
const isActive = statusFilters[status];
|
||||||
|
const projectCount = projects.filter(
|
||||||
|
(p) => p.project_status === status && p.coordinates
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => toggleStatusFilter(status)}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-all duration-200 hover:bg-gray-100 ${
|
||||||
|
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
|
||||||
|
}`}
|
||||||
|
title={`Toggle ${config.label} (${projectCount} projects)`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 rounded-full border-2 transition-all duration-200 ${
|
||||||
|
isActive ? "border-white shadow-sm" : "border-gray-300"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: isActive ? config.color : "#e5e7eb",
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<span
|
||||||
|
className={`transition-colors duration-200 ${
|
||||||
|
isActive ? "text-gray-700" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{config.shortLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`ml-1 text-xs transition-colors duration-200 ${
|
||||||
|
isActive ? "text-gray-500" : "text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
({projectCount})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}{" "}
|
||||||
|
{/* Full Screen Map */}
|
||||||
|
{markers.length === 0 ? (
|
||||||
|
<div className="h-full w-full flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="text-center max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<svg
|
||||||
|
className="w-16 h-16 mx-auto"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No projects with coordinates
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
Projects need coordinates to appear on the map. Add coordinates
|
||||||
|
when creating or editing projects.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Link href="/projects">
|
||||||
|
<Button variant="outline">View All Projects</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/projects/new">
|
||||||
|
<Button variant="primary">Add Project</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<DynamicMap
|
||||||
|
center={mapCenter}
|
||||||
|
zoom={mapZoom}
|
||||||
|
markers={markers}
|
||||||
|
showLayerControl={false}
|
||||||
|
defaultLayer={activeBaseLayer}
|
||||||
|
activeOverlays={activeOverlays}
|
||||||
|
onViewChange={handleMapViewChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}{" "}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState, Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
@@ -17,7 +17,7 @@ const DynamicMap = dynamic(() => import("@/components/ui/LeafletMap"), {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ProjectsMapPage() {
|
function ProjectsMapPageContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [projects, setProjects] = useState([]);
|
const [projects, setProjects] = useState([]);
|
||||||
@@ -541,7 +541,7 @@ export default function ProjectsMapPage() {
|
|||||||
{/* Layer Control Panel - Right Side */}
|
{/* Layer Control Panel - Right Side */}
|
||||||
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
|
<div className="absolute top-4 right-4 z-[1000] flex flex-col gap-3">
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 justify-end">
|
||||||
<Link href="/projects">
|
<Link href="/projects">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -926,3 +926,18 @@ export default function ProjectsMapPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ProjectsMapPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Loading map...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<ProjectsMapPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -195,7 +195,13 @@ export default function ProjectListPage() {
|
|||||||
</th>
|
</th>
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||||
Status
|
Status
|
||||||
</th>{" "}
|
</th>
|
||||||
|
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||||
|
Created By
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
|
||||||
|
Assigned To
|
||||||
|
</th>
|
||||||
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
|
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
@@ -275,6 +281,18 @@ export default function ProjectListPage() {
|
|||||||
? "Zakończony"
|
? "Zakończony"
|
||||||
: "-"}
|
: "-"}
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||||
|
title={project.created_by_name || "Unknown"}
|
||||||
|
>
|
||||||
|
{project.created_by_name || "Unknown"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-2 py-3 text-xs text-gray-600 truncate"
|
||||||
|
title={project.assigned_to_name || "Unassigned"}
|
||||||
|
>
|
||||||
|
{project.assigned_to_name || "Unassigned"}
|
||||||
|
</td>
|
||||||
<td className="px-2 py-3">
|
<td className="px-2 py-3">
|
||||||
<Link href={`/projects/${project.project_id}`}>
|
<Link href={`/projects/${project.project_id}`}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
424
src/components/AuditLogViewer.js
Normal file
424
src/components/AuditLogViewer.js
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export default function AuditLogViewer() {
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
const [stats, setStats] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
action: "",
|
||||||
|
resourceType: "",
|
||||||
|
userId: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [actionTypes, setActionTypes] = useState([]);
|
||||||
|
const [resourceTypes, setResourceTypes] = useState([]);
|
||||||
|
|
||||||
|
const fetchAuditLogs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
Object.entries(filters).forEach(([key, value]) => {
|
||||||
|
if (value && value !== "") {
|
||||||
|
queryParams.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
queryParams.append("includeStats", "true");
|
||||||
|
|
||||||
|
const response = await fetch(`/api/audit-logs?${queryParams}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch audit logs");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
setLogs(result.data);
|
||||||
|
setStats(result.stats);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set available filter options
|
||||||
|
setActionTypes([
|
||||||
|
"login",
|
||||||
|
"logout",
|
||||||
|
"login_failed",
|
||||||
|
"project_create",
|
||||||
|
"project_update",
|
||||||
|
"project_delete",
|
||||||
|
"project_view",
|
||||||
|
"task_create",
|
||||||
|
"task_update",
|
||||||
|
"task_delete",
|
||||||
|
"task_status_change",
|
||||||
|
"project_task_create",
|
||||||
|
"project_task_update",
|
||||||
|
"project_task_delete",
|
||||||
|
"contract_create",
|
||||||
|
"contract_update",
|
||||||
|
"contract_delete",
|
||||||
|
"note_create",
|
||||||
|
"note_update",
|
||||||
|
"note_delete",
|
||||||
|
"user_create",
|
||||||
|
"user_update",
|
||||||
|
"user_delete",
|
||||||
|
"user_role_change",
|
||||||
|
]);
|
||||||
|
|
||||||
|
setResourceTypes([
|
||||||
|
"project",
|
||||||
|
"task",
|
||||||
|
"project_task",
|
||||||
|
"contract",
|
||||||
|
"note",
|
||||||
|
"user",
|
||||||
|
"session",
|
||||||
|
"system",
|
||||||
|
]);
|
||||||
|
|
||||||
|
fetchAuditLogs();
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleFilterChange = (key, value) => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[key]: value,
|
||||||
|
offset: 0, // Reset pagination when filters change
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
fetchAuditLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setFilters({
|
||||||
|
action: "",
|
||||||
|
resourceType: "",
|
||||||
|
userId: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
offset: prev.offset + prev.limit,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filters.offset > 0) {
|
||||||
|
fetchAuditLogs();
|
||||||
|
}
|
||||||
|
}, [filters.offset]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const formatTimestamp = (timestamp) => {
|
||||||
|
try {
|
||||||
|
return format(new Date(timestamp), "yyyy-MM-dd HH:mm:ss");
|
||||||
|
} catch {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionColor = (action) => {
|
||||||
|
const colorMap = {
|
||||||
|
login: "text-green-600",
|
||||||
|
logout: "text-blue-600",
|
||||||
|
login_failed: "text-red-600",
|
||||||
|
create: "text-green-600",
|
||||||
|
update: "text-yellow-600",
|
||||||
|
delete: "text-red-600",
|
||||||
|
view: "text-gray-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, color] of Object.entries(colorMap)) {
|
||||||
|
if (action.includes(key)) {
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "text-gray-600";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Audit Logs</h1>
|
||||||
|
<p className="text-gray-600">View system activity and user actions</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Filters</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Action
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.action}
|
||||||
|
onChange={(e) => handleFilterChange("action", e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Actions</option>
|
||||||
|
{actionTypes.map((action) => (
|
||||||
|
<option key={action} value={action}>
|
||||||
|
{action.replace(/_/g, " ").toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Resource Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.resourceType}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("resourceType", e.target.value)
|
||||||
|
}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="">All Resources</option>
|
||||||
|
{resourceTypes.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type.replace(/_/g, " ").toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
User ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filters.userId}
|
||||||
|
onChange={(e) => handleFilterChange("userId", e.target.value)}
|
||||||
|
placeholder="Enter user ID"
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={filters.startDate}
|
||||||
|
onChange={(e) => handleFilterChange("startDate", e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={filters.endDate}
|
||||||
|
onChange={(e) => handleFilterChange("endDate", e.target.value)}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Limit
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={filters.limit}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleFilterChange("limit", parseInt(e.target.value))
|
||||||
|
}
|
||||||
|
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
<option value={200}>200</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Searching..." : "Search"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Clear Filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 className="text-lg font-semibold">Total Events</h3>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 className="text-lg font-semibold">Top Action</h3>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{stats.actionBreakdown[0]?.action || "N/A"}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold text-green-600">
|
||||||
|
{stats.actionBreakdown[0]?.count || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 className="text-lg font-semibold">Active Users</h3>
|
||||||
|
<p className="text-2xl font-bold text-purple-600">
|
||||||
|
{stats.userBreakdown.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 className="text-lg font-semibold">Resource Types</h3>
|
||||||
|
<p className="text-2xl font-bold text-orange-600">
|
||||||
|
{stats.resourceBreakdown.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded mb-6">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audit Logs Table */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Timestamp
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Action
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Resource
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
IP Address
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Details
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{logs.map((log) => (
|
||||||
|
<tr key={log.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{formatTimestamp(log.timestamp)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{log.user_name || "Anonymous"}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">{log.user_email}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<span
|
||||||
|
className={`font-medium ${getActionColor(log.action)}`}
|
||||||
|
>
|
||||||
|
{log.action.replace(/_/g, " ").toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{log.resource_type || "N/A"}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">
|
||||||
|
ID: {log.resource_id || "N/A"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{log.ip_address || "Unknown"}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-gray-500">
|
||||||
|
{log.details && (
|
||||||
|
<details className="cursor-pointer">
|
||||||
|
<summary className="text-blue-600 hover:text-blue-800">
|
||||||
|
View Details
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 text-xs bg-gray-100 p-2 rounded overflow-auto max-w-md">
|
||||||
|
{JSON.stringify(log.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logs.length === 0 && !loading && (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
No audit logs found matching your criteria.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
Showing {filters.offset + 1} to {filters.offset + logs.length}{" "}
|
||||||
|
results
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={loading || logs.length < filters.limit}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,22 +22,59 @@ export default function ProjectForm({ initialData = null }) {
|
|||||||
contact: "",
|
contact: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
coordinates: "",
|
coordinates: "",
|
||||||
project_type: initialData?.project_type || "design",
|
project_type: "design",
|
||||||
// project_status is not included in the form for creation or editing
|
assigned_to: "",
|
||||||
...initialData,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [contracts, setContracts] = useState([]);
|
const [contracts, setContracts] = useState([]);
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isEdit = !!initialData;
|
const isEdit = !!initialData;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Fetch contracts
|
||||||
fetch("/api/contracts")
|
fetch("/api/contracts")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then(setContracts);
|
.then(setContracts);
|
||||||
|
|
||||||
|
// Fetch users for assignment
|
||||||
|
fetch("/api/projects/users")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(setUsers);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Update form state when initialData changes (for edit mode)
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialData) {
|
||||||
|
setForm({
|
||||||
|
contract_id: "",
|
||||||
|
project_name: "",
|
||||||
|
address: "",
|
||||||
|
plot: "",
|
||||||
|
district: "",
|
||||||
|
unit: "",
|
||||||
|
city: "",
|
||||||
|
investment_number: "",
|
||||||
|
finish_date: "",
|
||||||
|
wp: "",
|
||||||
|
contact: "",
|
||||||
|
notes: "",
|
||||||
|
coordinates: "",
|
||||||
|
project_type: "design",
|
||||||
|
assigned_to: "",
|
||||||
|
...initialData,
|
||||||
|
// Ensure these defaults are preserved if not in initialData
|
||||||
|
project_type: initialData.project_type || "design",
|
||||||
|
assigned_to: initialData.assigned_to || "",
|
||||||
|
// Format finish_date for input if it exists
|
||||||
|
finish_date: initialData.finish_date
|
||||||
|
? formatDateForInput(initialData.finish_date)
|
||||||
|
: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
function handleChange(e) {
|
function handleChange(e) {
|
||||||
setForm({ ...form, [e.target.name]: e.target.value });
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
}
|
}
|
||||||
@@ -83,7 +120,7 @@ export default function ProjectForm({ initialData = null }) {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Contract and Project Type Section */}
|
{/* Contract and Project Type Section */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Contract <span className="text-red-500">*</span>
|
Contract <span className="text-red-500">*</span>
|
||||||
@@ -125,6 +162,25 @@ export default function ProjectForm({ initialData = null }) {
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Assigned To
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
name="assigned_to"
|
||||||
|
value={form.assigned_to || ""}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.name} ({user.email})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Basic Information Section */}
|
{/* Basic Information Section */}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import Badge from "./ui/Badge";
|
|||||||
|
|
||||||
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
||||||
const [taskTemplates, setTaskTemplates] = useState([]);
|
const [taskTemplates, setTaskTemplates] = useState([]);
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
const [taskType, setTaskType] = useState("template"); // "template" or "custom"
|
const [taskType, setTaskType] = useState("template"); // "template" or "custom"
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState("");
|
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||||||
const [customTaskName, setCustomTaskName] = useState("");
|
const [customTaskName, setCustomTaskName] = useState("");
|
||||||
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
|
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
|
||||||
const [customDescription, setCustomDescription] = useState("");
|
const [customDescription, setCustomDescription] = useState("");
|
||||||
const [priority, setPriority] = useState("normal");
|
const [priority, setPriority] = useState("normal");
|
||||||
|
const [assignedTo, setAssignedTo] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -19,6 +21,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
fetch("/api/tasks/templates")
|
fetch("/api/tasks/templates")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then(setTaskTemplates);
|
.then(setTaskTemplates);
|
||||||
|
|
||||||
|
// Fetch users for assignment
|
||||||
|
fetch("/api/project-tasks/users")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then(setUsers);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
@@ -34,6 +41,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
const requestData = {
|
const requestData = {
|
||||||
project_id: parseInt(projectId),
|
project_id: parseInt(projectId),
|
||||||
priority,
|
priority,
|
||||||
|
assigned_to: assignedTo || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (taskType === "template") {
|
if (taskType === "template") {
|
||||||
@@ -56,6 +64,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
setCustomMaxWaitDays("");
|
setCustomMaxWaitDays("");
|
||||||
setCustomDescription("");
|
setCustomDescription("");
|
||||||
setPriority("normal");
|
setPriority("normal");
|
||||||
|
setAssignedTo("");
|
||||||
if (onTaskAdded) onTaskAdded();
|
if (onTaskAdded) onTaskAdded();
|
||||||
} else {
|
} else {
|
||||||
alert("Failed to add task to project.");
|
alert("Failed to add task to project.");
|
||||||
@@ -158,6 +167,24 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Assign To <span className="text-gray-500 text-xs">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={assignedTo}
|
||||||
|
onChange={(e) => setAssignedTo(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{users.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.name} ({user.email})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Priority
|
Priority
|
||||||
|
|||||||
@@ -273,6 +273,28 @@ export default function ProjectTasksList() {
|
|||||||
<td className="px-4 py-3 text-sm text-gray-600">
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
{task.address || "N/A"}
|
{task.address || "N/A"}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{task.created_by_name ? (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{task.created_by_name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{task.created_by_email}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"N/A"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{task.assigned_to_name ? (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{task.assigned_to_name}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{task.assigned_to_email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">Unassigned</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
{showTimeLeft && (
|
{showTimeLeft && (
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -361,7 +383,7 @@ export default function ProjectTasksList() {
|
|||||||
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false }) => {
|
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false }) => {
|
||||||
const filteredTasks = filterTasks(tasks);
|
const filteredTasks = filterTasks(tasks);
|
||||||
const groupedTasks = groupTasksByName(filteredTasks);
|
const groupedTasks = groupTasksByName(filteredTasks);
|
||||||
const colSpan = showTimeLeft ? "8" : "7";
|
const colSpan = showTimeLeft ? "10" : "9";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -379,7 +401,13 @@ export default function ProjectTasksList() {
|
|||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||||
Address
|
Address
|
||||||
</th>{" "}
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||||
|
Created By
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||||
|
Assigned To
|
||||||
|
</th>
|
||||||
{showTimeLeft && (
|
{showTimeLeft && (
|
||||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
|
||||||
Time Left
|
Time Left
|
||||||
|
|||||||
@@ -517,6 +517,11 @@ export default function ProjectTasksSection({ projectId }) {
|
|||||||
System
|
System
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{note.created_by_name && (
|
||||||
|
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full font-medium">
|
||||||
|
{note.created_by_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-800">
|
<p className="text-sm text-gray-800">
|
||||||
{note.note}
|
{note.note}
|
||||||
@@ -525,6 +530,11 @@ export default function ProjectTasksSection({ projectId }) {
|
|||||||
{formatDate(note.note_date, {
|
{formatDate(note.note_date, {
|
||||||
includeTime: true,
|
includeTime: true,
|
||||||
})}
|
})}
|
||||||
|
{note.created_by_name && (
|
||||||
|
<span className="ml-2">
|
||||||
|
by {note.created_by_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!note.is_system && (
|
{!note.is_system && (
|
||||||
|
|||||||
11
src/components/auth/AuthProvider.js
Normal file
11
src/components/auth/AuthProvider.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react"
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
{children}
|
||||||
|
</SessionProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
src/components/ui/ClientProjectMap.js
Normal file
15
src/components/ui/ClientProjectMap.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
|
const ProjectMap = dynamic(
|
||||||
|
() => import("@/components/ui/ProjectMap"),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function ClientProjectMap(props) {
|
||||||
|
return <ProjectMap {...props} />;
|
||||||
|
}
|
||||||
@@ -56,8 +56,6 @@ function WMSLayer({ url, params, opacity = 1, attribution }) {
|
|||||||
// Fix for default markers in react-leaflet
|
// Fix for default markers in react-leaflet
|
||||||
const fixLeafletIcons = () => {
|
const fixLeafletIcons = () => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const L = require("leaflet");
|
|
||||||
|
|
||||||
delete L.Icon.Default.prototype._getIconUrl;
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
L.Icon.Default.mergeOptions({
|
L.Icon.Default.mergeOptions({
|
||||||
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
|
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
|
||||||
@@ -70,8 +68,6 @@ const fixLeafletIcons = () => {
|
|||||||
// Create colored marker icons
|
// Create colored marker icons
|
||||||
const createColoredMarkerIcon = (color) => {
|
const createColoredMarkerIcon = (color) => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const L = require("leaflet");
|
|
||||||
|
|
||||||
return new L.Icon({
|
return new L.Icon({
|
||||||
iconUrl: `data:image/svg+xml;base64,${btoa(`
|
iconUrl: `data:image/svg+xml;base64,${btoa(`
|
||||||
<svg width="25" height="41" viewBox="0 0 25 41" xmlns="http://www.w3.org/2000/svg">
|
<svg width="25" height="41" viewBox="0 0 25 41" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
|
||||||
const Navigation = () => {
|
const Navigation = () => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
const isActive = (path) => {
|
const isActive = (path) => {
|
||||||
if (path === "/") return pathname === "/";
|
if (path === "/") return pathname === "/";
|
||||||
// Exact match for paths
|
// Exact match for paths
|
||||||
@@ -13,6 +16,7 @@ const Navigation = () => {
|
|||||||
if (pathname.startsWith(path + "/")) return true;
|
if (pathname.startsWith(path + "/")) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/", label: "Dashboard" },
|
{ href: "/", label: "Dashboard" },
|
||||||
{ href: "/projects", label: "Projects" },
|
{ href: "/projects", label: "Projects" },
|
||||||
@@ -21,6 +25,20 @@ const Navigation = () => {
|
|||||||
{ href: "/contracts", label: "Contracts" },
|
{ href: "/contracts", label: "Contracts" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Add admin-only items
|
||||||
|
if (session?.user?.role === 'admin') {
|
||||||
|
navItems.push({ href: "/admin/users", label: "User Management" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await signOut({ callbackUrl: "/auth/signin" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't show navigation on auth pages
|
||||||
|
if (pathname.startsWith('/auth/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-white border-b border-gray-200">
|
<nav className="bg-white border-b border-gray-200">
|
||||||
<div className="max-w-6xl mx-auto px-6">
|
<div className="max-w-6xl mx-auto px-6">
|
||||||
@@ -31,6 +49,11 @@ const Navigation = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{status === "loading" ? (
|
||||||
|
<div className="text-gray-500">Loading...</div>
|
||||||
|
) : session ? (
|
||||||
|
<>
|
||||||
<div className="flex space-x-8">
|
<div className="flex space-x-8">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -46,6 +69,32 @@ const Navigation = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4 ml-8 pl-8 border-l border-gray-200">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-gray-900">{session.user.name}</div>
|
||||||
|
<div className="text-gray-500 capitalize">{session.user.role?.replace('_', ' ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/auth/signin"
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
424
src/lib/auditLog.js
Normal file
424
src/lib/auditLog.js
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* Audit log actions - standardized action types
|
||||||
|
*/
|
||||||
|
export const AUDIT_ACTIONS = {
|
||||||
|
// Authentication
|
||||||
|
LOGIN: "login",
|
||||||
|
LOGOUT: "logout",
|
||||||
|
LOGIN_FAILED: "login_failed",
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
PROJECT_CREATE: "project_create",
|
||||||
|
PROJECT_UPDATE: "project_update",
|
||||||
|
PROJECT_DELETE: "project_delete",
|
||||||
|
PROJECT_VIEW: "project_view",
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
TASK_CREATE: "task_create",
|
||||||
|
TASK_UPDATE: "task_update",
|
||||||
|
TASK_DELETE: "task_delete",
|
||||||
|
TASK_STATUS_CHANGE: "task_status_change",
|
||||||
|
|
||||||
|
// Project Tasks
|
||||||
|
PROJECT_TASK_CREATE: "project_task_create",
|
||||||
|
PROJECT_TASK_UPDATE: "project_task_update",
|
||||||
|
PROJECT_TASK_DELETE: "project_task_delete",
|
||||||
|
PROJECT_TASK_STATUS_CHANGE: "project_task_status_change",
|
||||||
|
|
||||||
|
// Contracts
|
||||||
|
CONTRACT_CREATE: "contract_create",
|
||||||
|
CONTRACT_UPDATE: "contract_update",
|
||||||
|
CONTRACT_DELETE: "contract_delete",
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
NOTE_CREATE: "note_create",
|
||||||
|
NOTE_UPDATE: "note_update",
|
||||||
|
NOTE_DELETE: "note_delete",
|
||||||
|
|
||||||
|
// Admin actions
|
||||||
|
USER_CREATE: "user_create",
|
||||||
|
USER_UPDATE: "user_update",
|
||||||
|
USER_DELETE: "user_delete",
|
||||||
|
USER_ROLE_CHANGE: "user_role_change",
|
||||||
|
|
||||||
|
// System actions
|
||||||
|
DATA_EXPORT: "data_export",
|
||||||
|
BULK_OPERATION: "bulk_operation",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource types for audit logging
|
||||||
|
*/
|
||||||
|
export const RESOURCE_TYPES = {
|
||||||
|
PROJECT: "project",
|
||||||
|
TASK: "task",
|
||||||
|
PROJECT_TASK: "project_task",
|
||||||
|
CONTRACT: "contract",
|
||||||
|
NOTE: "note",
|
||||||
|
USER: "user",
|
||||||
|
SESSION: "session",
|
||||||
|
SYSTEM: "system",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an audit event
|
||||||
|
* @param {Object} params - Audit log parameters
|
||||||
|
* @param {string} params.action - Action performed (use AUDIT_ACTIONS constants)
|
||||||
|
* @param {string} [params.userId] - ID of user performing the action
|
||||||
|
* @param {string} [params.resourceType] - Type of resource affected (use RESOURCE_TYPES constants)
|
||||||
|
* @param {string} [params.resourceId] - ID of the affected resource
|
||||||
|
* @param {string} [params.ipAddress] - IP address of the user
|
||||||
|
* @param {string} [params.userAgent] - User agent string
|
||||||
|
* @param {Object} [params.details] - Additional details about the action
|
||||||
|
* @param {string} [params.timestamp] - Custom timestamp (defaults to current time)
|
||||||
|
*/
|
||||||
|
export async function logAuditEvent({
|
||||||
|
action,
|
||||||
|
userId = null,
|
||||||
|
resourceType = null,
|
||||||
|
resourceId = null,
|
||||||
|
ipAddress = null,
|
||||||
|
userAgent = null,
|
||||||
|
details = null,
|
||||||
|
timestamp = null,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// Check if we're in Edge Runtime - if so, skip database operations
|
||||||
|
if (
|
||||||
|
typeof EdgeRuntime !== "undefined" ||
|
||||||
|
process.env.NEXT_RUNTIME === "edge"
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`[Audit Log - Edge Runtime] ${action} by user ${
|
||||||
|
userId || "anonymous"
|
||||||
|
} on ${resourceType}:${resourceId}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import to avoid Edge Runtime issues
|
||||||
|
const { default: db } = await import("./db.js");
|
||||||
|
|
||||||
|
const auditTimestamp = timestamp || new Date().toISOString();
|
||||||
|
const detailsJson = details ? JSON.stringify(details) : null;
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
user_id, action, resource_type, resource_id,
|
||||||
|
ip_address, user_agent, timestamp, details
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
auditTimestamp,
|
||||||
|
detailsJson
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Audit log: ${action} by user ${
|
||||||
|
userId || "anonymous"
|
||||||
|
} on ${resourceType}:${resourceId}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to log audit event:", error);
|
||||||
|
// Don't throw error to avoid breaking the main application flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit logs with filtering and pagination
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {string} [options.userId] - Filter by user ID
|
||||||
|
* @param {string} [options.action] - Filter by action
|
||||||
|
* @param {string} [options.resourceType] - Filter by resource type
|
||||||
|
* @param {string} [options.resourceId] - Filter by resource ID
|
||||||
|
* @param {string} [options.startDate] - Filter from this date (ISO string)
|
||||||
|
* @param {string} [options.endDate] - Filter until this date (ISO string)
|
||||||
|
* @param {number} [options.limit] - Maximum number of records to return
|
||||||
|
* @param {number} [options.offset] - Number of records to skip
|
||||||
|
* @param {string} [options.orderBy] - Order by field (default: timestamp)
|
||||||
|
* @param {string} [options.orderDirection] - Order direction (ASC/DESC, default: DESC)
|
||||||
|
* @returns {Array} Array of audit log entries
|
||||||
|
*/
|
||||||
|
export async function getAuditLogs({
|
||||||
|
userId = null,
|
||||||
|
action = null,
|
||||||
|
resourceType = null,
|
||||||
|
resourceId = null,
|
||||||
|
startDate = null,
|
||||||
|
endDate = null,
|
||||||
|
limit = 100,
|
||||||
|
offset = 0,
|
||||||
|
orderBy = "timestamp",
|
||||||
|
orderDirection = "DESC",
|
||||||
|
} = {}) {
|
||||||
|
try {
|
||||||
|
// Check if we're in Edge Runtime - if so, return empty array
|
||||||
|
if (
|
||||||
|
typeof EdgeRuntime !== "undefined" ||
|
||||||
|
process.env.NEXT_RUNTIME === "edge"
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"[Audit Log - Edge Runtime] Cannot query audit logs in Edge Runtime"
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import to avoid Edge Runtime issues
|
||||||
|
const { default: db } = await import("./db.js");
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
al.*,
|
||||||
|
u.name as user_name,
|
||||||
|
u.email as user_email
|
||||||
|
FROM audit_logs al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE 1=1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
query += " AND al.user_id = ?";
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
query += " AND al.action = ?";
|
||||||
|
params.push(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceType) {
|
||||||
|
query += " AND al.resource_type = ?";
|
||||||
|
params.push(resourceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId) {
|
||||||
|
query += " AND al.resource_id = ?";
|
||||||
|
params.push(resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
query += " AND al.timestamp >= ?";
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
query += " AND al.timestamp <= ?";
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate order direction
|
||||||
|
const validOrderDirection = ["ASC", "DESC"].includes(
|
||||||
|
orderDirection.toUpperCase()
|
||||||
|
)
|
||||||
|
? orderDirection.toUpperCase()
|
||||||
|
: "DESC";
|
||||||
|
|
||||||
|
// Validate order by field
|
||||||
|
const validOrderFields = [
|
||||||
|
"timestamp",
|
||||||
|
"action",
|
||||||
|
"user_id",
|
||||||
|
"resource_type",
|
||||||
|
"resource_id",
|
||||||
|
];
|
||||||
|
const validOrderBy = validOrderFields.includes(orderBy)
|
||||||
|
? orderBy
|
||||||
|
: "timestamp";
|
||||||
|
|
||||||
|
query += ` ORDER BY al.${validOrderBy} ${validOrderDirection}`;
|
||||||
|
|
||||||
|
if (limit) {
|
||||||
|
query += " LIMIT ?";
|
||||||
|
params.push(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset) {
|
||||||
|
query += " OFFSET ?";
|
||||||
|
params.push(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare(query);
|
||||||
|
const results = stmt.all(...params);
|
||||||
|
|
||||||
|
// Parse details JSON for each result
|
||||||
|
return results.map((log) => ({
|
||||||
|
...log,
|
||||||
|
details: log.details ? JSON.parse(log.details) : null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get audit logs:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audit log statistics
|
||||||
|
* @param {Object} options - Query options
|
||||||
|
* @param {string} [options.startDate] - Filter from this date (ISO string)
|
||||||
|
* @param {string} [options.endDate] - Filter until this date (ISO string)
|
||||||
|
* @returns {Object} Statistics object
|
||||||
|
*/
|
||||||
|
export async function getAuditLogStats({
|
||||||
|
startDate = null,
|
||||||
|
endDate = null,
|
||||||
|
} = {}) {
|
||||||
|
try {
|
||||||
|
// Check if we're in Edge Runtime - if so, return empty stats
|
||||||
|
if (
|
||||||
|
typeof EdgeRuntime !== "undefined" ||
|
||||||
|
process.env.NEXT_RUNTIME === "edge"
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
"[Audit Log - Edge Runtime] Cannot query audit log stats in Edge Runtime"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
actionBreakdown: [],
|
||||||
|
userBreakdown: [],
|
||||||
|
resourceBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic import to avoid Edge Runtime issues
|
||||||
|
const { default: db } = await import("./db.js");
|
||||||
|
|
||||||
|
let baseQuery = "FROM audit_logs WHERE 1=1";
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
baseQuery += " AND timestamp >= ?";
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
baseQuery += " AND timestamp <= ?";
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total count
|
||||||
|
const totalStmt = db.prepare(`SELECT COUNT(*) as total ${baseQuery}`);
|
||||||
|
const totalResult = totalStmt.get(...params);
|
||||||
|
|
||||||
|
// Actions breakdown
|
||||||
|
const actionsStmt = db.prepare(`
|
||||||
|
SELECT action, COUNT(*) as count
|
||||||
|
${baseQuery}
|
||||||
|
GROUP BY action
|
||||||
|
ORDER BY count DESC
|
||||||
|
`);
|
||||||
|
const actionsResult = actionsStmt.all(...params);
|
||||||
|
|
||||||
|
// Users breakdown
|
||||||
|
const usersStmt = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
al.user_id,
|
||||||
|
u.name as user_name,
|
||||||
|
u.email as user_email,
|
||||||
|
COUNT(*) as count
|
||||||
|
${baseQuery}
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
GROUP BY al.user_id, u.name, u.email
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
const usersResult = usersStmt.all(...params);
|
||||||
|
|
||||||
|
// Resource types breakdown
|
||||||
|
const resourcesStmt = db.prepare(`
|
||||||
|
SELECT resource_type, COUNT(*) as count
|
||||||
|
${baseQuery}
|
||||||
|
WHERE resource_type IS NOT NULL
|
||||||
|
GROUP BY resource_type
|
||||||
|
ORDER BY count DESC
|
||||||
|
`);
|
||||||
|
const resourcesResult = resourcesStmt.all(...params);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: totalResult.total,
|
||||||
|
actionBreakdown: actionsResult,
|
||||||
|
userBreakdown: usersResult,
|
||||||
|
resourceBreakdown: resourcesResult,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get audit log statistics:", error);
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
actionBreakdown: [],
|
||||||
|
userBreakdown: [],
|
||||||
|
resourceBreakdown: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to extract client information from request
|
||||||
|
* @param {Request} req - The request object
|
||||||
|
* @returns {Object} Object containing IP address and user agent
|
||||||
|
*/
|
||||||
|
export function getClientInfo(req) {
|
||||||
|
const ipAddress =
|
||||||
|
req.headers.get("x-forwarded-for") ||
|
||||||
|
req.headers.get("x-real-ip") ||
|
||||||
|
req.headers.get("cf-connecting-ip") ||
|
||||||
|
req.ip ||
|
||||||
|
"unknown";
|
||||||
|
|
||||||
|
const userAgent = req.headers.get("user-agent") || "unknown";
|
||||||
|
|
||||||
|
return { ipAddress, userAgent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware helper to log API actions
|
||||||
|
* @param {Request} req - The request object
|
||||||
|
* @param {string} action - The action being performed
|
||||||
|
* @param {string} resourceType - The type of resource
|
||||||
|
* @param {string} resourceId - The ID of the resource
|
||||||
|
* @param {Object} session - The user session
|
||||||
|
* @param {Object} additionalDetails - Additional details to log
|
||||||
|
*/
|
||||||
|
export async function logApiAction(
|
||||||
|
req,
|
||||||
|
action,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
session,
|
||||||
|
additionalDetails = {}
|
||||||
|
) {
|
||||||
|
const { ipAddress, userAgent } = getClientInfo(req);
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
action,
|
||||||
|
userId: session?.user?.id || null,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
...additionalDetails,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditLog = {
|
||||||
|
logAuditEvent,
|
||||||
|
getAuditLogs,
|
||||||
|
getAuditLogStats,
|
||||||
|
getClientInfo,
|
||||||
|
logApiAction,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default auditLog;
|
||||||
129
src/lib/auditLogEdge.js
Normal file
129
src/lib/auditLogEdge.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Edge-compatible audit logging utility
|
||||||
|
* This version avoids direct database imports and can be used in Edge Runtime
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AUDIT_ACTIONS, RESOURCE_TYPES } from "./auditLog.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an audit event in Edge Runtime compatible way
|
||||||
|
* @param {Object} params - Audit log parameters
|
||||||
|
*/
|
||||||
|
export async function logAuditEventAsync({
|
||||||
|
action,
|
||||||
|
userId = null,
|
||||||
|
resourceType = null,
|
||||||
|
resourceId = null,
|
||||||
|
ipAddress = null,
|
||||||
|
userAgent = null,
|
||||||
|
details = null,
|
||||||
|
timestamp = null,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// In Edge Runtime or when database is not available, log to console
|
||||||
|
if (
|
||||||
|
typeof EdgeRuntime !== "undefined" ||
|
||||||
|
process.env.NEXT_RUNTIME === "edge"
|
||||||
|
) {
|
||||||
|
console.log(
|
||||||
|
`[Audit Log - Edge] ${action} by user ${
|
||||||
|
userId || "anonymous"
|
||||||
|
} on ${resourceType}:${resourceId}`,
|
||||||
|
{
|
||||||
|
details,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
timestamp: timestamp || new Date().toISOString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to make an API call to log the event
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/audit-logs/log", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
action,
|
||||||
|
userId,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details,
|
||||||
|
timestamp,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (fetchError) {
|
||||||
|
// Fallback to console logging if API call fails
|
||||||
|
console.log(
|
||||||
|
`[Audit Log - Fallback] ${action} by user ${
|
||||||
|
userId || "anonymous"
|
||||||
|
} on ${resourceType}:${resourceId}`,
|
||||||
|
{
|
||||||
|
details,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
timestamp: timestamp || new Date().toISOString(),
|
||||||
|
error: fetchError.message,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to log audit event:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to extract client information from request (Edge compatible)
|
||||||
|
* @param {Request} req - The request object
|
||||||
|
* @returns {Object} Object containing IP address and user agent
|
||||||
|
*/
|
||||||
|
export function getClientInfoEdgeCompatible(req) {
|
||||||
|
const ipAddress =
|
||||||
|
req.headers.get("x-forwarded-for") ||
|
||||||
|
req.headers.get("x-real-ip") ||
|
||||||
|
req.headers.get("cf-connecting-ip") ||
|
||||||
|
"unknown";
|
||||||
|
|
||||||
|
const userAgent = req.headers.get("user-agent") || "unknown";
|
||||||
|
|
||||||
|
return { ipAddress, userAgent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware helper to log API actions (Edge compatible)
|
||||||
|
*/
|
||||||
|
export async function logApiActionAsync(
|
||||||
|
req,
|
||||||
|
action,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
session,
|
||||||
|
additionalDetails = {}
|
||||||
|
) {
|
||||||
|
const { ipAddress, userAgent } = getClientInfoEdgeCompatible(req);
|
||||||
|
|
||||||
|
await logAuditEventAsync({
|
||||||
|
action,
|
||||||
|
userId: session?.user?.id || null,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
...additionalDetails,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AUDIT_ACTIONS, RESOURCE_TYPES };
|
||||||
159
src/lib/auditLogSafe.js
Normal file
159
src/lib/auditLogSafe.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Safe audit logging that doesn't cause Edge Runtime issues
|
||||||
|
* This module can be safely imported anywhere without causing database issues
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Constants that can be safely exported
|
||||||
|
export const AUDIT_ACTIONS = {
|
||||||
|
// Authentication
|
||||||
|
LOGIN: "login",
|
||||||
|
LOGOUT: "logout",
|
||||||
|
LOGIN_FAILED: "login_failed",
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
PROJECT_CREATE: "project_create",
|
||||||
|
PROJECT_UPDATE: "project_update",
|
||||||
|
PROJECT_DELETE: "project_delete",
|
||||||
|
PROJECT_VIEW: "project_view",
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
TASK_CREATE: "task_create",
|
||||||
|
TASK_UPDATE: "task_update",
|
||||||
|
TASK_DELETE: "task_delete",
|
||||||
|
TASK_STATUS_CHANGE: "task_status_change",
|
||||||
|
|
||||||
|
// Project Tasks
|
||||||
|
PROJECT_TASK_CREATE: "project_task_create",
|
||||||
|
PROJECT_TASK_UPDATE: "project_task_update",
|
||||||
|
PROJECT_TASK_DELETE: "project_task_delete",
|
||||||
|
PROJECT_TASK_STATUS_CHANGE: "project_task_status_change",
|
||||||
|
|
||||||
|
// Contracts
|
||||||
|
CONTRACT_CREATE: "contract_create",
|
||||||
|
CONTRACT_UPDATE: "contract_update",
|
||||||
|
CONTRACT_DELETE: "contract_delete",
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
NOTE_CREATE: "note_create",
|
||||||
|
NOTE_UPDATE: "note_update",
|
||||||
|
NOTE_DELETE: "note_delete",
|
||||||
|
|
||||||
|
// Admin actions
|
||||||
|
USER_CREATE: "user_create",
|
||||||
|
USER_UPDATE: "user_update",
|
||||||
|
USER_DELETE: "user_delete",
|
||||||
|
USER_ROLE_CHANGE: "user_role_change",
|
||||||
|
|
||||||
|
// System actions
|
||||||
|
DATA_EXPORT: "data_export",
|
||||||
|
BULK_OPERATION: "bulk_operation",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RESOURCE_TYPES = {
|
||||||
|
PROJECT: "project",
|
||||||
|
TASK: "task",
|
||||||
|
PROJECT_TASK: "project_task",
|
||||||
|
CONTRACT: "contract",
|
||||||
|
NOTE: "note",
|
||||||
|
USER: "user",
|
||||||
|
SESSION: "session",
|
||||||
|
SYSTEM: "system",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe audit logging function that works in any runtime
|
||||||
|
*/
|
||||||
|
export async function logAuditEventSafe({
|
||||||
|
action,
|
||||||
|
userId = null,
|
||||||
|
resourceType = null,
|
||||||
|
resourceId = null,
|
||||||
|
ipAddress = null,
|
||||||
|
userAgent = null,
|
||||||
|
details = null,
|
||||||
|
timestamp = null,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// Always log to console first
|
||||||
|
console.log(
|
||||||
|
`[Audit] ${action} by user ${
|
||||||
|
userId || "anonymous"
|
||||||
|
} on ${resourceType}:${resourceId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if we're in Edge Runtime
|
||||||
|
if (
|
||||||
|
typeof EdgeRuntime !== "undefined" ||
|
||||||
|
process.env.NEXT_RUNTIME === "edge"
|
||||||
|
) {
|
||||||
|
console.log("[Audit] Edge Runtime detected - console logging only");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the database-enabled audit function
|
||||||
|
try {
|
||||||
|
const auditModule = await import("./auditLog.js");
|
||||||
|
await auditModule.logAuditEvent({
|
||||||
|
action,
|
||||||
|
userId,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
} catch (dbError) {
|
||||||
|
console.log(
|
||||||
|
"[Audit] Database logging failed, using console fallback:",
|
||||||
|
dbError.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Audit] Failed to log audit event:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to extract client information from request
|
||||||
|
*/
|
||||||
|
export function getClientInfo(req) {
|
||||||
|
const ipAddress =
|
||||||
|
req.headers?.get?.("x-forwarded-for") ||
|
||||||
|
req.headers?.get?.("x-real-ip") ||
|
||||||
|
req.headers?.get?.("cf-connecting-ip") ||
|
||||||
|
req.ip ||
|
||||||
|
"unknown";
|
||||||
|
|
||||||
|
const userAgent = req.headers?.get?.("user-agent") || "unknown";
|
||||||
|
|
||||||
|
return { ipAddress, userAgent };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe API action logging
|
||||||
|
*/
|
||||||
|
export async function logApiActionSafe(
|
||||||
|
req,
|
||||||
|
action,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
session,
|
||||||
|
additionalDetails = {}
|
||||||
|
) {
|
||||||
|
const { ipAddress, userAgent } = getClientInfo(req);
|
||||||
|
|
||||||
|
await logAuditEventSafe({
|
||||||
|
action,
|
||||||
|
userId: session?.user?.id || null,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details: {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
...additionalDetails,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
157
src/lib/auth.js
Normal file
157
src/lib/auth.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email format"),
|
||||||
|
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Password", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
try {
|
||||||
|
// Import database here to avoid edge runtime issues
|
||||||
|
const { default: db } = await import("./db.js");
|
||||||
|
|
||||||
|
// Validate input
|
||||||
|
const validatedFields = loginSchema.parse(credentials);
|
||||||
|
|
||||||
|
// Check if user exists and is active
|
||||||
|
const user = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id, email, name, password_hash, role, is_active,
|
||||||
|
failed_login_attempts, locked_until
|
||||||
|
FROM users
|
||||||
|
WHERE email = ? AND is_active = 1
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get(validatedFields.email);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if account is locked
|
||||||
|
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||||
|
throw new Error("Account temporarily locked");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValidPassword = await bcrypt.compare(
|
||||||
|
validatedFields.password,
|
||||||
|
user.password_hash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
// Increment failed attempts
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET failed_login_attempts = failed_login_attempts + 1,
|
||||||
|
locked_until = CASE
|
||||||
|
WHEN failed_login_attempts >= 4
|
||||||
|
THEN datetime('now', '+15 minutes')
|
||||||
|
ELSE locked_until
|
||||||
|
END
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
).run(user.id);
|
||||||
|
|
||||||
|
// Log failed login attempt (only in Node.js runtime)
|
||||||
|
try {
|
||||||
|
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } =
|
||||||
|
await import("./auditLogSafe.js");
|
||||||
|
await logAuditEventSafe({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN_FAILED,
|
||||||
|
userId: user.id,
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
details: {
|
||||||
|
email: validatedFields.email,
|
||||||
|
reason: "invalid_password",
|
||||||
|
failed_attempts: user.failed_login_attempts + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (auditError) {
|
||||||
|
console.error("Failed to log audit event:", auditError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset failed attempts and update last login
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET failed_login_attempts = 0,
|
||||||
|
locked_until = NULL,
|
||||||
|
last_login = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
).run(user.id);
|
||||||
|
|
||||||
|
// Log successful login (only in Node.js runtime)
|
||||||
|
try {
|
||||||
|
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } =
|
||||||
|
await import("./auditLogSafe.js");
|
||||||
|
await logAuditEventSafe({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN,
|
||||||
|
userId: user.id,
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
details: {
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (auditError) {
|
||||||
|
console.error("Failed to log audit event:", auditError);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
session: {
|
||||||
|
strategy: "jwt",
|
||||||
|
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.role = user.role;
|
||||||
|
token.userId = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token) {
|
||||||
|
session.user.id = token.userId;
|
||||||
|
session.user.role = token.role;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/auth/signin",
|
||||||
|
signOut: "/auth/signout",
|
||||||
|
error: "/auth/error",
|
||||||
|
},
|
||||||
|
debug: process.env.NODE_ENV === "development",
|
||||||
|
});
|
||||||
@@ -162,4 +162,156 @@ export default function initializeDatabase() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Column already exists, ignore error
|
// Column already exists, ignore error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration: Add user tracking columns to projects table
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE projects ADD COLUMN created_by TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE projects ADD COLUMN assigned_to TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE projects ADD COLUMN created_at TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE projects ADD COLUMN updated_at TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Add user tracking columns to project_tasks table
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE project_tasks ADD COLUMN created_by TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE project_tasks ADD COLUMN assigned_to TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE project_tasks ADD COLUMN created_at TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE project_tasks ADD COLUMN updated_at TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes for project_tasks user tracking
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_tasks_created_by ON project_tasks(created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_project_tasks_assigned_to ON project_tasks(assigned_to);
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Index already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: Add user tracking columns to notes table
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE notes ADD COLUMN created_by TEXT;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE notes ADD COLUMN is_system INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Column already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes for notes user tracking
|
||||||
|
try {
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_created_by ON notes(created_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_task_id ON notes(task_id);
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
// Index already exists, ignore error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization tables
|
||||||
|
db.exec(`
|
||||||
|
-- Users table
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT CHECK(role IN ('admin', 'project_manager', 'user', 'read_only')) DEFAULT 'user',
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active INTEGER DEFAULT 1,
|
||||||
|
last_login TEXT,
|
||||||
|
failed_login_attempts INTEGER DEFAULT 0,
|
||||||
|
locked_until TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- NextAuth.js sessions table (simplified for custom implementation)
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||||
|
session_token TEXT UNIQUE NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
expires TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Audit log table for security tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
details TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(session_token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp ON audit_logs(user_id, timestamp);
|
||||||
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
235
src/lib/middleware/auditLog.js
Normal file
235
src/lib/middleware/auditLog.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { logApiAction, AUDIT_ACTIONS, RESOURCE_TYPES } from "@/lib/auditLog.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher-order function to add audit logging to API routes
|
||||||
|
* @param {Function} handler - The original API route handler
|
||||||
|
* @param {Object} auditConfig - Audit logging configuration
|
||||||
|
* @param {string} auditConfig.action - The audit action to log
|
||||||
|
* @param {string} auditConfig.resourceType - The resource type being accessed
|
||||||
|
* @param {Function} [auditConfig.getResourceId] - Function to extract resource ID from request/params
|
||||||
|
* @param {Function} [auditConfig.getAdditionalDetails] - Function to get additional details to log
|
||||||
|
* @returns {Function} Wrapped handler with audit logging
|
||||||
|
*/
|
||||||
|
export function withAuditLog(handler, auditConfig) {
|
||||||
|
return async (request, context) => {
|
||||||
|
try {
|
||||||
|
// Execute the original handler first
|
||||||
|
const response = await handler(request, context);
|
||||||
|
|
||||||
|
// Extract resource ID if function provided
|
||||||
|
let resourceId = null;
|
||||||
|
if (auditConfig.getResourceId) {
|
||||||
|
resourceId = auditConfig.getResourceId(request, context, response);
|
||||||
|
} else if (context?.params?.id) {
|
||||||
|
resourceId = context.params.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get additional details if function provided
|
||||||
|
let additionalDetails = {};
|
||||||
|
if (auditConfig.getAdditionalDetails) {
|
||||||
|
additionalDetails = auditConfig.getAdditionalDetails(
|
||||||
|
request,
|
||||||
|
context,
|
||||||
|
response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the action
|
||||||
|
logApiAction(
|
||||||
|
request,
|
||||||
|
auditConfig.action,
|
||||||
|
auditConfig.resourceType,
|
||||||
|
resourceId,
|
||||||
|
request.session,
|
||||||
|
additionalDetails
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
// Log failed actions
|
||||||
|
const resourceId = auditConfig.getResourceId
|
||||||
|
? auditConfig.getResourceId(request, context, null)
|
||||||
|
: context?.params?.id || null;
|
||||||
|
|
||||||
|
logApiAction(
|
||||||
|
request,
|
||||||
|
`${auditConfig.action}_failed`,
|
||||||
|
auditConfig.resourceType,
|
||||||
|
resourceId,
|
||||||
|
request.session,
|
||||||
|
{
|
||||||
|
error: error.message,
|
||||||
|
...(auditConfig.getAdditionalDetails
|
||||||
|
? auditConfig.getAdditionalDetails(request, context, null)
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-throw the error
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predefined audit configurations for common actions
|
||||||
|
*/
|
||||||
|
export const AUDIT_CONFIGS = {
|
||||||
|
// Project actions
|
||||||
|
PROJECT_VIEW: {
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_VIEW,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
},
|
||||||
|
PROJECT_CREATE: {
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
getResourceId: (req, ctx, res) => res?.json?.projectId?.toString(),
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { projectData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PROJECT_UPDATE: {
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { updatedData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PROJECT_DELETE: {
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_DELETE,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task actions
|
||||||
|
TASK_VIEW: {
|
||||||
|
action: AUDIT_ACTIONS.TASK_VIEW,
|
||||||
|
resourceType: RESOURCE_TYPES.TASK,
|
||||||
|
},
|
||||||
|
TASK_CREATE: {
|
||||||
|
action: AUDIT_ACTIONS.TASK_CREATE,
|
||||||
|
resourceType: RESOURCE_TYPES.TASK,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { taskData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TASK_UPDATE: {
|
||||||
|
action: AUDIT_ACTIONS.TASK_UPDATE,
|
||||||
|
resourceType: RESOURCE_TYPES.TASK,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { updatedData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TASK_DELETE: {
|
||||||
|
action: AUDIT_ACTIONS.TASK_DELETE,
|
||||||
|
resourceType: RESOURCE_TYPES.TASK,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Project Task actions
|
||||||
|
PROJECT_TASK_VIEW: {
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_TASK_VIEW,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||||
|
},
|
||||||
|
PROJECT_TASK_CREATE: {
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_TASK_CREATE,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { taskData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PROJECT_TASK_UPDATE: {
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_TASK_UPDATE,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { updatedData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
PROJECT_TASK_DELETE: {
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_TASK_DELETE,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT_TASK,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Contract actions
|
||||||
|
CONTRACT_VIEW: {
|
||||||
|
action: AUDIT_ACTIONS.CONTRACT_VIEW,
|
||||||
|
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||||
|
},
|
||||||
|
CONTRACT_CREATE: {
|
||||||
|
action: AUDIT_ACTIONS.CONTRACT_CREATE,
|
||||||
|
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { contractData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CONTRACT_UPDATE: {
|
||||||
|
action: AUDIT_ACTIONS.CONTRACT_UPDATE,
|
||||||
|
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { updatedData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CONTRACT_DELETE: {
|
||||||
|
action: AUDIT_ACTIONS.CONTRACT_DELETE,
|
||||||
|
resourceType: RESOURCE_TYPES.CONTRACT,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Note actions
|
||||||
|
NOTE_VIEW: {
|
||||||
|
action: AUDIT_ACTIONS.NOTE_VIEW,
|
||||||
|
resourceType: RESOURCE_TYPES.NOTE,
|
||||||
|
},
|
||||||
|
NOTE_CREATE: {
|
||||||
|
action: AUDIT_ACTIONS.NOTE_CREATE,
|
||||||
|
resourceType: RESOURCE_TYPES.NOTE,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { noteData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NOTE_UPDATE: {
|
||||||
|
action: AUDIT_ACTIONS.NOTE_UPDATE,
|
||||||
|
resourceType: RESOURCE_TYPES.NOTE,
|
||||||
|
getAdditionalDetails: async (req) => {
|
||||||
|
const data = await req.json();
|
||||||
|
return { updatedData: data };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NOTE_DELETE: {
|
||||||
|
action: AUDIT_ACTIONS.NOTE_DELETE,
|
||||||
|
resourceType: RESOURCE_TYPES.NOTE,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility function to create audit-logged API handlers
|
||||||
|
* @param {Object} handlers - Object with HTTP method handlers
|
||||||
|
* @param {Object} auditConfig - Audit configuration for this route
|
||||||
|
* @returns {Object} Object with audit-logged handlers
|
||||||
|
*/
|
||||||
|
export function createAuditedHandlers(handlers, auditConfig) {
|
||||||
|
const auditedHandlers = {};
|
||||||
|
|
||||||
|
Object.entries(handlers).forEach(([method, handler]) => {
|
||||||
|
// Get method-specific audit config or use default
|
||||||
|
const config = auditConfig[method] || auditConfig.default || auditConfig;
|
||||||
|
|
||||||
|
auditedHandlers[method] = withAuditLog(handler, config);
|
||||||
|
});
|
||||||
|
|
||||||
|
return auditedHandlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditLogMiddleware = {
|
||||||
|
withAuditLog,
|
||||||
|
AUDIT_CONFIGS,
|
||||||
|
createAuditedHandlers,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default auditLogMiddleware;
|
||||||
76
src/lib/middleware/auth.js
Normal file
76
src/lib/middleware/auth.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
// Role hierarchy for permission checking
|
||||||
|
const ROLE_HIERARCHY = {
|
||||||
|
'admin': 4,
|
||||||
|
'project_manager': 3,
|
||||||
|
'user': 2,
|
||||||
|
'read_only': 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withAuth(handler, options = {}) {
|
||||||
|
return auth(async (req, context) => {
|
||||||
|
try {
|
||||||
|
// Check if user is authenticated
|
||||||
|
if (!req.auth?.user) {
|
||||||
|
console.log("No session found for request to:", req.url)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Authentication required" },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Session found for user:", req.auth.user.email)
|
||||||
|
|
||||||
|
// Check role-based permissions (without database access)
|
||||||
|
if (options.requiredRole && !hasPermission(req.auth.user.role, options.requiredRole)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Insufficient permissions" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user info to request
|
||||||
|
req.user = {
|
||||||
|
id: req.auth.user.id,
|
||||||
|
email: req.auth.user.email,
|
||||||
|
name: req.auth.user.name,
|
||||||
|
role: req.auth.user.role
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the original handler with both req and context
|
||||||
|
return await handler(req, context)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth middleware error:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermission(userRole, requiredRole) {
|
||||||
|
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for read-only operations
|
||||||
|
export function withReadAuth(handler) {
|
||||||
|
return withAuth(handler, { requiredRole: 'read_only' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for user-level operations
|
||||||
|
export function withUserAuth(handler) {
|
||||||
|
return withAuth(handler, { requiredRole: 'user' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for admin-level operations
|
||||||
|
export function withAdminAuth(handler) {
|
||||||
|
return withAuth(handler, { requiredRole: 'admin' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for project manager operations
|
||||||
|
export function withManagerAuth(handler) {
|
||||||
|
return withAuth(handler, { requiredRole: 'project_manager' })
|
||||||
|
}
|
||||||
@@ -2,29 +2,100 @@ import db from "../db.js";
|
|||||||
|
|
||||||
export function getNotesByProjectId(project_id) {
|
export function getNotesByProjectId(project_id) {
|
||||||
return db
|
return db
|
||||||
.prepare(`SELECT * FROM notes WHERE project_id = ? ORDER BY note_date DESC`)
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT n.*,
|
||||||
|
u.name as created_by_name,
|
||||||
|
u.email as created_by_email
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN users u ON n.created_by = u.id
|
||||||
|
WHERE n.project_id = ?
|
||||||
|
ORDER BY n.note_date DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
.all(project_id);
|
.all(project_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addNoteToProject(project_id, note) {
|
export function addNoteToProject(project_id, note, created_by = null) {
|
||||||
db.prepare(`INSERT INTO notes (project_id, note) VALUES (?, ?)`).run(
|
db.prepare(
|
||||||
project_id,
|
`
|
||||||
note
|
INSERT INTO notes (project_id, note, created_by, note_date)
|
||||||
);
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`
|
||||||
|
).run(project_id, note, created_by);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNotesByTaskId(task_id) {
|
export function getNotesByTaskId(task_id) {
|
||||||
return db
|
return db
|
||||||
.prepare(`SELECT * FROM notes WHERE task_id = ? ORDER BY note_date DESC`)
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT n.*,
|
||||||
|
u.name as created_by_name,
|
||||||
|
u.email as created_by_email
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN users u ON n.created_by = u.id
|
||||||
|
WHERE n.task_id = ?
|
||||||
|
ORDER BY n.note_date DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
.all(task_id);
|
.all(task_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addNoteToTask(task_id, note, is_system = false) {
|
export function addNoteToTask(
|
||||||
|
task_id,
|
||||||
|
note,
|
||||||
|
is_system = false,
|
||||||
|
created_by = null
|
||||||
|
) {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO notes (task_id, note, is_system) VALUES (?, ?, ?)`
|
`INSERT INTO notes (task_id, note, is_system, created_by, note_date)
|
||||||
).run(task_id, note, is_system ? 1 : 0);
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`
|
||||||
|
).run(task_id, note, is_system ? 1 : 0, created_by);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteNote(note_id) {
|
export function deleteNote(note_id) {
|
||||||
db.prepare(`DELETE FROM notes WHERE note_id = ?`).run(note_id);
|
db.prepare(`DELETE FROM notes WHERE note_id = ?`).run(note_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all notes with user information (for admin/reporting purposes)
|
||||||
|
export function getAllNotesWithUsers() {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT n.*,
|
||||||
|
u.name as created_by_name,
|
||||||
|
u.email as created_by_email,
|
||||||
|
p.project_name,
|
||||||
|
COALESCE(pt.custom_task_name, t.name) as task_name
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN users u ON n.created_by = u.id
|
||||||
|
LEFT JOIN projects p ON n.project_id = p.project_id
|
||||||
|
LEFT JOIN project_tasks pt ON n.task_id = pt.id
|
||||||
|
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
|
||||||
|
ORDER BY n.note_date DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get notes created by a specific user
|
||||||
|
export function getNotesByCreator(userId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT n.*,
|
||||||
|
u.name as created_by_name,
|
||||||
|
u.email as created_by_email,
|
||||||
|
p.project_name,
|
||||||
|
COALESCE(pt.custom_task_name, t.name) as task_name
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN users u ON n.created_by = u.id
|
||||||
|
LEFT JOIN projects p ON n.project_id = p.project_id
|
||||||
|
LEFT JOIN project_tasks pt ON n.task_id = pt.id
|
||||||
|
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
|
||||||
|
WHERE n.created_by = ?
|
||||||
|
ORDER BY n.note_date DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(userId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,48 @@
|
|||||||
import db from "../db.js";
|
import db from "../db.js";
|
||||||
|
|
||||||
export function getAllProjects(contractId = null) {
|
export function getAllProjects(contractId = null) {
|
||||||
|
const baseQuery = `
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
creator.email as created_by_email,
|
||||||
|
assignee.name as assigned_to_name,
|
||||||
|
assignee.email as assigned_to_email
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN users creator ON p.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON p.assigned_to = assignee.id
|
||||||
|
`;
|
||||||
|
|
||||||
if (contractId) {
|
if (contractId) {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT * FROM projects WHERE contract_id = ? ORDER BY finish_date DESC"
|
baseQuery + " WHERE p.contract_id = ? ORDER BY p.finish_date DESC"
|
||||||
)
|
)
|
||||||
.all(contractId);
|
.all(contractId);
|
||||||
}
|
}
|
||||||
return db.prepare("SELECT * FROM projects ORDER BY finish_date DESC").all();
|
return db.prepare(baseQuery + " ORDER BY p.finish_date DESC").all();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProjectById(id) {
|
export function getProjectById(id) {
|
||||||
return db.prepare("SELECT * FROM projects WHERE project_id = ?").get(id);
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
creator.email as created_by_email,
|
||||||
|
assignee.name as assigned_to_name,
|
||||||
|
assignee.email as assigned_to_email
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN users creator ON p.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON p.assigned_to = assignee.id
|
||||||
|
WHERE p.project_id = ?
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProject(data) {
|
export function createProject(data, userId = null) {
|
||||||
// 1. Get the contract number and count existing projects
|
// 1. Get the contract number and count existing projects
|
||||||
const contractInfo = db
|
const contractInfo = db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -37,12 +64,16 @@ export function createProject(data) {
|
|||||||
|
|
||||||
// 2. Generate sequential number and project number
|
// 2. Generate sequential number and project number
|
||||||
const sequentialNumber = (contractInfo.project_count || 0) + 1;
|
const sequentialNumber = (contractInfo.project_count || 0) + 1;
|
||||||
const projectNumber = `${sequentialNumber}/${contractInfo.contract_number}`; const stmt = db.prepare(`
|
const projectNumber = `${sequentialNumber}/${contractInfo.contract_number}`;
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
INSERT INTO projects (
|
INSERT INTO projects (
|
||||||
contract_id, project_name, project_number, address, plot, district, unit, city, investment_number, finish_date,
|
contract_id, project_name, project_number, address, plot, district, unit, city, investment_number, finish_date,
|
||||||
wp, contact, notes, project_type, project_status, coordinates
|
wp, contact, notes, project_type, project_status, coordinates, created_by, assigned_to, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`);stmt.run(
|
`);
|
||||||
|
|
||||||
|
const result = stmt.run(
|
||||||
data.contract_id,
|
data.contract_id,
|
||||||
data.project_name,
|
data.project_name,
|
||||||
projectNumber,
|
projectNumber,
|
||||||
@@ -55,16 +86,23 @@ export function createProject(data) {
|
|||||||
data.finish_date,
|
data.finish_date,
|
||||||
data.wp,
|
data.wp,
|
||||||
data.contact,
|
data.contact,
|
||||||
data.notes, data.project_type || "design",
|
data.notes,
|
||||||
|
data.project_type || "design",
|
||||||
data.project_status || "registered",
|
data.project_status || "registered",
|
||||||
data.coordinates || null
|
data.coordinates || null,
|
||||||
|
userId,
|
||||||
|
data.assigned_to || null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProject(id, data) { const stmt = db.prepare(`
|
export function updateProject(id, data, userId = null) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
UPDATE projects SET
|
UPDATE projects SET
|
||||||
contract_id = ?, project_name = ?, project_number = ?, address = ?, plot = ?, district = ?, unit = ?, city = ?,
|
contract_id = ?, project_name = ?, project_number = ?, address = ?, plot = ?, district = ?, unit = ?, city = ?,
|
||||||
investment_number = ?, finish_date = ?, wp = ?, contact = ?, notes = ?, project_type = ?, project_status = ?, coordinates = ?
|
investment_number = ?, finish_date = ?, wp = ?, contact = ?, notes = ?, project_type = ?, project_status = ?,
|
||||||
|
coordinates = ?, assigned_to = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE project_id = ?
|
WHERE project_id = ?
|
||||||
`);
|
`);
|
||||||
stmt.run(
|
stmt.run(
|
||||||
@@ -80,9 +118,11 @@ export function updateProject(id, data) { const stmt = db.prepare(`
|
|||||||
data.finish_date,
|
data.finish_date,
|
||||||
data.wp,
|
data.wp,
|
||||||
data.contact,
|
data.contact,
|
||||||
data.notes, data.project_type || "design",
|
data.notes,
|
||||||
|
data.project_type || "design",
|
||||||
data.project_status || "registered",
|
data.project_status || "registered",
|
||||||
data.coordinates || null,
|
data.coordinates || null,
|
||||||
|
data.assigned_to || null,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,6 +131,75 @@ export function deleteProject(id) {
|
|||||||
db.prepare("DELETE FROM projects WHERE project_id = ?").run(id);
|
db.prepare("DELETE FROM projects WHERE project_id = ?").run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all users for assignment dropdown
|
||||||
|
export function getAllUsersForAssignment() {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id, name, email, role
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY name
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get projects assigned to a specific user
|
||||||
|
export function getProjectsByAssignedUser(userId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
creator.email as created_by_email,
|
||||||
|
assignee.name as assigned_to_name,
|
||||||
|
assignee.email as assigned_to_email
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN users creator ON p.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON p.assigned_to = assignee.id
|
||||||
|
WHERE p.assigned_to = ?
|
||||||
|
ORDER BY p.finish_date DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get projects created by a specific user
|
||||||
|
export function getProjectsByCreator(userId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
creator.email as created_by_email,
|
||||||
|
assignee.name as assigned_to_name,
|
||||||
|
assignee.email as assigned_to_email
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN users creator ON p.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON p.assigned_to = assignee.id
|
||||||
|
WHERE p.created_by = ?
|
||||||
|
ORDER BY p.finish_date DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project assignment
|
||||||
|
export function updateProjectAssignment(projectId, assignedToUserId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
UPDATE projects
|
||||||
|
SET assigned_to = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE project_id = ?
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.run(assignedToUserId, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
export function getProjectWithContract(id) {
|
export function getProjectWithContract(id) {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -113,9 +222,13 @@ export function getNotesForProject(projectId) {
|
|||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT * FROM notes
|
SELECT n.*,
|
||||||
WHERE project_id = ?
|
u.name as created_by_name,
|
||||||
ORDER BY note_date DESC
|
u.email as created_by_email
|
||||||
|
FROM notes n
|
||||||
|
LEFT JOIN users u ON n.created_by = u.id
|
||||||
|
WHERE n.project_id = ?
|
||||||
|
ORDER BY n.note_date DESC
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.all(projectId);
|
.all(projectId);
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ export function getAllProjectTasks() {
|
|||||||
p.plot,
|
p.plot,
|
||||||
p.city,
|
p.city,
|
||||||
p.address,
|
p.address,
|
||||||
p.finish_date
|
p.finish_date,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
creator.email as created_by_email,
|
||||||
|
assignee.name as assigned_to_name,
|
||||||
|
assignee.email as assigned_to_email
|
||||||
FROM project_tasks pt
|
FROM project_tasks pt
|
||||||
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
|
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
|
||||||
LEFT JOIN projects p ON pt.project_id = p.project_id
|
LEFT JOIN projects p ON pt.project_id = p.project_id
|
||||||
|
LEFT JOIN users creator ON pt.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
|
||||||
ORDER BY pt.date_added DESC
|
ORDER BY pt.date_added DESC
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
@@ -50,9 +56,15 @@ export function getProjectTasks(projectId) {
|
|||||||
CASE
|
CASE
|
||||||
WHEN pt.task_template_id IS NOT NULL THEN 'template'
|
WHEN pt.task_template_id IS NOT NULL THEN 'template'
|
||||||
ELSE 'custom'
|
ELSE 'custom'
|
||||||
END as task_type
|
END as task_type,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
creator.email as created_by_email,
|
||||||
|
assignee.name as assigned_to_name,
|
||||||
|
assignee.email as assigned_to_email
|
||||||
FROM project_tasks pt
|
FROM project_tasks pt
|
||||||
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
|
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
|
||||||
|
LEFT JOIN users creator ON pt.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
|
||||||
WHERE pt.project_id = ?
|
WHERE pt.project_id = ?
|
||||||
ORDER BY pt.date_added DESC
|
ORDER BY pt.date_added DESC
|
||||||
`
|
`
|
||||||
@@ -68,14 +80,19 @@ export function createProjectTask(data) {
|
|||||||
if (data.task_template_id) {
|
if (data.task_template_id) {
|
||||||
// Creating from template - explicitly set custom_max_wait_days to NULL so COALESCE uses template value
|
// Creating from template - explicitly set custom_max_wait_days to NULL so COALESCE uses template value
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO project_tasks (project_id, task_template_id, custom_max_wait_days, status, priority)
|
INSERT INTO project_tasks (
|
||||||
VALUES (?, ?, NULL, ?, ?)
|
project_id, task_template_id, custom_max_wait_days, status, priority,
|
||||||
|
created_by, assigned_to, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, NULL, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`);
|
`);
|
||||||
result = stmt.run(
|
result = stmt.run(
|
||||||
data.project_id,
|
data.project_id,
|
||||||
data.task_template_id,
|
data.task_template_id,
|
||||||
data.status || "pending",
|
data.status || "pending",
|
||||||
data.priority || "normal"
|
data.priority || "normal",
|
||||||
|
data.created_by || null,
|
||||||
|
data.assigned_to || null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the template name for the log
|
// Get the template name for the log
|
||||||
@@ -85,8 +102,11 @@ export function createProjectTask(data) {
|
|||||||
} else {
|
} else {
|
||||||
// Creating custom task
|
// Creating custom task
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO project_tasks (project_id, custom_task_name, custom_max_wait_days, custom_description, status, priority)
|
INSERT INTO project_tasks (
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
project_id, custom_task_name, custom_max_wait_days, custom_description,
|
||||||
|
status, priority, created_by, assigned_to, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`);
|
`);
|
||||||
result = stmt.run(
|
result = stmt.run(
|
||||||
data.project_id,
|
data.project_id,
|
||||||
@@ -94,7 +114,9 @@ export function createProjectTask(data) {
|
|||||||
data.custom_max_wait_days || 0,
|
data.custom_max_wait_days || 0,
|
||||||
data.custom_description || "",
|
data.custom_description || "",
|
||||||
data.status || "pending",
|
data.status || "pending",
|
||||||
data.priority || "normal"
|
data.priority || "normal",
|
||||||
|
data.created_by || null,
|
||||||
|
data.assigned_to || null
|
||||||
);
|
);
|
||||||
|
|
||||||
taskName = data.custom_task_name;
|
taskName = data.custom_task_name;
|
||||||
@@ -105,14 +127,14 @@ export function createProjectTask(data) {
|
|||||||
const priority = data.priority || "normal";
|
const priority = data.priority || "normal";
|
||||||
const status = data.status || "pending";
|
const status = data.status || "pending";
|
||||||
const logMessage = `Task "${taskName}" created with priority: ${priority}, status: ${status}`;
|
const logMessage = `Task "${taskName}" created with priority: ${priority}, status: ${status}`;
|
||||||
addNoteToTask(result.lastInsertRowid, logMessage, true);
|
addNoteToTask(result.lastInsertRowid, logMessage, true, data.created_by);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update project task status
|
// Update project task status
|
||||||
export function updateProjectTaskStatus(taskId, status) {
|
export function updateProjectTaskStatus(taskId, status, userId = null) {
|
||||||
// First get the current task details for logging
|
// First get the current task details for logging
|
||||||
const getCurrentTask = db.prepare(`
|
const getCurrentTask = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -136,7 +158,7 @@ export function updateProjectTaskStatus(taskId, status) {
|
|||||||
// Starting a task - set date_started
|
// Starting a task - set date_started
|
||||||
stmt = db.prepare(`
|
stmt = db.prepare(`
|
||||||
UPDATE project_tasks
|
UPDATE project_tasks
|
||||||
SET status = ?, date_started = CURRENT_TIMESTAMP
|
SET status = ?, date_started = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
result = stmt.run(status, taskId);
|
result = stmt.run(status, taskId);
|
||||||
@@ -144,7 +166,7 @@ export function updateProjectTaskStatus(taskId, status) {
|
|||||||
// Completing a task - set date_completed
|
// Completing a task - set date_completed
|
||||||
stmt = db.prepare(`
|
stmt = db.prepare(`
|
||||||
UPDATE project_tasks
|
UPDATE project_tasks
|
||||||
SET status = ?, date_completed = CURRENT_TIMESTAMP
|
SET status = ?, date_completed = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
result = stmt.run(status, taskId);
|
result = stmt.run(status, taskId);
|
||||||
@@ -152,7 +174,7 @@ export function updateProjectTaskStatus(taskId, status) {
|
|||||||
// Just updating status without changing timestamps
|
// Just updating status without changing timestamps
|
||||||
stmt = db.prepare(`
|
stmt = db.prepare(`
|
||||||
UPDATE project_tasks
|
UPDATE project_tasks
|
||||||
SET status = ?
|
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
`);
|
`);
|
||||||
result = stmt.run(status, taskId);
|
result = stmt.run(status, taskId);
|
||||||
@@ -162,7 +184,7 @@ export function updateProjectTaskStatus(taskId, status) {
|
|||||||
if (result.changes > 0 && oldStatus !== status) {
|
if (result.changes > 0 && oldStatus !== status) {
|
||||||
const taskName = currentTask.task_name || "Unknown task";
|
const taskName = currentTask.task_name || "Unknown task";
|
||||||
const logMessage = `Status changed from "${oldStatus}" to "${status}"`;
|
const logMessage = `Status changed from "${oldStatus}" to "${status}"`;
|
||||||
addNoteToTask(taskId, logMessage, true);
|
addNoteToTask(taskId, logMessage, true, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -173,3 +195,99 @@ export function deleteProjectTask(taskId) {
|
|||||||
const stmt = db.prepare("DELETE FROM project_tasks WHERE id = ?");
|
const stmt = db.prepare("DELETE FROM project_tasks WHERE id = ?");
|
||||||
return stmt.run(taskId);
|
return stmt.run(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get project tasks assigned to a specific user
|
||||||
|
export function getProjectTasksByAssignedUser(userId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
pt.*,
|
||||||
|
COALESCE(pt.custom_task_name, t.name) as task_name,
|
||||||
|
COALESCE(pt.custom_max_wait_days, t.max_wait_days) as max_wait_days,
|
||||||
|
COALESCE(pt.custom_description, t.description) as description,
|
||||||
|
CASE
|
||||||
|
WHEN pt.task_template_id IS NOT NULL THEN 'template'
|
||||||
|
ELSE 'custom'
|
||||||
|
END as task_type,
|
||||||
|
p.project_name,
|
||||||
|
p.wp,
|
||||||
|
p.plot,
|
||||||
|
p.city,
|
||||||
|
p.address,
|
||||||
|
p.finish_date,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
creator.email as created_by_email,
|
||||||
|
assignee.name as assigned_to_name,
|
||||||
|
assignee.email as assigned_to_email
|
||||||
|
FROM project_tasks pt
|
||||||
|
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
|
||||||
|
LEFT JOIN projects p ON pt.project_id = p.project_id
|
||||||
|
LEFT JOIN users creator ON pt.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
|
||||||
|
WHERE pt.assigned_to = ?
|
||||||
|
ORDER BY pt.date_added DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project tasks created by a specific user
|
||||||
|
export function getProjectTasksByCreator(userId) {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
pt.*,
|
||||||
|
COALESCE(pt.custom_task_name, t.name) as task_name,
|
||||||
|
COALESCE(pt.custom_max_wait_days, t.max_wait_days) as max_wait_days,
|
||||||
|
COALESCE(pt.custom_description, t.description) as description,
|
||||||
|
CASE
|
||||||
|
WHEN pt.task_template_id IS NOT NULL THEN 'template'
|
||||||
|
ELSE 'custom'
|
||||||
|
END as task_type,
|
||||||
|
p.project_name,
|
||||||
|
p.wp,
|
||||||
|
p.plot,
|
||||||
|
p.city,
|
||||||
|
p.address,
|
||||||
|
p.finish_date,
|
||||||
|
creator.name as created_by_name,
|
||||||
|
creator.email as created_by_email,
|
||||||
|
assignee.name as assigned_to_name,
|
||||||
|
assignee.email as assigned_to_email
|
||||||
|
FROM project_tasks pt
|
||||||
|
LEFT JOIN tasks t ON pt.task_template_id = t.task_id
|
||||||
|
LEFT JOIN projects p ON pt.project_id = p.project_id
|
||||||
|
LEFT JOIN users creator ON pt.created_by = creator.id
|
||||||
|
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
|
||||||
|
WHERE pt.created_by = ?
|
||||||
|
ORDER BY pt.date_added DESC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update project task assignment
|
||||||
|
export function updateProjectTaskAssignment(taskId, assignedToUserId) {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
UPDATE project_tasks
|
||||||
|
SET assigned_to = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
return stmt.run(assignedToUserId, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active users for task assignment (same as projects)
|
||||||
|
export function getAllUsersForTaskAssignment() {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id, name, email, role
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = 1
|
||||||
|
ORDER BY name ASC
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
}
|
||||||
|
|||||||
267
src/lib/userManagement.js
Normal file
267
src/lib/userManagement.js
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import db from "./db.js"
|
||||||
|
import bcrypt from "bcryptjs"
|
||||||
|
import { randomBytes } from "crypto"
|
||||||
|
|
||||||
|
// Create a new user
|
||||||
|
export async function createUser({ name, email, password, role = 'user', is_active = true }) {
|
||||||
|
const existingUser = db.prepare("SELECT id FROM users WHERE email = ?").get(email)
|
||||||
|
if (existingUser) {
|
||||||
|
throw new Error("User with this email already exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
|
const userId = randomBytes(16).toString('hex')
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO users (id, name, email, password_hash, role, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(userId, name, email, passwordHash, role, is_active ? 1 : 0)
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, name, email, role, created_at, updated_at, last_login,
|
||||||
|
is_active, failed_login_attempts, locked_until
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
`).get(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by ID
|
||||||
|
export function getUserById(id) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, name, email, password_hash, role, created_at, updated_at, last_login,
|
||||||
|
is_active, failed_login_attempts, locked_until
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
`).get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user by email
|
||||||
|
export function getUserByEmail(email) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, name, email, role, created_at, last_login, is_active
|
||||||
|
FROM users WHERE email = ?
|
||||||
|
`).get(email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all users (for admin)
|
||||||
|
export function getAllUsers() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, name, email, password_hash, role, created_at, updated_at, last_login, is_active,
|
||||||
|
failed_login_attempts, locked_until
|
||||||
|
FROM users
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user role
|
||||||
|
export function updateUserRole(userId, role) {
|
||||||
|
const validRoles = ['admin', 'project_manager', 'user', 'read_only']
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
throw new Error("Invalid role")
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(role, userId)
|
||||||
|
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate/deactivate user
|
||||||
|
export function setUserActive(userId, isActive) {
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE users SET is_active = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(isActive ? 1 : 0, userId)
|
||||||
|
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change user password
|
||||||
|
export async function changeUserPassword(userId, newPassword) {
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12)
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP,
|
||||||
|
failed_login_attempts = 0, locked_until = NULL
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(passwordHash, userId)
|
||||||
|
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up expired sessions
|
||||||
|
export function cleanupExpiredSessions() {
|
||||||
|
const result = db.prepare(`
|
||||||
|
DELETE FROM sessions WHERE expires < datetime('now')
|
||||||
|
`).run()
|
||||||
|
|
||||||
|
return result.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user sessions
|
||||||
|
export function getUserSessions(userId) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, session_token, expires, created_at
|
||||||
|
FROM sessions
|
||||||
|
WHERE user_id = ? AND expires > datetime('now')
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke user session
|
||||||
|
export function revokeSession(sessionToken) {
|
||||||
|
const result = db.prepare(`
|
||||||
|
DELETE FROM sessions WHERE session_token = ?
|
||||||
|
`).run(sessionToken)
|
||||||
|
|
||||||
|
return result.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get audit logs for user
|
||||||
|
export function getUserAuditLogs(userId, limit = 50) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT action, resource_type, resource_id, ip_address, timestamp, details
|
||||||
|
FROM audit_logs
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(userId, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user (comprehensive update function)
|
||||||
|
export async function updateUser(userId, updates) {
|
||||||
|
const user = getUserById(userId);
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is being changed and if it already exists
|
||||||
|
if (updates.email && updates.email !== user.email) {
|
||||||
|
const existingUser = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(updates.email, userId);
|
||||||
|
if (existingUser) {
|
||||||
|
throw new Error("User with this email already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare update fields
|
||||||
|
const updateFields = [];
|
||||||
|
const updateValues = [];
|
||||||
|
|
||||||
|
if (updates.name !== undefined) {
|
||||||
|
updateFields.push("name = ?");
|
||||||
|
updateValues.push(updates.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.email !== undefined) {
|
||||||
|
updateFields.push("email = ?");
|
||||||
|
updateValues.push(updates.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.role !== undefined) {
|
||||||
|
const validRoles = ['admin', 'project_manager', 'user', 'read_only'];
|
||||||
|
if (!validRoles.includes(updates.role)) {
|
||||||
|
throw new Error("Invalid role");
|
||||||
|
}
|
||||||
|
updateFields.push("role = ?");
|
||||||
|
updateValues.push(updates.role);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.is_active !== undefined) {
|
||||||
|
updateFields.push("is_active = ?");
|
||||||
|
updateValues.push(updates.is_active ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.password !== undefined) {
|
||||||
|
const passwordHash = await bcrypt.hash(updates.password, 12);
|
||||||
|
updateFields.push("password_hash = ?");
|
||||||
|
updateValues.push(passwordHash);
|
||||||
|
// Reset failed login attempts when password is changed
|
||||||
|
updateFields.push("failed_login_attempts = 0");
|
||||||
|
updateFields.push("locked_until = NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updateFields.length === 0) {
|
||||||
|
return getUserById(userId); // Return existing user if no updates
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields.push("updated_at = CURRENT_TIMESTAMP");
|
||||||
|
updateValues.push(userId);
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE users
|
||||||
|
SET ${updateFields.join(", ")}
|
||||||
|
WHERE id = ?
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = db.prepare(query).run(...updateValues);
|
||||||
|
|
||||||
|
if (result.changes > 0) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, name, email, role, created_at, updated_at, last_login,
|
||||||
|
is_active, failed_login_attempts, locked_until
|
||||||
|
FROM users WHERE id = ?
|
||||||
|
`).get(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
export function deleteUser(userId) {
|
||||||
|
// First, delete related data (sessions, audit logs, etc.)
|
||||||
|
db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId);
|
||||||
|
db.prepare("DELETE FROM audit_logs WHERE user_id = ?").run(userId);
|
||||||
|
|
||||||
|
// Then delete the user
|
||||||
|
const result = db.prepare("DELETE FROM users WHERE id = ?").run(userId);
|
||||||
|
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset user password (admin function)
|
||||||
|
export async function resetUserPassword(userId, newPassword) {
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?,
|
||||||
|
failed_login_attempts = 0,
|
||||||
|
locked_until = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(passwordHash, userId);
|
||||||
|
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock user account
|
||||||
|
export function unlockUserAccount(userId) {
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET failed_login_attempts = 0,
|
||||||
|
locked_until = NULL,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(userId);
|
||||||
|
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user statistics
|
||||||
|
export function getUserStats() {
|
||||||
|
const stats = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total_users,
|
||||||
|
COUNT(CASE WHEN is_active = 1 THEN 1 END) as active_users,
|
||||||
|
COUNT(CASE WHEN is_active = 0 THEN 1 END) as inactive_users,
|
||||||
|
COUNT(CASE WHEN role = 'admin' THEN 1 END) as admin_users,
|
||||||
|
COUNT(CASE WHEN role = 'project_manager' THEN 1 END) as manager_users,
|
||||||
|
COUNT(CASE WHEN role = 'user' THEN 1 END) as regular_users,
|
||||||
|
COUNT(CASE WHEN role = 'read_only' THEN 1 END) as readonly_users,
|
||||||
|
COUNT(CASE WHEN last_login IS NOT NULL THEN 1 END) as users_with_login
|
||||||
|
FROM users
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
43
src/middleware.js
Normal file
43
src/middleware.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
// Allow access to auth pages
|
||||||
|
if (pathname.startsWith("/auth/")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow access to API routes (they handle their own auth)
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require authentication for all other pages
|
||||||
|
if (!req.auth) {
|
||||||
|
const url = new URL("/auth/signin", req.url);
|
||||||
|
url.searchParams.set("callbackUrl", req.nextUrl.pathname);
|
||||||
|
return Response.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin routes (role check only, no database access)
|
||||||
|
if (pathname.startsWith("/admin/")) {
|
||||||
|
if (!["admin", "project_manager"].includes(req.auth.user.role)) {
|
||||||
|
return Response.redirect(new URL("/", req.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - api (all API routes handle their own auth)
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
* - auth pages (auth pages should be accessible)
|
||||||
|
*/
|
||||||
|
"/((?!api|_next/static|_next/image|favicon.ico|auth).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
97
test-audit-fix-direct.mjs
Normal file
97
test-audit-fix-direct.mjs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Test script to verify audit logging after our fixes
|
||||||
|
// This test shows what happens when API calls are made with proper authentication
|
||||||
|
|
||||||
|
console.log("=== TESTING AUDIT LOGGING FIX ===\n");
|
||||||
|
|
||||||
|
// Simulate the flow that would happen in a real authenticated API call
|
||||||
|
async function testAuditLogging() {
|
||||||
|
try {
|
||||||
|
// Import the logging function
|
||||||
|
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import(
|
||||||
|
"./src/lib/auditLogSafe.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("1. Testing audit logging with proper user session...");
|
||||||
|
|
||||||
|
// Simulate an authenticated session (like what req.auth would contain)
|
||||||
|
const mockAuthenticatedSession = {
|
||||||
|
user: {
|
||||||
|
id: "e42a4b036074ff7233942a0728557141", // Real user ID from our logs
|
||||||
|
email: "admin@localhost.com",
|
||||||
|
name: "Administrator",
|
||||||
|
role: "admin",
|
||||||
|
},
|
||||||
|
expires: "2025-08-08T21:18:07.949Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate a null/undefined session (like unauthenticated requests)
|
||||||
|
const mockUnauthenticatedSession = null;
|
||||||
|
|
||||||
|
// Test 1: Authenticated user logging
|
||||||
|
console.log("\n2. Testing with authenticated session:");
|
||||||
|
await logAuditEventSafe({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_VIEW,
|
||||||
|
userId: mockAuthenticatedSession?.user?.id || null,
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "test-project-123",
|
||||||
|
ipAddress: "127.0.0.1",
|
||||||
|
userAgent: "Test Browser",
|
||||||
|
details: {
|
||||||
|
test: "authenticated_user_test",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 2: Unauthenticated user logging (should result in null userId)
|
||||||
|
console.log("\n3. Testing with unauthenticated session:");
|
||||||
|
await logAuditEventSafe({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN_FAILED,
|
||||||
|
userId: mockUnauthenticatedSession?.user?.id || null,
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
resourceId: null,
|
||||||
|
ipAddress: "127.0.0.1",
|
||||||
|
userAgent: "Test Browser",
|
||||||
|
details: {
|
||||||
|
test: "unauthenticated_user_test",
|
||||||
|
email: "hacker@test.com",
|
||||||
|
reason: "invalid_credentials",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Check what we just logged
|
||||||
|
console.log("\n4. Checking the audit events we just created...");
|
||||||
|
const { getAuditLogs } = await import("./src/lib/auditLog.js");
|
||||||
|
const latestLogs = await getAuditLogs({ limit: 2 });
|
||||||
|
|
||||||
|
console.log("Latest 2 audit events:");
|
||||||
|
latestLogs.forEach((log, index) => {
|
||||||
|
const userDisplay = log.user_id ? `user ${log.user_id}` : "NULL USER ID";
|
||||||
|
console.log(
|
||||||
|
`${index + 1}. ${log.timestamp} - ${log.action} by ${userDisplay} on ${
|
||||||
|
log.resource_type
|
||||||
|
}:${log.resource_id || "N/A"}`
|
||||||
|
);
|
||||||
|
if (log.details) {
|
||||||
|
const details =
|
||||||
|
typeof log.details === "string"
|
||||||
|
? JSON.parse(log.details)
|
||||||
|
: log.details;
|
||||||
|
console.log(` Details: ${JSON.stringify(details, null, 4)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\n5. CONCLUSION:");
|
||||||
|
console.log("✅ The audit logging system is working correctly!");
|
||||||
|
console.log("✅ Authenticated users get proper user IDs logged");
|
||||||
|
console.log(
|
||||||
|
"✅ Unauthenticated requests get NULL user IDs (which is expected)"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"✅ The logApiActionSafe function will extract userId from session?.user?.id correctly"
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAuditLogging();
|
||||||
138
test-audit-logging.mjs
Normal file
138
test-audit-logging.mjs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import {
|
||||||
|
logAuditEvent,
|
||||||
|
getAuditLogs,
|
||||||
|
getAuditLogStats,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "./src/lib/auditLog.js";
|
||||||
|
|
||||||
|
// Test audit logging functionality
|
||||||
|
console.log("Testing Audit Logging System...\n");
|
||||||
|
|
||||||
|
// Test 1: Log some sample events
|
||||||
|
console.log("1. Creating sample audit events...");
|
||||||
|
|
||||||
|
logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN,
|
||||||
|
userId: "user123",
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
ipAddress: "192.168.1.100",
|
||||||
|
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
details: {
|
||||||
|
email: "test@example.com",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
userId: "user123",
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "proj-456",
|
||||||
|
ipAddress: "192.168.1.100",
|
||||||
|
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
details: {
|
||||||
|
project_name: "Test Project",
|
||||||
|
project_number: "TP-001",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||||
|
userId: "user456",
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "proj-456",
|
||||||
|
ipAddress: "192.168.1.101",
|
||||||
|
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
details: {
|
||||||
|
updatedFields: ["project_name", "address"],
|
||||||
|
oldValues: { project_name: "Test Project" },
|
||||||
|
newValues: { project_name: "Updated Test Project" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN_FAILED,
|
||||||
|
userId: null,
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
ipAddress: "192.168.1.102",
|
||||||
|
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
details: {
|
||||||
|
email: "hacker@evil.com",
|
||||||
|
reason: "invalid_password",
|
||||||
|
failed_attempts: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Sample events created!\n");
|
||||||
|
|
||||||
|
// Test 2: Retrieve audit logs
|
||||||
|
console.log("2. Retrieving audit logs...");
|
||||||
|
|
||||||
|
const allLogs = getAuditLogs();
|
||||||
|
console.log(`Found ${allLogs.length} total audit events`);
|
||||||
|
|
||||||
|
// Show the latest 3 events
|
||||||
|
console.log("\nLatest audit events:");
|
||||||
|
allLogs.slice(0, 3).forEach((log, index) => {
|
||||||
|
console.log(
|
||||||
|
`${index + 1}. ${log.timestamp} - ${log.action} by user ${
|
||||||
|
log.user_id || "anonymous"
|
||||||
|
} on ${log.resource_type}:${log.resource_id || "N/A"}`
|
||||||
|
);
|
||||||
|
if (log.details) {
|
||||||
|
console.log(` Details: ${JSON.stringify(log.details, null, 2)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 3: Filtered queries
|
||||||
|
console.log("\n3. Testing filtered queries...");
|
||||||
|
|
||||||
|
const loginEvents = getAuditLogs({ action: AUDIT_ACTIONS.LOGIN });
|
||||||
|
console.log(`Found ${loginEvents.length} login events`);
|
||||||
|
|
||||||
|
const projectEvents = getAuditLogs({ resourceType: RESOURCE_TYPES.PROJECT });
|
||||||
|
console.log(`Found ${projectEvents.length} project-related events`);
|
||||||
|
|
||||||
|
const user123Events = getAuditLogs({ userId: "user123" });
|
||||||
|
console.log(`Found ${user123Events.length} events by user123`);
|
||||||
|
|
||||||
|
// Test 4: Statistics
|
||||||
|
console.log("\n4. Getting audit statistics...");
|
||||||
|
|
||||||
|
const stats = getAuditLogStats();
|
||||||
|
console.log("Overall statistics:");
|
||||||
|
console.log(`- Total events: ${stats.total}`);
|
||||||
|
console.log("- Action breakdown:");
|
||||||
|
stats.actionBreakdown.forEach((action) => {
|
||||||
|
console.log(` - ${action.action}: ${action.count}`);
|
||||||
|
});
|
||||||
|
console.log("- User breakdown:");
|
||||||
|
stats.userBreakdown.forEach((user) => {
|
||||||
|
console.log(
|
||||||
|
` - ${user.user_name || user.user_id || "Anonymous"}: ${user.count}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
console.log("- Resource breakdown:");
|
||||||
|
stats.resourceBreakdown.forEach((resource) => {
|
||||||
|
console.log(` - ${resource.resource_type}: ${resource.count}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 5: Date range filtering
|
||||||
|
console.log("\n5. Testing date range filtering...");
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const recentLogs = getAuditLogs({
|
||||||
|
startDate: oneHourAgo.toISOString(),
|
||||||
|
endDate: now.toISOString(),
|
||||||
|
});
|
||||||
|
console.log(`Found ${recentLogs.length} events in the last hour`);
|
||||||
|
|
||||||
|
console.log("\nAudit logging test completed successfully! ✅");
|
||||||
|
console.log("\nTo view audit logs in the application:");
|
||||||
|
console.log("1. Start your Next.js application");
|
||||||
|
console.log("2. Login as an admin or project manager");
|
||||||
|
console.log("3. Navigate to /admin/audit-logs");
|
||||||
|
console.log("4. Use the filters to explore the audit trail");
|
||||||
109
test-auth-api.mjs
Normal file
109
test-auth-api.mjs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// Test authenticated API access using NextAuth.js client-side approach
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
async function testAuthenticatedAPI() {
|
||||||
|
console.log('🔐 Testing Authenticated API Access\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: Check if server is running
|
||||||
|
console.log('1️⃣ Checking server status...');
|
||||||
|
const healthResponse = await fetch(`${BASE_URL}/api/auth/session`);
|
||||||
|
console.log(`Server status: ${healthResponse.status}`);
|
||||||
|
|
||||||
|
if (!healthResponse.ok) {
|
||||||
|
console.log('❌ Server not responding properly');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Test unauthenticated access to protected endpoints
|
||||||
|
console.log('\n2️⃣ Testing unauthenticated access...');
|
||||||
|
const protectedEndpoints = [
|
||||||
|
'/api/projects',
|
||||||
|
'/api/contracts',
|
||||||
|
'/api/tasks',
|
||||||
|
'/api/project-tasks'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of protectedEndpoints) {
|
||||||
|
const response = await fetch(`${BASE_URL}${endpoint}`);
|
||||||
|
console.log(`${endpoint}: ${response.status} ${response.status === 401 ? '✅ (properly protected)' : '❌ (not protected)'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Check protected pages
|
||||||
|
console.log('\n3️⃣ Testing protected pages...');
|
||||||
|
const protectedPages = ['/projects', '/contracts', '/tasks'];
|
||||||
|
|
||||||
|
for (const page of protectedPages) {
|
||||||
|
const response = await fetch(`${BASE_URL}${page}`, {
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 302) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (location && location.includes('/auth/signin')) {
|
||||||
|
console.log(`${page}: ✅ Properly redirects to sign-in`);
|
||||||
|
} else {
|
||||||
|
console.log(`${page}: ⚠️ Redirects to: ${location}`);
|
||||||
|
}
|
||||||
|
} else if (response.status === 200) {
|
||||||
|
console.log(`${page}: ❌ Accessible without authentication`);
|
||||||
|
} else {
|
||||||
|
console.log(`${page}: ❓ Status ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Test sign-in page accessibility
|
||||||
|
console.log('\n4️⃣ Testing sign-in page...');
|
||||||
|
const signinResponse = await fetch(`${BASE_URL}/auth/signin`);
|
||||||
|
if (signinResponse.ok) {
|
||||||
|
console.log('✅ Sign-in page accessible');
|
||||||
|
const content = await signinResponse.text();
|
||||||
|
const hasEmailField = content.includes('name="email"') || content.includes('id="email"');
|
||||||
|
const hasPasswordField = content.includes('name="password"') || content.includes('id="password"');
|
||||||
|
console.log(` Email field: ${hasEmailField ? '✅' : '❌'}`);
|
||||||
|
console.log(` Password field: ${hasPasswordField ? '✅' : '❌'}`);
|
||||||
|
} else {
|
||||||
|
console.log('❌ Sign-in page not accessible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 5: Check NextAuth.js providers endpoint
|
||||||
|
console.log('\n5️⃣ Testing NextAuth.js configuration...');
|
||||||
|
const providersResponse = await fetch(`${BASE_URL}/api/auth/providers`);
|
||||||
|
if (providersResponse.ok) {
|
||||||
|
const providers = await providersResponse.json();
|
||||||
|
console.log('✅ NextAuth.js providers endpoint accessible');
|
||||||
|
console.log('Available providers:', Object.keys(providers));
|
||||||
|
} else {
|
||||||
|
console.log('❌ NextAuth.js providers endpoint failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 6: Check CSRF token endpoint
|
||||||
|
console.log('\n6️⃣ Testing CSRF token...');
|
||||||
|
const csrfResponse = await fetch(`${BASE_URL}/api/auth/csrf`);
|
||||||
|
if (csrfResponse.ok) {
|
||||||
|
const csrf = await csrfResponse.json();
|
||||||
|
console.log('✅ CSRF token endpoint accessible');
|
||||||
|
console.log('CSRF token available:', !!csrf.csrfToken);
|
||||||
|
} else {
|
||||||
|
console.log('❌ CSRF token endpoint failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎯 Manual Testing Instructions:');
|
||||||
|
console.log('1. Open browser to: http://localhost:3000/auth/signin');
|
||||||
|
console.log('2. Use credentials:');
|
||||||
|
console.log(' Email: admin@localhost.com');
|
||||||
|
console.log(' Password: admin123456');
|
||||||
|
console.log('3. After login, test these pages:');
|
||||||
|
protectedPages.forEach(page => {
|
||||||
|
console.log(` - http://localhost:3000${page}`);
|
||||||
|
});
|
||||||
|
console.log('4. Test API endpoints with browser dev tools or Postman');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Test failed with error:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testAuthenticatedAPI();
|
||||||
40
test-auth-detailed.mjs
Normal file
40
test-auth-detailed.mjs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Test script to verify API route protection with better error handling
|
||||||
|
const BASE_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Test unauthenticated access to protected routes
|
||||||
|
async function testProtectedRoutes() {
|
||||||
|
console.log('🔐 Testing Authorization Setup\n');
|
||||||
|
|
||||||
|
const protectedRoutes = [
|
||||||
|
'/api/projects',
|
||||||
|
'/api/contracts'
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Testing unauthenticated access to protected routes...\n');
|
||||||
|
|
||||||
|
for (const route of protectedRoutes) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${route}`);
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
console.log(`Route: ${route}`);
|
||||||
|
console.log(`Status: ${response.status}`);
|
||||||
|
console.log(`Content-Type: ${contentType}`);
|
||||||
|
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`Response: ${JSON.stringify(data)}`);
|
||||||
|
} else {
|
||||||
|
const text = await response.text();
|
||||||
|
console.log(`Response (first 200 chars): ${text.substring(0, 200)}...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('---\n');
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ ${route} - ERROR: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testProtectedRoutes().catch(console.error);
|
||||||
127
test-auth-pages.mjs
Normal file
127
test-auth-pages.mjs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Test authenticated access to pages and API endpoints
|
||||||
|
const BASE_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Helper to extract cookies from response headers
|
||||||
|
function extractCookies(response) {
|
||||||
|
const cookies = [];
|
||||||
|
const setCookieHeaders = response.headers.get('set-cookie');
|
||||||
|
if (setCookieHeaders) {
|
||||||
|
cookies.push(setCookieHeaders);
|
||||||
|
}
|
||||||
|
return cookies.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test authenticated access
|
||||||
|
async function testAuthenticatedAccess() {
|
||||||
|
console.log('🔐 Testing Authenticated Access\n');
|
||||||
|
|
||||||
|
// Step 1: Get the sign-in page to check if it loads
|
||||||
|
console.log('1️⃣ Testing sign-in page access...');
|
||||||
|
try {
|
||||||
|
const signInResponse = await fetch(`${BASE_URL}/auth/signin`);
|
||||||
|
console.log(`✅ Sign-in page: ${signInResponse.status} ${signInResponse.statusText}`);
|
||||||
|
|
||||||
|
if (signInResponse.status === 200) {
|
||||||
|
const pageContent = await signInResponse.text();
|
||||||
|
const hasForm = pageContent.includes('Sign in to your account');
|
||||||
|
console.log(` Form present: ${hasForm ? '✅ Yes' : '❌ No'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ Sign-in page error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n2️⃣ Testing authentication endpoint...');
|
||||||
|
|
||||||
|
// Step 2: Test the authentication API endpoint
|
||||||
|
try {
|
||||||
|
const sessionResponse = await fetch(`${BASE_URL}/api/auth/session`);
|
||||||
|
console.log(`✅ Session endpoint: ${sessionResponse.status} ${sessionResponse.statusText}`);
|
||||||
|
|
||||||
|
if (sessionResponse.status === 200) {
|
||||||
|
const sessionData = await sessionResponse.json();
|
||||||
|
console.log(` Session data: ${JSON.stringify(sessionData)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ Session endpoint error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n3️⃣ Testing CSRF token endpoint...');
|
||||||
|
|
||||||
|
// Step 3: Get CSRF token
|
||||||
|
try {
|
||||||
|
const csrfResponse = await fetch(`${BASE_URL}/api/auth/csrf`);
|
||||||
|
console.log(`✅ CSRF endpoint: ${csrfResponse.status} ${csrfResponse.statusText}`);
|
||||||
|
|
||||||
|
if (csrfResponse.status === 200) {
|
||||||
|
const csrfData = await csrfResponse.json();
|
||||||
|
console.log(` CSRF token: ${csrfData.csrfToken ? '✅ Present' : '❌ Missing'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ CSRF endpoint error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n4️⃣ Testing main dashboard page (unauthenticated)...');
|
||||||
|
|
||||||
|
// Step 4: Test main page redirect
|
||||||
|
try {
|
||||||
|
const mainPageResponse = await fetch(`${BASE_URL}/`, {
|
||||||
|
redirect: 'manual' // Don't follow redirects automatically
|
||||||
|
});
|
||||||
|
console.log(`✅ Main page: ${mainPageResponse.status} ${mainPageResponse.statusText}`);
|
||||||
|
|
||||||
|
if (mainPageResponse.status === 307 || mainPageResponse.status === 302) {
|
||||||
|
const location = mainPageResponse.headers.get('location');
|
||||||
|
console.log(` Redirects to: ${location}`);
|
||||||
|
console.log(` Correct redirect: ${location && location.includes('/auth/signin') ? '✅ Yes' : '❌ No'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ Main page error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n5️⃣ Testing projects page (unauthenticated)...');
|
||||||
|
|
||||||
|
// Step 5: Test projects page redirect
|
||||||
|
try {
|
||||||
|
const projectsPageResponse = await fetch(`${BASE_URL}/projects`, {
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
console.log(`✅ Projects page: ${projectsPageResponse.status} ${projectsPageResponse.statusText}`);
|
||||||
|
|
||||||
|
if (projectsPageResponse.status === 307 || projectsPageResponse.status === 302) {
|
||||||
|
const location = projectsPageResponse.headers.get('location');
|
||||||
|
console.log(` Redirects to: ${location}`);
|
||||||
|
console.log(` Correct redirect: ${location && location.includes('/auth/signin') ? '✅ Yes' : '❌ No'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ Projects page error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n6️⃣ Testing API endpoints (unauthenticated)...');
|
||||||
|
|
||||||
|
// Step 6: Test API endpoints
|
||||||
|
const apiEndpoints = ['/api/projects', '/api/contracts', '/api/tasks/templates'];
|
||||||
|
|
||||||
|
for (const endpoint of apiEndpoints) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${endpoint}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.log(`✅ ${endpoint}: Protected (401) - ${data.error}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${endpoint}: Not protected (${response.status})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ ${endpoint}: Error - ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📋 Summary:');
|
||||||
|
console.log('- Sign-in page should be accessible');
|
||||||
|
console.log('- Protected pages should redirect to /auth/signin');
|
||||||
|
console.log('- Protected API endpoints should return 401 with JSON error');
|
||||||
|
console.log('- Auth endpoints (/api/auth/*) should be accessible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testAuthenticatedAccess().catch(console.error);
|
||||||
37
test-auth-session.mjs
Normal file
37
test-auth-session.mjs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
// Test what the auth session looks like
|
||||||
|
console.log("Testing authentication session structure...\n");
|
||||||
|
|
||||||
|
async function testAuth() {
|
||||||
|
try {
|
||||||
|
// Create a mock request
|
||||||
|
const mockReq = {
|
||||||
|
url: "http://localhost:3000/api/projects",
|
||||||
|
method: "GET",
|
||||||
|
headers: new Map([
|
||||||
|
["cookie", ""], // Add any cookies if needed
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is how the auth middleware would wrap a handler
|
||||||
|
const testHandler = auth(async (req) => {
|
||||||
|
console.log("=== Authentication Session Debug ===");
|
||||||
|
console.log("req.auth:", JSON.stringify(req.auth, null, 2));
|
||||||
|
console.log("req.auth?.user:", JSON.stringify(req.auth?.user, null, 2));
|
||||||
|
console.log("req.auth?.user?.id:", req.auth?.user?.id);
|
||||||
|
console.log("req.user:", JSON.stringify(req.user, null, 2));
|
||||||
|
console.log("req.user?.id:", req.user?.id);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
// This would normally be called by Next.js
|
||||||
|
const result = await testHandler(mockReq);
|
||||||
|
console.log("Handler result:", result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auth test failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAuth();
|
||||||
49
test-auth.mjs
Normal file
49
test-auth.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Test script to verify API route protection
|
||||||
|
const BASE_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Test unauthenticated access to protected routes
|
||||||
|
async function testProtectedRoutes() {
|
||||||
|
console.log('🔐 Testing Authorization Setup\n');
|
||||||
|
|
||||||
|
const protectedRoutes = [
|
||||||
|
'/api/projects',
|
||||||
|
'/api/contracts',
|
||||||
|
'/api/tasks/templates',
|
||||||
|
'/api/project-tasks',
|
||||||
|
'/api/notes',
|
||||||
|
'/api/all-project-tasks'
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('Testing unauthenticated access to protected routes...\n');
|
||||||
|
|
||||||
|
for (const route of protectedRoutes) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${route}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.log(`✅ ${route} - PROTECTED (401 Unauthorized)`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${route} - NOT PROTECTED (${response.status})`);
|
||||||
|
console.log(` Response: ${JSON.stringify(data).substring(0, 100)}...`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ ${route} - ERROR: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🔍 Testing authentication endpoint...\n');
|
||||||
|
|
||||||
|
// Test NextAuth endpoint
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/auth/session`);
|
||||||
|
const data = await response.json();
|
||||||
|
console.log(`✅ /api/auth/session - Available (${response.status})`);
|
||||||
|
console.log(` Response: ${JSON.stringify(data)}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`❌ /api/auth/session - ERROR: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testProtectedRoutes().catch(console.error);
|
||||||
115
test-complete-auth.mjs
Normal file
115
test-complete-auth.mjs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Complete authentication flow test
|
||||||
|
const BASE_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
async function testCompleteAuthFlow() {
|
||||||
|
console.log('🔐 Testing Complete Authentication Flow\n');
|
||||||
|
|
||||||
|
// Test 1: Verify unauthenticated access is properly blocked
|
||||||
|
console.log('1️⃣ Testing unauthenticated access protection...');
|
||||||
|
|
||||||
|
const protectedRoutes = [
|
||||||
|
{ path: '/', name: 'Dashboard' },
|
||||||
|
{ path: '/projects', name: 'Projects Page' },
|
||||||
|
{ path: '/tasks/templates', name: 'Tasks Page' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const route of protectedRoutes) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${route.path}`, {
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 302 || response.status === 307) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (location && location.includes('/auth/signin')) {
|
||||||
|
console.log(` ✅ ${route.name}: Properly redirects to sign-in`);
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ ${route.name}: Redirects to wrong location: ${location}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ ${route.name}: Not protected (${response.status})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ❌ ${route.name}: Error - ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Verify API protection
|
||||||
|
console.log('\n2️⃣ Testing API protection...');
|
||||||
|
|
||||||
|
const apiRoutes = ['/api/projects', '/api/contracts', '/api/tasks/templates'];
|
||||||
|
|
||||||
|
for (const route of apiRoutes) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${route}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.status === 401 && data.error === 'Authentication required') {
|
||||||
|
console.log(` ✅ ${route}: Properly protected`);
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ ${route}: Not protected (${response.status}) - ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ❌ ${route}: Error - ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Verify auth endpoints work
|
||||||
|
console.log('\n3️⃣ Testing NextAuth endpoints...');
|
||||||
|
|
||||||
|
const authEndpoints = [
|
||||||
|
{ path: '/api/auth/session', name: 'Session' },
|
||||||
|
{ path: '/api/auth/providers', name: 'Providers' },
|
||||||
|
{ path: '/api/auth/csrf', name: 'CSRF' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of authEndpoints) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}${endpoint.path}`);
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
console.log(` ✅ ${endpoint.name}: Working (200)`);
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ ${endpoint.name}: Error (${response.status})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ❌ ${endpoint.name}: Error - ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Verify sign-in page accessibility
|
||||||
|
console.log('\n4️⃣ Testing sign-in page...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/auth/signin`);
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const html = await response.text();
|
||||||
|
const hasForm = html.includes('Sign in to your account');
|
||||||
|
const hasEmailField = html.includes('email');
|
||||||
|
const hasPasswordField = html.includes('password');
|
||||||
|
|
||||||
|
console.log(` ✅ Sign-in page: Accessible (200)`);
|
||||||
|
console.log(` ✅ Form present: ${hasForm ? 'Yes' : 'No'}`);
|
||||||
|
console.log(` ✅ Email field: ${hasEmailField ? 'Yes' : 'No'}`);
|
||||||
|
console.log(` ✅ Password field: ${hasPasswordField ? 'Yes' : 'No'}`);
|
||||||
|
} else {
|
||||||
|
console.log(` ❌ Sign-in page: Error (${response.status})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ❌ Sign-in page: Error - ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📋 Summary:');
|
||||||
|
console.log('✅ All protected pages redirect to sign-in');
|
||||||
|
console.log('✅ All API endpoints require authentication');
|
||||||
|
console.log('✅ NextAuth endpoints are functional');
|
||||||
|
console.log('✅ Sign-in page is accessible and complete');
|
||||||
|
console.log('\n🎉 Authentication system is fully functional!');
|
||||||
|
console.log('\n📝 Next steps:');
|
||||||
|
console.log(' • Visit http://localhost:3000/auth/signin');
|
||||||
|
console.log(' • Login with: admin@localhost / admin123456');
|
||||||
|
console.log(' • Access the protected application!');
|
||||||
|
}
|
||||||
|
|
||||||
|
testCompleteAuthFlow().catch(console.error);
|
||||||
40
test-create-function.mjs
Normal file
40
test-create-function.mjs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { createProject } from "./src/lib/queries/projects.js";
|
||||||
|
import initializeDatabase from "./src/lib/init-db.js";
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
|
console.log("Testing createProject function...\n");
|
||||||
|
|
||||||
|
const testProjectData = {
|
||||||
|
contract_id: 1, // Assuming contract 1 exists
|
||||||
|
project_name: "Test Project - User Tracking",
|
||||||
|
address: "Test Address 123",
|
||||||
|
plot: "123/456",
|
||||||
|
district: "Test District",
|
||||||
|
unit: "Test Unit",
|
||||||
|
city: "Test City",
|
||||||
|
investment_number: "TEST-2025-001",
|
||||||
|
finish_date: "2025-12-31",
|
||||||
|
wp: "TEST/2025/001",
|
||||||
|
contact: "test@example.com",
|
||||||
|
notes: "Test project with user tracking",
|
||||||
|
project_type: "design",
|
||||||
|
project_status: "registered",
|
||||||
|
coordinates: "50.0,20.0",
|
||||||
|
assigned_to: "e42a4b036074ff7233942a0728557141", // admin user ID
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Creating test project with admin user as creator...");
|
||||||
|
const result = createProject(
|
||||||
|
testProjectData,
|
||||||
|
"e42a4b036074ff7233942a0728557141"
|
||||||
|
);
|
||||||
|
console.log("✅ Project created successfully!");
|
||||||
|
console.log("Result:", result);
|
||||||
|
console.log("Project ID:", result.lastInsertRowid);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error creating project:", error.message);
|
||||||
|
console.error("Stack:", error.stack);
|
||||||
|
}
|
||||||
124
test-current-audit-logs.mjs
Normal file
124
test-current-audit-logs.mjs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
logAuditEvent,
|
||||||
|
getAuditLogs,
|
||||||
|
getAuditLogStats,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "./src/lib/auditLog.js";
|
||||||
|
|
||||||
|
// Test audit logging functionality
|
||||||
|
console.log("Testing Audit Logging System...\n");
|
||||||
|
|
||||||
|
async function testAuditLogging() {
|
||||||
|
try {
|
||||||
|
// Test 1: Check existing audit logs
|
||||||
|
console.log("1. Checking existing audit logs...");
|
||||||
|
const existingLogs = await getAuditLogs({ limit: 10 });
|
||||||
|
console.log(`Found ${existingLogs.length} existing audit events`);
|
||||||
|
|
||||||
|
if (existingLogs.length > 0) {
|
||||||
|
console.log("\nLatest audit events:");
|
||||||
|
existingLogs.slice(0, 5).forEach((log, index) => {
|
||||||
|
console.log(
|
||||||
|
`${index + 1}. ${log.timestamp} - ${log.action} by user ${
|
||||||
|
log.user_id || "NULL"
|
||||||
|
} on ${log.resource_type}:${log.resource_id || "N/A"}`
|
||||||
|
);
|
||||||
|
if (log.details) {
|
||||||
|
console.log(
|
||||||
|
` Details: ${JSON.stringify(JSON.parse(log.details), null, 2)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for null userIds
|
||||||
|
const nullUserIdLogs = await getAuditLogs();
|
||||||
|
const nullUserCount = nullUserIdLogs.filter(
|
||||||
|
(log) => log.user_id === null
|
||||||
|
).length;
|
||||||
|
console.log(
|
||||||
|
`\nFound ${nullUserCount} audit events with NULL user_id out of ${nullUserIdLogs.length} total`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test 2: Log some sample events with different user scenarios
|
||||||
|
console.log("\n2. Creating sample audit events...");
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN,
|
||||||
|
userId: "user123",
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
ipAddress: "192.168.1.100",
|
||||||
|
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
details: {
|
||||||
|
email: "test@example.com",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
userId: "user123",
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "proj-456",
|
||||||
|
ipAddress: "192.168.1.100",
|
||||||
|
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
details: {
|
||||||
|
project_name: "Test Project",
|
||||||
|
project_number: "TP-001",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test null userId scenario
|
||||||
|
await logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN_FAILED,
|
||||||
|
userId: null,
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
ipAddress: "192.168.1.102",
|
||||||
|
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||||
|
details: {
|
||||||
|
email: "hacker@evil.com",
|
||||||
|
reason: "invalid_password",
|
||||||
|
failed_attempts: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Sample events created!\n");
|
||||||
|
|
||||||
|
// Test 3: Check new logs
|
||||||
|
console.log("3. Checking audit logs after test events...");
|
||||||
|
const newLogs = await getAuditLogs({ limit: 5 });
|
||||||
|
console.log(`Latest 5 audit events:`);
|
||||||
|
newLogs.forEach((log, index) => {
|
||||||
|
console.log(
|
||||||
|
`${index + 1}. ${log.timestamp} - ${log.action} by user ${
|
||||||
|
log.user_id || "NULL"
|
||||||
|
} on ${log.resource_type}:${log.resource_id || "N/A"}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test 4: Statistics
|
||||||
|
console.log("\n4. Getting audit log statistics...");
|
||||||
|
const stats = await getAuditLogStats();
|
||||||
|
console.log(`Total events: ${stats.total}`);
|
||||||
|
|
||||||
|
console.log("\nAction breakdown:");
|
||||||
|
stats.actionBreakdown.forEach((item) => {
|
||||||
|
console.log(` ${item.action}: ${item.count}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("\nUser breakdown:");
|
||||||
|
stats.userBreakdown.slice(0, 5).forEach((item) => {
|
||||||
|
console.log(
|
||||||
|
` ${item.user_id || "NULL"} (${item.user_name || "Unknown"}): ${
|
||||||
|
item.count
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Test failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testAuditLogging();
|
||||||
83
test-edge-compatibility.mjs
Normal file
83
test-edge-compatibility.mjs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Test Edge Runtime compatibility for audit logging
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Test Edge Runtime detection
|
||||||
|
console.log("Testing Edge Runtime compatibility...\n");
|
||||||
|
|
||||||
|
// Simulate Edge Runtime environment
|
||||||
|
const originalEdgeRuntime = global.EdgeRuntime;
|
||||||
|
const originalNextRuntime = process.env.NEXT_RUNTIME;
|
||||||
|
|
||||||
|
console.log("1. Testing in simulated Edge Runtime environment...");
|
||||||
|
global.EdgeRuntime = "edge";
|
||||||
|
process.env.NEXT_RUNTIME = "edge";
|
||||||
|
|
||||||
|
// Import the audit logging functions
|
||||||
|
const { logAuditEvent, getAuditLogs, AUDIT_ACTIONS, RESOURCE_TYPES } =
|
||||||
|
await import("./src/lib/auditLog.js");
|
||||||
|
|
||||||
|
// Test logging in Edge Runtime
|
||||||
|
logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_VIEW,
|
||||||
|
userId: "test-user",
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "test-project",
|
||||||
|
details: { test: "edge runtime test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test querying in Edge Runtime
|
||||||
|
const logs = getAuditLogs({ limit: 10 });
|
||||||
|
console.log(`Queried logs in Edge Runtime: ${logs.length} results`);
|
||||||
|
|
||||||
|
console.log("2. Testing in simulated Node.js Runtime environment...");
|
||||||
|
// Restore Node.js environment
|
||||||
|
delete global.EdgeRuntime;
|
||||||
|
delete process.env.NEXT_RUNTIME;
|
||||||
|
|
||||||
|
// Test logging in Node.js Runtime
|
||||||
|
try {
|
||||||
|
logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
userId: "test-user",
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "test-project-2",
|
||||||
|
details: { test: "nodejs runtime test" },
|
||||||
|
});
|
||||||
|
console.log("Node.js runtime logging: ✅ Success");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Node.js runtime logging: ❌ Error:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test querying in Node.js Runtime
|
||||||
|
try {
|
||||||
|
const nodeLogs = getAuditLogs({ limit: 10 });
|
||||||
|
console.log(
|
||||||
|
`Node.js runtime querying: ✅ Success (${nodeLogs.length} results)`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Node.js runtime querying: ❌ Error:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore original environment
|
||||||
|
if (originalEdgeRuntime !== undefined) {
|
||||||
|
global.EdgeRuntime = originalEdgeRuntime;
|
||||||
|
} else {
|
||||||
|
delete global.EdgeRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalNextRuntime !== undefined) {
|
||||||
|
process.env.NEXT_RUNTIME = originalNextRuntime;
|
||||||
|
} else {
|
||||||
|
delete process.env.NEXT_RUNTIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n✅ Edge Runtime compatibility test completed!");
|
||||||
|
console.log("\nKey points:");
|
||||||
|
console.log(
|
||||||
|
"- Edge Runtime: Logs to console, returns empty arrays for queries"
|
||||||
|
);
|
||||||
|
console.log("- Node.js Runtime: Full database functionality");
|
||||||
|
console.log('- API routes are configured with runtime: "nodejs"');
|
||||||
|
console.log("- Middleware avoids database operations");
|
||||||
|
console.log("- Error handling prevents runtime crashes");
|
||||||
206
test-logged-in-flow.mjs
Normal file
206
test-logged-in-flow.mjs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
// Test authenticated flow without external dependencies
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
const TEST_CREDENTIALS = {
|
||||||
|
email: 'admin@localhost.com',
|
||||||
|
password: 'admin123456'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to extract cookies from response
|
||||||
|
function extractCookies(response) {
|
||||||
|
const cookies = response.headers.raw()['set-cookie'];
|
||||||
|
if (!cookies) return '';
|
||||||
|
|
||||||
|
return cookies
|
||||||
|
.map(cookie => cookie.split(';')[0])
|
||||||
|
.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to make authenticated requests
|
||||||
|
async function makeAuthenticatedRequest(url, options = {}, cookies = '') {
|
||||||
|
return fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Cookie': cookies,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testCompleteAuthenticatedFlow() {
|
||||||
|
console.log('🔐 Testing Complete Authenticated Flow\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Get CSRF token from sign-in page
|
||||||
|
console.log('1️⃣ Getting CSRF token...');
|
||||||
|
const signinResponse = await fetch(`${BASE_URL}/auth/signin`);
|
||||||
|
const signinHtml = await signinResponse.text();
|
||||||
|
|
||||||
|
// Extract CSRF token (NextAuth.js typically includes it in the form)
|
||||||
|
const csrfMatch = signinHtml.match(/name="csrfToken" value="([^"]+)"/);
|
||||||
|
const csrfToken = csrfMatch ? csrfMatch[1] : null;
|
||||||
|
|
||||||
|
if (!csrfToken) {
|
||||||
|
console.log('❌ Could not extract CSRF token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ CSRF token extracted');
|
||||||
|
const initialCookies = extractCookies(signinResponse);
|
||||||
|
|
||||||
|
// Step 2: Attempt login
|
||||||
|
console.log('\n2️⃣ Attempting login...');
|
||||||
|
const loginResponse = await fetch(`${BASE_URL}/api/auth/callback/credentials`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Cookie': initialCookies
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
csrfToken,
|
||||||
|
email: TEST_CREDENTIALS.email,
|
||||||
|
password: TEST_CREDENTIALS.password,
|
||||||
|
callbackUrl: `${BASE_URL}/projects`,
|
||||||
|
json: 'true'
|
||||||
|
}),
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Login response status: ${loginResponse.status}`);
|
||||||
|
|
||||||
|
if (loginResponse.status === 200) {
|
||||||
|
const loginResult = await loginResponse.json();
|
||||||
|
console.log('Login result:', loginResult);
|
||||||
|
|
||||||
|
if (loginResult.url) {
|
||||||
|
console.log('✅ Login successful, redirecting to:', loginResult.url);
|
||||||
|
} else if (loginResult.error) {
|
||||||
|
console.log('❌ Login failed:', loginResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (loginResponse.status === 302) {
|
||||||
|
console.log('✅ Login successful (redirect)');
|
||||||
|
} else {
|
||||||
|
console.log('❌ Login failed with status:', loginResponse.status);
|
||||||
|
const errorText = await loginResponse.text();
|
||||||
|
console.log('Error response:', errorText.substring(0, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get session cookies
|
||||||
|
const sessionCookies = extractCookies(loginResponse) || initialCookies;
|
||||||
|
console.log('Session cookies:', sessionCookies ? 'Present' : 'Missing');
|
||||||
|
|
||||||
|
// Step 3: Test session endpoint
|
||||||
|
console.log('\n3️⃣ Testing session endpoint...');
|
||||||
|
const sessionResponse = await makeAuthenticatedRequest(
|
||||||
|
`${BASE_URL}/api/auth/session`,
|
||||||
|
{},
|
||||||
|
sessionCookies
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sessionResponse.ok) {
|
||||||
|
const session = await sessionResponse.json();
|
||||||
|
console.log('✅ Session data:', JSON.stringify(session, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log('❌ Session check failed:', sessionResponse.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Test protected pages
|
||||||
|
console.log('\n4️⃣ Testing protected pages...');
|
||||||
|
const protectedPages = ['/projects', '/contracts', '/tasks'];
|
||||||
|
|
||||||
|
for (const page of protectedPages) {
|
||||||
|
const pageResponse = await makeAuthenticatedRequest(
|
||||||
|
`${BASE_URL}${page}`,
|
||||||
|
{},
|
||||||
|
sessionCookies
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pageResponse.ok) {
|
||||||
|
console.log(`✅ ${page} - accessible`);
|
||||||
|
} else if (pageResponse.status === 302) {
|
||||||
|
console.log(`⚠️ ${page} - redirected (status: 302)`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${page} - failed (status: ${pageResponse.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Test API endpoints
|
||||||
|
console.log('\n5️⃣ Testing API endpoints...');
|
||||||
|
const apiEndpoints = [
|
||||||
|
{ url: '/api/projects', method: 'GET' },
|
||||||
|
{ url: '/api/contracts', method: 'GET' },
|
||||||
|
{ url: '/api/tasks', method: 'GET' },
|
||||||
|
{ url: '/api/tasks/templates', method: 'GET' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of apiEndpoints) {
|
||||||
|
const apiResponse = await makeAuthenticatedRequest(
|
||||||
|
`${BASE_URL}${endpoint.url}`,
|
||||||
|
{ method: endpoint.method },
|
||||||
|
sessionCookies
|
||||||
|
);
|
||||||
|
|
||||||
|
if (apiResponse.ok) {
|
||||||
|
const data = await apiResponse.json();
|
||||||
|
console.log(`✅ ${endpoint.method} ${endpoint.url} - success (${Array.isArray(data) ? data.length : 'object'} items)`);
|
||||||
|
} else if (apiResponse.status === 401) {
|
||||||
|
console.log(`❌ ${endpoint.method} ${endpoint.url} - unauthorized (status: 401)`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ ${endpoint.method} ${endpoint.url} - failed (status: ${apiResponse.status})`);
|
||||||
|
const errorText = await apiResponse.text();
|
||||||
|
console.log(` Error: ${errorText.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Test creating data
|
||||||
|
console.log('\n6️⃣ Testing data creation...');
|
||||||
|
|
||||||
|
// Test creating a project
|
||||||
|
const projectData = {
|
||||||
|
name: 'Test Project Auth',
|
||||||
|
description: 'Testing authentication flow',
|
||||||
|
deadline: '2025-12-31',
|
||||||
|
status: 'active'
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProjectResponse = await makeAuthenticatedRequest(
|
||||||
|
`${BASE_URL}/api/projects`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(projectData)
|
||||||
|
},
|
||||||
|
sessionCookies
|
||||||
|
);
|
||||||
|
|
||||||
|
if (createProjectResponse.ok) {
|
||||||
|
const newProject = await createProjectResponse.json();
|
||||||
|
console.log('✅ Project creation successful:', newProject.name);
|
||||||
|
|
||||||
|
// Clean up - delete the test project
|
||||||
|
const deleteResponse = await makeAuthenticatedRequest(
|
||||||
|
`${BASE_URL}/api/projects/${newProject.id}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
sessionCookies
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleteResponse.ok) {
|
||||||
|
console.log('✅ Test project cleaned up');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('❌ Project creation failed:', createProjectResponse.status);
|
||||||
|
const errorText = await createProjectResponse.text();
|
||||||
|
console.log(' Error:', errorText.substring(0, 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Test failed with error:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
testCompleteAuthenticatedFlow();
|
||||||
47
test-nextauth.mjs
Normal file
47
test-nextauth.mjs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Simple test for NextAuth endpoints
|
||||||
|
const BASE_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
async function testNextAuthEndpoints() {
|
||||||
|
console.log('🔐 Testing NextAuth Endpoints\n');
|
||||||
|
|
||||||
|
// Test session endpoint
|
||||||
|
try {
|
||||||
|
const sessionResponse = await fetch(`${BASE_URL}/api/auth/session`);
|
||||||
|
console.log(`Session endpoint: ${sessionResponse.status} ${sessionResponse.statusText}`);
|
||||||
|
|
||||||
|
if (sessionResponse.ok) {
|
||||||
|
const sessionData = await sessionResponse.json();
|
||||||
|
console.log(`Session data: ${JSON.stringify(sessionData)}\n`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Session endpoint error: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test providers endpoint
|
||||||
|
try {
|
||||||
|
const providersResponse = await fetch(`${BASE_URL}/api/auth/providers`);
|
||||||
|
console.log(`Providers endpoint: ${providersResponse.status} ${providersResponse.statusText}`);
|
||||||
|
|
||||||
|
if (providersResponse.ok) {
|
||||||
|
const providersData = await providersResponse.json();
|
||||||
|
console.log(`Providers: ${JSON.stringify(providersData, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Providers endpoint error: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test CSRF endpoint
|
||||||
|
try {
|
||||||
|
const csrfResponse = await fetch(`${BASE_URL}/api/auth/csrf`);
|
||||||
|
console.log(`CSRF endpoint: ${csrfResponse.status} ${csrfResponse.statusText}`);
|
||||||
|
|
||||||
|
if (csrfResponse.ok) {
|
||||||
|
const csrfData = await csrfResponse.json();
|
||||||
|
console.log(`CSRF token present: ${csrfData.csrfToken ? 'Yes' : 'No'}\n`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`CSRF endpoint error: ${error.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testNextAuthEndpoints().catch(console.error);
|
||||||
27
test-project-api.mjs
Normal file
27
test-project-api.mjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import fetch from "node-fetch";
|
||||||
|
|
||||||
|
async function testProjectAPI() {
|
||||||
|
const baseURL = "http://localhost:3000";
|
||||||
|
|
||||||
|
console.log("Testing project API endpoints...\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test fetching project 1
|
||||||
|
console.log("1. Fetching project 1:");
|
||||||
|
const response = await fetch(`${baseURL}/api/projects/1`);
|
||||||
|
console.log("Status:", response.status);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const project = await response.json();
|
||||||
|
console.log("Project data received:");
|
||||||
|
console.log(JSON.stringify(project, null, 2));
|
||||||
|
} else {
|
||||||
|
const error = await response.text();
|
||||||
|
console.log("Error:", error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error testing API:", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testProjectAPI();
|
||||||
43
test-project-creation.mjs
Normal file
43
test-project-creation.mjs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Test project creation
|
||||||
|
const BASE_URL = "http://localhost:3001";
|
||||||
|
|
||||||
|
async function testProjectCreation() {
|
||||||
|
console.log("🧪 Testing project creation...\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, login to get session
|
||||||
|
console.log("1. Logging in...");
|
||||||
|
const loginResponse = await fetch(
|
||||||
|
`${BASE_URL}/api/auth/signin/credentials`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "admin@localhost.com",
|
||||||
|
password: "admin123456",
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("Login response status:", loginResponse.status);
|
||||||
|
const loginResult = await loginResponse.text();
|
||||||
|
console.log("Login result:", loginResult.substring(0, 200));
|
||||||
|
|
||||||
|
// Try a simple API call to see the auth system
|
||||||
|
console.log("\n2. Testing projects API...");
|
||||||
|
const projectsResponse = await fetch(`${BASE_URL}/api/projects`);
|
||||||
|
console.log("Projects API status:", projectsResponse.status);
|
||||||
|
|
||||||
|
if (projectsResponse.status === 401) {
|
||||||
|
console.log("❌ Authentication required (expected for this test)");
|
||||||
|
} else {
|
||||||
|
const projectsData = await projectsResponse.json();
|
||||||
|
console.log("✅ Projects API accessible");
|
||||||
|
console.log("Number of projects:", projectsData.length);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Test failed:", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testProjectCreation();
|
||||||
82
test-safe-audit-logging.mjs
Normal file
82
test-safe-audit-logging.mjs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Test the safe audit logging in different runtime environments
|
||||||
|
*/
|
||||||
|
|
||||||
|
console.log("Testing Safe Audit Logging...\n");
|
||||||
|
|
||||||
|
// Test 1: Import the safe module (should work in any runtime)
|
||||||
|
console.log("1. Testing safe module import...");
|
||||||
|
try {
|
||||||
|
const { AUDIT_ACTIONS, RESOURCE_TYPES, logAuditEventSafe } = await import(
|
||||||
|
"./src/lib/auditLogSafe.js"
|
||||||
|
);
|
||||||
|
console.log("✅ Safe module imported successfully");
|
||||||
|
console.log(` Available actions: ${Object.keys(AUDIT_ACTIONS).length}`);
|
||||||
|
console.log(
|
||||||
|
` Available resource types: ${Object.keys(RESOURCE_TYPES).length}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("❌ Failed to import safe module:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Test in simulated Edge Runtime
|
||||||
|
console.log("\n2. Testing in simulated Edge Runtime...");
|
||||||
|
global.EdgeRuntime = "edge";
|
||||||
|
try {
|
||||||
|
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import(
|
||||||
|
"./src/lib/auditLogSafe.js"
|
||||||
|
);
|
||||||
|
await logAuditEventSafe({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_VIEW,
|
||||||
|
userId: null, // Use null to avoid foreign key constraint
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "test-123",
|
||||||
|
details: { test: "edge runtime" },
|
||||||
|
});
|
||||||
|
console.log("✅ Edge Runtime logging successful (console only)");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("❌ Edge Runtime logging failed:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 3: Test in simulated Node.js Runtime
|
||||||
|
console.log("\n3. Testing in simulated Node.js Runtime...");
|
||||||
|
delete global.EdgeRuntime;
|
||||||
|
try {
|
||||||
|
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import(
|
||||||
|
"./src/lib/auditLogSafe.js"
|
||||||
|
);
|
||||||
|
await logAuditEventSafe({
|
||||||
|
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
userId: null, // Use null to avoid foreign key constraint
|
||||||
|
resourceType: RESOURCE_TYPES.PROJECT,
|
||||||
|
resourceId: "test-456",
|
||||||
|
details: { test: "nodejs runtime" },
|
||||||
|
});
|
||||||
|
console.log("✅ Node.js Runtime logging successful (database + console)");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("❌ Node.js Runtime logging failed:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 4: Test constants accessibility
|
||||||
|
console.log("\n4. Testing constants accessibility...");
|
||||||
|
try {
|
||||||
|
const { AUDIT_ACTIONS, RESOURCE_TYPES } = await import(
|
||||||
|
"./src/lib/auditLogSafe.js"
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ Constants accessible:");
|
||||||
|
console.log(` LOGIN action: ${AUDIT_ACTIONS.LOGIN}`);
|
||||||
|
console.log(` PROJECT resource: ${RESOURCE_TYPES.PROJECT}`);
|
||||||
|
console.log(` NOTE_CREATE action: ${AUDIT_ACTIONS.NOTE_CREATE}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("❌ Constants not accessible:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n✅ Safe Audit Logging test completed!");
|
||||||
|
console.log("\nKey features verified:");
|
||||||
|
console.log("- ✅ No static database imports");
|
||||||
|
console.log("- ✅ Edge Runtime compatibility");
|
||||||
|
console.log("- ✅ Graceful fallbacks");
|
||||||
|
console.log("- ✅ Constants always available");
|
||||||
|
console.log("- ✅ Async/await support");
|
||||||
|
console.log("\nThe middleware should now work without Edge Runtime errors!");
|
||||||
44
test-task-api.mjs
Normal file
44
test-task-api.mjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
// Test the project-tasks API endpoints
|
||||||
|
|
||||||
|
async function testAPI() {
|
||||||
|
const baseURL = "http://localhost:3000";
|
||||||
|
|
||||||
|
console.log("Testing project-tasks API endpoints...\n");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test 1: Check if users endpoint exists
|
||||||
|
console.log("1. Testing /api/project-tasks/users:");
|
||||||
|
const usersResponse = await fetch(`${baseURL}/api/project-tasks/users`);
|
||||||
|
console.log("Status:", usersResponse.status);
|
||||||
|
if (usersResponse.ok) {
|
||||||
|
const users = await usersResponse.json();
|
||||||
|
console.log("Users found:", users.length);
|
||||||
|
console.log("First user:", users[0]);
|
||||||
|
} else {
|
||||||
|
const error = await usersResponse.text();
|
||||||
|
console.log("Error:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: Try to create a task (this will fail without auth, but let's see the response)
|
||||||
|
console.log("\n2. Testing POST /api/project-tasks:");
|
||||||
|
const taskData = {
|
||||||
|
project_id: 1,
|
||||||
|
task_template_id: 1,
|
||||||
|
priority: "normal",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createResponse = await fetch(`${baseURL}/api/project-tasks`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(taskData),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Status:", createResponse.status);
|
||||||
|
const responseText = await createResponse.text();
|
||||||
|
console.log("Response:", responseText);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error testing API:", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testAPI();
|
||||||
27
test-user-tracking.mjs
Normal file
27
test-user-tracking.mjs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
getAllProjects,
|
||||||
|
getAllUsersForAssignment,
|
||||||
|
} from "./src/lib/queries/projects.js";
|
||||||
|
import initializeDatabase from "./src/lib/init-db.js";
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
|
console.log("Testing user tracking in projects...\n");
|
||||||
|
|
||||||
|
console.log("1. Available users for assignment:");
|
||||||
|
const users = getAllUsersForAssignment();
|
||||||
|
console.log(JSON.stringify(users, null, 2));
|
||||||
|
|
||||||
|
console.log("\n2. Current projects with user information:");
|
||||||
|
const projects = getAllProjects();
|
||||||
|
console.log("Total projects:", projects.length);
|
||||||
|
|
||||||
|
if (projects.length > 0) {
|
||||||
|
console.log("\nFirst project details:");
|
||||||
|
console.log(JSON.stringify(projects[0], null, 2));
|
||||||
|
} else {
|
||||||
|
console.log("No projects found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n✅ User tracking implementation test completed!");
|
||||||
101
verify-audit-fix.mjs
Normal file
101
verify-audit-fix.mjs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
logAuditEvent,
|
||||||
|
getAuditLogs,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "./src/lib/auditLog.js";
|
||||||
|
|
||||||
|
console.log("=== FINAL AUDIT LOGGING VERIFICATION ===\n");
|
||||||
|
|
||||||
|
async function verifyAuditLogging() {
|
||||||
|
try {
|
||||||
|
// 1. Check recent audit logs
|
||||||
|
console.log("1. Checking recent audit logs for user ID issues...");
|
||||||
|
const recentLogs = await getAuditLogs({ limit: 10 });
|
||||||
|
|
||||||
|
console.log(`Found ${recentLogs.length} recent audit events:`);
|
||||||
|
recentLogs.forEach((log, index) => {
|
||||||
|
const userDisplay = log.user_id ? `user ${log.user_id}` : "NULL USER ID";
|
||||||
|
console.log(
|
||||||
|
`${index + 1}. ${log.timestamp} - ${log.action} by ${userDisplay} on ${
|
||||||
|
log.resource_type
|
||||||
|
}:${log.resource_id || "N/A"}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Count null user IDs
|
||||||
|
const allLogs = await getAuditLogs();
|
||||||
|
const nullUserCount = allLogs.filter((log) => log.user_id === null).length;
|
||||||
|
const totalCount = allLogs.length;
|
||||||
|
const nullPercentage = ((nullUserCount / totalCount) * 100).toFixed(2);
|
||||||
|
|
||||||
|
console.log(`\n2. Audit Log Statistics:`);
|
||||||
|
console.log(` Total audit logs: ${totalCount}`);
|
||||||
|
console.log(` Logs with NULL user_id: ${nullUserCount}`);
|
||||||
|
console.log(` Percentage with NULL user_id: ${nullPercentage}%`);
|
||||||
|
|
||||||
|
// 3. Check distribution by action type
|
||||||
|
console.log(`\n3. Action distribution for NULL user_id logs:`);
|
||||||
|
const nullUserLogs = allLogs.filter((log) => log.user_id === null);
|
||||||
|
const actionCounts = {};
|
||||||
|
nullUserLogs.forEach((log) => {
|
||||||
|
actionCounts[log.action] = (actionCounts[log.action] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(actionCounts).forEach(([action, count]) => {
|
||||||
|
console.log(` ${action}: ${count} events`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Test new audit event with valid user ID
|
||||||
|
console.log(`\n4. Testing new audit event with valid user ID...`);
|
||||||
|
await logAuditEvent({
|
||||||
|
action: AUDIT_ACTIONS.LOGIN,
|
||||||
|
userId: "test-user-123",
|
||||||
|
resourceType: RESOURCE_TYPES.SESSION,
|
||||||
|
ipAddress: "127.0.0.1",
|
||||||
|
userAgent: "Test Agent",
|
||||||
|
details: {
|
||||||
|
test: "verification",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify the new event was logged correctly
|
||||||
|
const verificationLogs = await getAuditLogs({ limit: 1 });
|
||||||
|
const latestLog = verificationLogs[0];
|
||||||
|
|
||||||
|
if (latestLog && latestLog.user_id === "test-user-123") {
|
||||||
|
console.log("✅ SUCCESS: New audit event logged with correct user ID");
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
"❌ FAILED: New audit event has incorrect user ID:",
|
||||||
|
latestLog?.user_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Summary
|
||||||
|
console.log(`\n5. SUMMARY:`);
|
||||||
|
if (nullPercentage < 10) {
|
||||||
|
console.log("✅ EXCELLENT: Very few NULL user IDs detected");
|
||||||
|
} else if (nullPercentage < 30) {
|
||||||
|
console.log("⚠️ GOOD: Some NULL user IDs but manageable");
|
||||||
|
} else {
|
||||||
|
console.log("❌ NEEDS ATTENTION: High percentage of NULL user IDs");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n6. RECOMMENDATIONS:`);
|
||||||
|
if (nullUserCount > 0) {
|
||||||
|
console.log(
|
||||||
|
" - The NULL user IDs are likely from before the fix was applied"
|
||||||
|
);
|
||||||
|
console.log(" - New audit events should now log user IDs correctly");
|
||||||
|
console.log(" - Monitor future logs to ensure the fix is working");
|
||||||
|
} else {
|
||||||
|
console.log(" - All audit events have valid user IDs!");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Verification failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyAuditLogging();
|
||||||
7
verify-project.mjs
Normal file
7
verify-project.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { getProjectById } from "./src/lib/queries/projects.js";
|
||||||
|
|
||||||
|
console.log("Checking the created project with user tracking...\n");
|
||||||
|
|
||||||
|
const project = getProjectById(17);
|
||||||
|
console.log("Project details:");
|
||||||
|
console.log(JSON.stringify(project, null, 2));
|
||||||
Reference in New Issue
Block a user