Merge branch 'auth2' into main

This commit is contained in:
Chop
2025-07-10 22:35:28 +02:00
98 changed files with 9522 additions and 636 deletions

View 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
View 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
View 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! 🎊

View 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.

View File

@@ -100,18 +100,27 @@ The application uses SQLite database which will be automatically initialized on
```
src/
├── app/ # Next.js app router pages
│ ├── admin/ # Admin dashboard and user management
│ ├── api/ # API routes
│ │ ├── admin/ # Admin-related endpoints (e.g., user management)
│ │ ├── all-project-tasks/ # Get all project tasks endpoint
│ │ ├── audit-logs/ # Audit log endpoints
│ │ ├── auth/ # Authentication endpoints
│ │ ├── contracts/ # Contract management endpoints
│ │ ├── notes/ # Notes management endpoints
│ │ ├── projects/ # Project management endpoints
│ │ ├── project-tasks/ # Task management endpoints
│ │ ├── task-notes/ # Task-specific notes endpoints
│ │ └── tasks/ # Task template endpoints
│ ├── auth/ # Authentication pages (login, etc.)
│ ├── contracts/ # Contract pages
│ ├── projects/ # Project pages
│ ├── project-tasks/ # Project-specific task pages
│ └── tasks/ # Task management pages
├── components/ # Reusable React components
│ ├── auth/ # Authentication-related components
│ ├── ui/ # UI components (Button, Card, etc.)
│ ├── AuditLogViewer.js # Component to view audit logs
│ ├── ContractForm.js # Contract form component
│ ├── NoteForm.js # Note form component
│ ├── ProjectForm.js # Project form component
@@ -119,10 +128,14 @@ src/
│ ├── ProjectTasksSection.js # Project tasks section component
│ ├── TaskForm.js # Task form component
│ └── TaskTemplateForm.js # Task template form component
── lib/ # Utility functions
├── queries/ # Database query functions
├── db.js # Database connection
── init-db.js # Database initialization
── lib/ # Utility functions
├── queries/ # Database query functions
├── auditLog.js # Audit logging utilities
── 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
@@ -147,6 +160,9 @@ The application uses the following main tables:
- **tasks** - Task templates
- **project_tasks** - Tasks assigned to specific projects
- **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
@@ -188,6 +204,19 @@ The application uses the following main tables:
- `POST /api/notes` - Create new 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
This project includes a powerful map system for project locations, supporting multiple dynamic base layers:

56
check-audit-db.mjs Normal file
View 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
View 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
View 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
View 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
View 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();

View File

@@ -1,7 +1,15 @@
"use client";
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() {
const [selectedLocation, setSelectedLocation] = useState('krakow');

View File

@@ -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';

View File

@@ -1,6 +1,16 @@
"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() {
// Test marker in Poland

View File

@@ -1,6 +1,14 @@
"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() {
const testMarkers = [

View File

@@ -1,8 +1,23 @@
"use client";
import { useState } from 'react';
import PolishOrthophotoMap from '../../components/ui/PolishOrthophotoMap';
import AdvancedPolishOrthophotoMap from '../../components/ui/AdvancedPolishOrthophotoMap';
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>
}
);
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() {
const [activeMap, setActiveMap] = useState('basic');

View File

@@ -1,6 +1,14 @@
"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() {
// Test markers - various locations in Poland

49
debug-task-insert.mjs Normal file
View 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
View 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
View 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
View File

@@ -8,16 +8,20 @@
"name": "panel",
"version": "0.1.0",
"dependencies": {
"bcryptjs": "^3.0.2",
"better-sqlite3": "^11.10.0",
"date-fns": "^4.1.0",
"leaflet": "^1.9.4",
"next": "15.1.8",
"next-auth": "^5.0.0-beta.29",
"node-fetch": "^3.3.2",
"proj4": "^2.19.3",
"proj4leaflet": "^1.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"recharts": "^2.15.3"
"recharts": "^2.15.3",
"zod": "^3.25.67"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -68,6 +72,35 @@
"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": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -1912,6 +1945,15 @@
"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": {
"version": "3.9.2",
"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": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
@@ -4114,6 +4164,14 @@
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"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": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
@@ -5262,6 +5320,28 @@
"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": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -5374,6 +5454,17 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -7176,6 +7267,15 @@
"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": {
"version": "4.0.0",
"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": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@@ -7758,6 +7885,42 @@
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -7801,6 +7964,15 @@
"dev": true,
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -8404,6 +8576,25 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"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": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -10382,6 +10573,14 @@
"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": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
@@ -10826,6 +11025,14 @@
"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": {
"version": "0.2.0-alpha.3",
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",
@@ -10857,6 +11064,18 @@
"@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": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -12031,6 +12250,11 @@
"integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==",
"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": {
"version": "3.9.2",
"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",
"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": {
"version": "11.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
@@ -13577,6 +13806,11 @@
"integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
"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": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
@@ -14419,6 +14653,15 @@
"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": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -14500,6 +14743,14 @@
"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": {
"version": "1.0.0",
"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==",
"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": {
"version": "4.0.0",
"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": {
"version": "3.75.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
@@ -16136,6 +16400,21 @@
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -16169,6 +16448,11 @@
"integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==",
"dev": true
},
"oauth4webapi": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.3.tgz",
"integrity": "sha512-2bnHosmBLAQpXNBLOvaJMyMkr4Yya5ohE5Q9jqyxiN+aa7GFCzvDN1RRRMrp0NkfqRR2MTaQNkcSUCCjILD9oQ=="
},
"object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -16559,6 +16843,17 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"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": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
@@ -17931,6 +18226,11 @@
"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": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz",
@@ -18240,6 +18540,11 @@
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
},
"zod": {
"version": "3.25.67",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
"integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="
},
"zstddec": {
"version": "0.2.0-alpha.3",
"resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0-alpha.3.tgz",

View File

@@ -2,6 +2,7 @@
"name": "panel",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -14,16 +15,20 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"bcryptjs": "^3.0.2",
"better-sqlite3": "^11.10.0",
"date-fns": "^4.1.0",
"leaflet": "^1.9.4",
"next": "15.1.8",
"next-auth": "^5.0.0-beta.29",
"node-fetch": "^3.3.2",
"proj4": "^2.19.3",
"proj4leaflet": "^1.0.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"recharts": "^2.15.3"
"recharts": "^2.15.3",
"zod": "^3.25.67"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

142
public/test-auth.html Normal file
View 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
View 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()

View 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&apos;t have permission to view this page.
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-100">
<AuditLogViewer />
</div>
);
}

View 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
View 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>
);
}

View 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);

View 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);

View File

@@ -1,8 +1,9 @@
import { getAllProjectTasks } from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import { withReadAuth } from "@/lib/middleware/auth";
// GET: Get all project tasks across all projects
export async function GET() {
async function getAllProjectTasksHandler() {
try {
const tasks = getAllProjectTasks();
return NextResponse.json(tasks);
@@ -13,3 +14,6 @@ export async function GET() {
);
}
}
// Protected routes - require authentication
export const GET = withReadAuth(getAllProjectTasksHandler);

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers

View File

@@ -1,7 +1,8 @@
import db from "@/lib/db";
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 contract = db
@@ -20,7 +21,7 @@ export async function GET(req, { params }) {
return NextResponse.json(contract);
}
export async function DELETE(req, { params }) {
async function deleteContractHandler(req, { params }) {
const { id } = params;
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);

View File

@@ -1,7 +1,8 @@
import db from "@/lib/db";
import { NextResponse } from "next/server";
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
export async function GET() {
async function getContractsHandler() {
const contracts = db
.prepare(
`
@@ -21,7 +22,7 @@ export async function GET() {
return NextResponse.json(contracts);
}
export async function POST(req) {
async function createContractHandler(req) {
const data = await req.json();
db.prepare(
`
@@ -46,3 +47,7 @@ export async function POST(req) {
);
return NextResponse.json({ success: true });
}
// Protected routes - require authentication
export const GET = withReadAuth(getContractsHandler);
export const POST = withUserAuth(createContractHandler);

View 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 })
}
})

View File

@@ -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 { 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();
if (!note || (!project_id && !task_id)) {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
db.prepare(
try {
const result = db
.prepare(
`
INSERT INTO notes (project_id, task_id, note)
VALUES (?, ?, ?)
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
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 });
} 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;
// 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);
// 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 });
}
export async function PUT(req, { params }) {
async function updateNoteHandler(req, { params }) {
const noteId = params.id;
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 });
}
// Get original note for audit log
const originalNote = db
.prepare("SELECT * FROM notes WHERE note_id = ?")
.get(noteId);
db.prepare(
`
UPDATE notes SET note = ? WHERE note_id = ?
`
).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 });
}
// Protected routes - require authentication
export const POST = withUserAuth(createNoteHandler);
export const DELETE = withUserAuth(deleteNoteHandler);
export const PUT = withUserAuth(updateNoteHandler);

View File

@@ -3,9 +3,10 @@ import {
deleteProjectTask,
} from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import { withUserAuth } from "@/lib/middleware/auth";
// PATCH: Update project task status
export async function PATCH(req, { params }) {
async function updateProjectTaskHandler(req, { params }) {
try {
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 });
} catch (error) {
console.error("Error updating task status:", error);
return NextResponse.json(
{ error: "Failed to update project task" },
{ error: "Failed to update project task", details: error.message },
{ status: 500 }
);
}
}
// DELETE: Delete a project task
export async function DELETE(req, { params }) {
async function deleteProjectTaskHandler(req, { params }) {
try {
deleteProjectTask(params.id);
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);

View File

@@ -5,9 +5,10 @@ import {
} from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import db from "@/lib/db";
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
// 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 projectId = searchParams.get("project_id");
@@ -23,7 +24,7 @@ export async function GET(req) {
}
// POST: Create a new project task
export async function POST(req) {
async function createProjectTaskHandler(req) {
try {
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 });
} catch (error) {
console.error("Error creating project task:", error);
return NextResponse.json(
{ error: "Failed to create project task" },
{ error: "Failed to create project task", details: error.message },
{ 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);

View 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);

View File

@@ -1,22 +1,103 @@
// Force this API route to use Node.js runtime for database access
export const runtime = "nodejs";
import {
getProjectById,
updateProject,
deleteProject,
} from "@/lib/queries/projects";
import initializeDatabase from "@/lib/init-db";
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);
}
export async function PUT(req, { params }) {
async function updateProjectHandler(req, { params }) {
const { id } = await params;
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 });
}
export async function DELETE(_, { params }) {
deleteProject(params.id);
return NextResponse.json({ success: true });
}
// Protected routes - require authentication
export const GET = withReadAuth(getProjectHandler);
export const PUT = withUserAuth(updateProjectHandler);
export const DELETE = withUserAuth(deleteProjectHandler);

View File

@@ -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 { 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();
export async function GET(req) {
async function getProjectsHandler(req) {
const { searchParams } = new URL(req.url);
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);
}
export async function POST(req) {
async function createProjectHandler(req) {
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);

View 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);

View File

@@ -4,9 +4,10 @@ import {
deleteNote,
} from "@/lib/queries/notes";
import { NextResponse } from "next/server";
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
// GET: Get notes for a specific task
export async function GET(req) {
async function getTaskNotesHandler(req) {
const { searchParams } = new URL(req.url);
const taskId = searchParams.get("task_id");
@@ -26,7 +27,7 @@ export async function GET(req) {
}
// POST: Add a note to a task
export async function POST(req) {
async function addTaskNoteHandler(req) {
try {
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 });
} catch (error) {
console.error("Error adding task note:", error);
@@ -49,7 +50,7 @@ export async function POST(req) {
}
// DELETE: Delete a note
export async function DELETE(req) {
async function deleteTaskNoteHandler(req) {
try {
const { searchParams } = new URL(req.url);
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);

View File

@@ -1,8 +1,9 @@
import db from "@/lib/db";
import { NextResponse } from "next/server";
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
// GET: Get a specific task template
export async function GET(req, { params }) {
async function getTaskHandler(req, { params }) {
try {
const template = db
.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
export async function PUT(req, { params }) {
async function updateTaskHandler(req, { params }) {
try {
const { name, max_wait_days, description } = await req.json();
@@ -58,7 +59,7 @@ export async function PUT(req, { params }) {
}
// DELETE: Delete a task template
export async function DELETE(req, { params }) {
async function deleteTaskHandler(req, { params }) {
try {
const result = db
.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);

View File

@@ -1,8 +1,10 @@
import db from "@/lib/db";
import { NextResponse } from "next/server";
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
import { getAllTaskTemplates } from "@/lib/queries/tasks";
// POST: create new template
export async function POST(req) {
async function createTaskHandler(req) {
const { name, max_wait_days, description } = await req.json();
if (!name) {
@@ -18,3 +20,13 @@ export async function POST(req) {
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);

View File

@@ -1,8 +1,12 @@
import { getAllTaskTemplates } from "@/lib/queries/tasks";
import { NextResponse } from "next/server";
import { withReadAuth } from "@/lib/middleware/auth";
// GET: Get all task templates
export async function GET() {
async function getTaskTemplatesHandler() {
const templates = getAllTaskTemplates();
return NextResponse.json(templates);
}
// Protected routes - require authentication
export const GET = withReadAuth(getTaskTemplatesHandler);

View 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
View 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>
)
}

View File

@@ -1,6 +1,7 @@
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import Navigation from "@/components/ui/Navigation";
import { AuthProvider } from "@/components/auth/AuthProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -23,8 +24,10 @@ export default function RootLayout({ children }) {
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<AuthProvider>
<Navigation />
<main>{children}</main>
</AuthProvider>
</body>
</html>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
@@ -24,6 +25,7 @@ import { formatDate } from "@/lib/utils";
import TaskStatusChart from "@/components/ui/TaskStatusChart";
export default function Home() {
const { data: session, status } = useSession();
const [stats, setStats] = useState({
totalProjects: 0,
activeProjects: 0,
@@ -47,6 +49,12 @@ export default function Home() {
const [loading, setLoading] = useState(true);
useEffect(() => {
// Only fetch data if user is authenticated
if (!session) {
setLoading(false);
return;
}
const fetchDashboardData = async () => {
try {
// Fetch all data concurrently
@@ -210,7 +218,7 @@ export default function Home() {
};
fetchDashboardData();
}, []);
}, [session]);
const getProjectStatusColor = (status) => {
switch (status) {
@@ -257,10 +265,38 @@ export default function Home() {
</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 (
<PageContainer>
<PageHeader
title="Dashboard"
title={`Welcome back, ${session.user.name}!`}
description="Overview of your projects, contracts, and tasks"
>
<div className="flex items-center gap-3">

View File

@@ -1,17 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import ProjectForm from "@/components/ProjectForm";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import Button from "@/components/ui/Button";
import Link from "next/link";
import { LoadingState } from "@/components/ui/States";
export default async function EditProjectPage({ params }) {
const { id } = await params;
const res = await fetch(`http://localhost:3000/api/projects/${id}`, {
cache: "no-store",
});
const project = await res.json();
export default function EditProjectPage() {
const params = useParams();
const id = params.id;
const [project, setProject] = useState(null);
const [loading, setLoading] = useState(true);
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 (
<PageContainer>
<div className="text-center py-12">

View File

@@ -13,12 +13,12 @@ import { formatDate } from "@/lib/utils";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
import ProjectMap from "@/components/ui/ProjectMap";
import ClientProjectMap from "@/components/ui/ClientProjectMap";
export default async function ProjectViewPage({ params }) {
const { id } = await params;
const project = getProjectWithContract(id);
const notes = getNotesForProject(id);
const project = await getProjectWithContract(id);
const notes = await getNotesForProject(id);
if (!project) {
return (
@@ -400,12 +400,20 @@ export default async function ProjectViewPage({ params }) {
<div className="mb-8">
{" "}
<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">
Project Location
</h2>
{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">
<svg
className="w-4 h-4 mr-2"
@@ -427,7 +435,7 @@ export default async function ProjectViewPage({ params }) {
</div>
</CardHeader>
<CardContent>
<ProjectMap
<ClientProjectMap
coordinates={project.coordinates}
projectName={project.project_name}
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"
>
<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">
{n.note_date}
</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>
<p className="text-gray-900 leading-relaxed">{n.note}</p>
</div>

View 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>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, Suspense } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
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 router = useRouter();
const [projects, setProjects] = useState([]);
@@ -541,7 +541,7 @@ export default function ProjectsMapPage() {
{/* 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">
<div className="flex gap-2 justify-end">
<Link href="/projects">
<Button
variant="outline"
@@ -926,3 +926,18 @@ export default function ProjectsMapPage() {
</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>
);
}

View File

@@ -195,7 +195,13 @@ export default function ProjectListPage() {
</th>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24">
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">
Actions
</th>
@@ -275,6 +281,18 @@ export default function ProjectListPage() {
? "Zakończony"
: "-"}
</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">
<Link href={`/projects/${project.project_id}`}>
<Button

View 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>
);
}

View File

@@ -22,22 +22,59 @@ export default function ProjectForm({ initialData = null }) {
contact: "",
notes: "",
coordinates: "",
project_type: initialData?.project_type || "design",
// project_status is not included in the form for creation or editing
...initialData,
project_type: "design",
assigned_to: "",
});
const [contracts, setContracts] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
const router = useRouter();
const isEdit = !!initialData;
useEffect(() => {
// Fetch contracts
fetch("/api/contracts")
.then((res) => res.json())
.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) {
setForm({ ...form, [e.target.name]: e.target.value });
}
@@ -83,7 +120,7 @@ export default function ProjectForm({ initialData = null }) {
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{/* 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>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contract <span className="text-red-500">*</span>
@@ -125,6 +162,25 @@ export default function ProjectForm({ initialData = null }) {
</option>
</select>
</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>
{/* Basic Information Section */}

View File

@@ -6,12 +6,14 @@ import Badge from "./ui/Badge";
export default function ProjectTaskForm({ projectId, onTaskAdded }) {
const [taskTemplates, setTaskTemplates] = useState([]);
const [users, setUsers] = useState([]);
const [taskType, setTaskType] = useState("template"); // "template" or "custom"
const [selectedTemplate, setSelectedTemplate] = useState("");
const [customTaskName, setCustomTaskName] = useState("");
const [customMaxWaitDays, setCustomMaxWaitDays] = useState("");
const [customDescription, setCustomDescription] = useState("");
const [priority, setPriority] = useState("normal");
const [assignedTo, setAssignedTo] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
@@ -19,6 +21,11 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
fetch("/api/tasks/templates")
.then((res) => res.json())
.then(setTaskTemplates);
// Fetch users for assignment
fetch("/api/project-tasks/users")
.then((res) => res.json())
.then(setUsers);
}, []);
async function handleSubmit(e) {
@@ -34,6 +41,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
const requestData = {
project_id: parseInt(projectId),
priority,
assigned_to: assignedTo || null,
};
if (taskType === "template") {
@@ -56,6 +64,7 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
setCustomMaxWaitDays("");
setCustomDescription("");
setPriority("normal");
setAssignedTo("");
if (onTaskAdded) onTaskAdded();
} else {
alert("Failed to add task to project.");
@@ -158,6 +167,24 @@ export default function ProjectTaskForm({ projectId, onTaskAdded }) {
</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>
<label className="block text-sm font-medium text-gray-700 mb-2">
Priority

View File

@@ -273,6 +273,28 @@ export default function ProjectTasksList() {
<td className="px-4 py-3 text-sm text-gray-600">
{task.address || "N/A"}
</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 && (
<td className="px-4 py-3">
<div className="flex items-center gap-2">
@@ -361,7 +383,7 @@ export default function ProjectTasksList() {
const TaskTable = ({ tasks, showGrouped = false, showTimeLeft = false }) => {
const filteredTasks = filterTasks(tasks);
const groupedTasks = groupTasksByName(filteredTasks);
const colSpan = showTimeLeft ? "8" : "7";
const colSpan = showTimeLeft ? "10" : "9";
return (
<div className="overflow-x-auto">
@@ -379,7 +401,13 @@ export default function ProjectTasksList() {
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
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 && (
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700">
Time Left

View File

@@ -517,6 +517,11 @@ export default function ProjectTasksSection({ projectId }) {
System
</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>
<p className="text-sm text-gray-800">
{note.note}
@@ -525,6 +530,11 @@ export default function ProjectTasksSection({ projectId }) {
{formatDate(note.note_date, {
includeTime: true,
})}
{note.created_by_name && (
<span className="ml-2">
by {note.created_by_name}
</span>
)}
</p>
</div>
{!note.is_system && (

View File

@@ -0,0 +1,11 @@
"use client"
import { SessionProvider } from "next-auth/react"
export function AuthProvider({ children }) {
return (
<SessionProvider>
{children}
</SessionProvider>
)
}

View 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} />;
}

View File

@@ -56,8 +56,6 @@ function WMSLayer({ url, params, opacity = 1, attribution }) {
// Fix for default markers in react-leaflet
const fixLeafletIcons = () => {
if (typeof window !== "undefined") {
const L = require("leaflet");
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "/leaflet/marker-icon-2x.png",
@@ -70,8 +68,6 @@ const fixLeafletIcons = () => {
// Create colored marker icons
const createColoredMarkerIcon = (color) => {
if (typeof window !== "undefined") {
const L = require("leaflet");
return new L.Icon({
iconUrl: `data:image/svg+xml;base64,${btoa(`
<svg width="25" height="41" viewBox="0 0 25 41" xmlns="http://www.w3.org/2000/svg">

View File

@@ -2,9 +2,12 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession, signOut } from "next-auth/react";
const Navigation = () => {
const pathname = usePathname();
const { data: session, status } = useSession();
const isActive = (path) => {
if (path === "/") return pathname === "/";
// Exact match for paths
@@ -13,6 +16,7 @@ const Navigation = () => {
if (pathname.startsWith(path + "/")) return true;
return false;
};
const navItems = [
{ href: "/", label: "Dashboard" },
{ href: "/projects", label: "Projects" },
@@ -21,6 +25,20 @@ const Navigation = () => {
{ 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 (
<nav className="bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6">
@@ -31,6 +49,11 @@ const Navigation = () => {
</Link>
</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">
{navItems.map((item) => (
<Link
@@ -46,6 +69,32 @@ const Navigation = () => {
</Link>
))}
</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>
</nav>

424
src/lib/auditLog.js Normal file
View 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
View 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
View 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
View 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",
});

View File

@@ -162,4 +162,156 @@ export default function initializeDatabase() {
} catch (e) {
// 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);
`);
}

View 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;

View 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' })
}

View File

@@ -2,29 +2,100 @@ import db from "../db.js";
export function getNotesByProjectId(project_id) {
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);
}
export function addNoteToProject(project_id, note) {
db.prepare(`INSERT INTO notes (project_id, note) VALUES (?, ?)`).run(
project_id,
note
);
export function addNoteToProject(project_id, note, created_by = null) {
db.prepare(
`
INSERT INTO notes (project_id, note, created_by, note_date)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
`
).run(project_id, note, created_by);
}
export function getNotesByTaskId(task_id) {
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);
}
export function addNoteToTask(task_id, note, is_system = false) {
export function addNoteToTask(
task_id,
note,
is_system = false,
created_by = null
) {
db.prepare(
`INSERT INTO notes (task_id, note, is_system) VALUES (?, ?, ?)`
).run(task_id, note, is_system ? 1 : 0);
`INSERT INTO notes (task_id, note, is_system, created_by, note_date)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`
).run(task_id, note, is_system ? 1 : 0, created_by);
}
export function deleteNote(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);
}

View File

@@ -1,21 +1,48 @@
import db from "../db.js";
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) {
return db
.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);
}
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) {
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
const contractInfo = db
.prepare(
@@ -37,12 +64,16 @@ export function createProject(data) {
// 2. Generate sequential number and project number
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 (
contract_id, project_name, project_number, address, plot, district, unit, city, investment_number, finish_date,
wp, contact, notes, project_type, project_status, coordinates
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);stmt.run(
wp, contact, notes, project_type, project_status, coordinates, created_by, assigned_to, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
const result = stmt.run(
data.contract_id,
data.project_name,
projectNumber,
@@ -55,16 +86,23 @@ export function createProject(data) {
data.finish_date,
data.wp,
data.contact,
data.notes, data.project_type || "design",
data.notes,
data.project_type || "design",
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
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 = ?
`);
stmt.run(
@@ -80,9 +118,11 @@ export function updateProject(id, data) { const stmt = db.prepare(`
data.finish_date,
data.wp,
data.contact,
data.notes, data.project_type || "design",
data.notes,
data.project_type || "design",
data.project_status || "registered",
data.coordinates || null,
data.assigned_to || null,
id
);
}
@@ -91,6 +131,75 @@ export function deleteProject(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) {
return db
.prepare(
@@ -113,9 +222,13 @@ export function getNotesForProject(projectId) {
return db
.prepare(
`
SELECT * FROM notes
WHERE project_id = ?
ORDER BY note_date DESC
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(projectId);

View File

@@ -27,10 +27,16 @@ export function getAllProjectTasks() {
p.plot,
p.city,
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
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
ORDER BY pt.date_added DESC
`
)
@@ -50,9 +56,15 @@ export function getProjectTasks(projectId) {
CASE
WHEN pt.task_template_id IS NOT NULL THEN 'template'
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
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 = ?
ORDER BY pt.date_added DESC
`
@@ -68,14 +80,19 @@ export function createProjectTask(data) {
if (data.task_template_id) {
// Creating from template - explicitly set custom_max_wait_days to NULL so COALESCE uses template value
const stmt = db.prepare(`
INSERT INTO project_tasks (project_id, task_template_id, custom_max_wait_days, status, priority)
VALUES (?, ?, NULL, ?, ?)
INSERT INTO project_tasks (
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(
data.project_id,
data.task_template_id,
data.status || "pending",
data.priority || "normal"
data.priority || "normal",
data.created_by || null,
data.assigned_to || null
);
// Get the template name for the log
@@ -85,8 +102,11 @@ export function createProjectTask(data) {
} else {
// Creating custom task
const stmt = db.prepare(`
INSERT INTO project_tasks (project_id, custom_task_name, custom_max_wait_days, custom_description, status, priority)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO project_tasks (
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(
data.project_id,
@@ -94,7 +114,9 @@ export function createProjectTask(data) {
data.custom_max_wait_days || 0,
data.custom_description || "",
data.status || "pending",
data.priority || "normal"
data.priority || "normal",
data.created_by || null,
data.assigned_to || null
);
taskName = data.custom_task_name;
@@ -105,14 +127,14 @@ export function createProjectTask(data) {
const priority = data.priority || "normal";
const status = data.status || "pending";
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;
}
// Update project task status
export function updateProjectTaskStatus(taskId, status) {
export function updateProjectTaskStatus(taskId, status, userId = null) {
// First get the current task details for logging
const getCurrentTask = db.prepare(`
SELECT
@@ -136,7 +158,7 @@ export function updateProjectTaskStatus(taskId, status) {
// Starting a task - set date_started
stmt = db.prepare(`
UPDATE project_tasks
SET status = ?, date_started = CURRENT_TIMESTAMP
SET status = ?, date_started = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
result = stmt.run(status, taskId);
@@ -144,7 +166,7 @@ export function updateProjectTaskStatus(taskId, status) {
// Completing a task - set date_completed
stmt = db.prepare(`
UPDATE project_tasks
SET status = ?, date_completed = CURRENT_TIMESTAMP
SET status = ?, date_completed = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
result = stmt.run(status, taskId);
@@ -152,7 +174,7 @@ export function updateProjectTaskStatus(taskId, status) {
// Just updating status without changing timestamps
stmt = db.prepare(`
UPDATE project_tasks
SET status = ?
SET status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
result = stmt.run(status, taskId);
@@ -162,7 +184,7 @@ export function updateProjectTaskStatus(taskId, status) {
if (result.changes > 0 && oldStatus !== status) {
const taskName = currentTask.task_name || "Unknown task";
const logMessage = `Status changed from "${oldStatus}" to "${status}"`;
addNoteToTask(taskId, logMessage, true);
addNoteToTask(taskId, logMessage, true, userId);
}
return result;
@@ -173,3 +195,99 @@ export function deleteProjectTask(taskId) {
const stmt = db.prepare("DELETE FROM project_tasks WHERE id = ?");
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();

View 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
View 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
View 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
View 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
View 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();

View 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
View 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
View 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
View 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
View 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));