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,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, created_by, note_date)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
`
INSERT INTO notes (project_id, task_id, note)
VALUES (?, ?, ?)
`
).run(project_id || null, task_id || null, note);
)
.run(project_id || null, task_id || null, note, req.user?.id || null);
return NextResponse.json({ success: true });
// 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,347 +0,0 @@
"use client";
import { useState } from 'react';
import ComprehensivePolishMap from '../../components/ui/ComprehensivePolishMap';
export default function ComprehensivePolishMapPage() {
const [selectedLocation, setSelectedLocation] = useState('krakow');
// Different locations to test the layers
const locations = {
krakow: {
center: [50.0647, 19.9450],
zoom: 14,
name: "Kraków",
description: "Historic city center with good cadastral data coverage"
},
warsaw: {
center: [52.2297, 21.0122],
zoom: 14,
name: "Warszawa",
description: "Capital city with extensive planning data"
},
gdansk: {
center: [54.3520, 18.6466],
zoom: 14,
name: "Gdańsk",
description: "Port city with detailed property boundaries"
},
wroclaw: {
center: [51.1079, 17.0385],
zoom: 14,
name: "Wrocław",
description: "University city with good orthophoto coverage"
},
poznan: {
center: [52.4064, 16.9252],
zoom: 14,
name: "Poznań",
description: "Industrial center with road network data"
}
};
const currentLocation = locations[selectedLocation];
// Test markers for selected location
const testMarkers = [
{
position: currentLocation.center,
popup: `${currentLocation.name} - ${currentLocation.description}`
}
];
return (
<div className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold text-gray-800 mb-6">
🇵🇱 Comprehensive Polish Geospatial Data Platform
</h1>
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
<h2 className="text-xl font-semibold text-green-800 mb-3">
All Polish Layers Implementation Complete! 🎉
</h2>
<p className="text-green-700 mb-4">
This comprehensive map includes all layers from your OpenLayers implementation,
converted to work seamlessly with your Leaflet-based React/Next.js project.
</p>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<strong className="text-green-800">Base Layers:</strong>
<ul className="mt-1 text-green-700">
<li> Polish Orthophoto (Standard & High Resolution)</li>
<li> OpenStreetMap, Google Maps, Esri Satellite</li>
</ul>
</div>
<div>
<strong className="text-green-800">Overlay Layers:</strong>
<ul className="mt-1 text-green-700">
<li> Cadastral Data, Spatial Planning</li>
<li> LP-Portal Roads, Street Names, Parcels, Surveys</li>
</ul>
</div>
</div>
</div>
{/* Location Selector */}
<div className="bg-white rounded-lg shadow-lg p-4 mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-3">
🎯 Select Test Location:
</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{Object.entries(locations).map(([key, location]) => (
<button
key={key}
onClick={() => setSelectedLocation(key)}
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
selectedLocation === key
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{location.name}
</button>
))}
</div>
<p className="text-sm text-gray-600 mt-2">
<strong>Current:</strong> {currentLocation.description}
</p>
</div>
{/* Map Container */}
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="p-4 bg-blue-600 text-white">
<h2 className="text-xl font-semibold">
Interactive Map: {currentLocation.name}
</h2>
<p className="text-blue-100 mt-2">
Use the layer control (top-right) to toggle between base layers and enable overlay layers.
Combine orthophoto with cadastral data for detailed property analysis.
</p>
</div>
<div className="h-96 md:h-[700px]">
<ComprehensivePolishMap
key={selectedLocation} // Force re-render when location changes
center={currentLocation.center}
zoom={currentLocation.zoom}
markers={testMarkers}
showLayerControl={true}
/>
</div>
</div>
{/* Layer Information */}
<div className="mt-8 grid md:grid-cols-2 gap-6">
{/* Base Layers */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center">
🗺 Base Layers
</h3>
<div className="space-y-3 text-sm">
<div className="flex items-start">
<span className="w-4 h-4 bg-green-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>Polish Orthophoto (Standard)</strong>
<p className="text-gray-600 mt-1">High-quality aerial imagery from Polish Geoportal</p>
</div>
</div>
<div className="flex items-start">
<span className="w-4 h-4 bg-emerald-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>Polish Orthophoto (High Resolution)</strong>
<p className="text-gray-600 mt-1">Ultra-high resolution aerial imagery for detailed analysis</p>
</div>
</div>
<div className="flex items-start">
<span className="w-4 h-4 bg-blue-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>OpenStreetMap</strong>
<p className="text-gray-600 mt-1">Community-driven map data</p>
</div>
</div>
<div className="flex items-start">
<span className="w-4 h-4 bg-red-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>Google Maps</strong>
<p className="text-gray-600 mt-1">Satellite imagery and road overlay</p>
</div>
</div>
</div>
</div>
{/* Overlay Layers */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center">
📊 Overlay Layers
</h3> <div className="space-y-3 text-sm">
<div className="flex items-start">
<span className="w-4 h-4 bg-orange-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>📋 Polish Cadastral Data</strong>
<p className="text-gray-600 mt-1">Property boundaries, parcels, and building outlines</p>
<p className="text-xs text-gray-500">Opacity: 80% - Semi-transparent overlay</p>
</div>
</div>
<div className="flex items-start">
<span className="w-4 h-4 bg-purple-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>🏗 Polish Spatial Planning</strong>
<p className="text-gray-600 mt-1">Zoning data and urban planning information</p>
<p className="text-xs text-gray-500">Opacity: 70% - Semi-transparent overlay</p>
</div>
</div>
<div className="flex items-start">
<span className="w-4 h-4 bg-teal-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>🛣 LP-Portal Roads</strong>
<p className="text-gray-600 mt-1">Detailed road network data</p>
<p className="text-xs text-gray-500">Opacity: 90% - Mostly opaque for visibility</p>
</div>
</div>
<div className="flex items-start">
<span className="w-4 h-4 bg-indigo-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>🏷 LP-Portal Street Names</strong>
<p className="text-gray-600 mt-1">Street names and road descriptions</p>
<p className="text-xs text-gray-500">Opacity: 100% - Fully opaque for readability</p>
</div>
</div>
<div className="flex items-start">
<span className="w-4 h-4 bg-pink-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
<div>
<strong>📐 LP-Portal Parcels & Surveys</strong>
<p className="text-gray-600 mt-1">Property parcels and survey markers</p>
<p className="text-xs text-gray-500">Opacity: 60-80% - Variable transparency</p>
</div>
</div>
</div>
</div>
</div>
{/* Transparency Information */}
<div className="mt-8 bg-green-50 border border-green-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-green-800 mb-4">
🎨 Layer Transparency Handling
</h3>
<div className="grid md:grid-cols-2 gap-6 text-sm">
<div>
<h4 className="font-semibold text-green-700 mb-3">Base Layers (Opaque):</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span>Polish Orthophoto</span>
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opaque</span>
</div>
<div className="flex justify-between">
<span>Google Satellite/Roads</span>
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opaque</span>
</div>
</div>
</div>
<div>
<h4 className="font-semibold text-green-700 mb-3">Overlay Layers (Transparent):</h4>
<div className="space-y-2">
<div className="flex justify-between">
<span>📋 Cadastral Data</span>
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">80% Opacity</span>
</div>
<div className="flex justify-between">
<span>🏗 Spatial Planning</span>
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">70% Opacity</span>
</div>
<div className="flex justify-between">
<span>🛣 Roads</span>
<span className="bg-blue-200 px-2 py-1 rounded text-xs">90% Opacity</span>
</div>
<div className="flex justify-between">
<span>🏷 Street Names</span>
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opacity</span>
</div>
<div className="flex justify-between">
<span>📐 Parcels</span>
<span className="bg-orange-200 px-2 py-1 rounded text-xs">60% Opacity</span>
</div>
<div className="flex justify-between">
<span>📍 Survey Markers</span>
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">80% Opacity</span>
</div>
</div>
</div>
</div>
<div className="mt-4 p-3 bg-green-100 rounded">
<p className="text-green-800 text-sm">
<strong>Smart Transparency:</strong> Each overlay layer has been optimized with appropriate transparency levels.
Property boundaries are semi-transparent (60-80%) so you can see the underlying imagery,
while text labels are fully opaque (100%) for maximum readability.
</p>
</div>
</div>
{/* Usage Guide */}
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-800 mb-4">
📋 How to Use This Comprehensive Map
</h3>
<div className="grid md:grid-cols-2 gap-6">
<div>
<h4 className="font-semibold text-blue-700 mb-2">Basic Navigation:</h4>
<ul className="text-blue-600 space-y-1 text-sm">
<li> Use mouse wheel to zoom in/out</li>
<li> Click and drag to pan around</li>
<li> Use layer control (top-right) to switch layers</li>
<li> Select different Polish cities above to test</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-blue-700 mb-2">Advanced Features:</h4>
<ul className="text-blue-600 space-y-1 text-sm">
<li> Combine orthophoto with cadastral overlay</li>
<li> Enable multiple overlays simultaneously</li>
<li> Use high-resolution orthophoto for detail work</li>
<li> Compare with Google/OSM base layers</li>
</ul>
</div>
</div>
</div>
{/* Technical Implementation */}
<div className="mt-8 bg-gray-50 border border-gray-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Technical Implementation Details
</h3>
<div className="grid md:grid-cols-3 gap-6 text-sm">
<div>
<h4 className="font-semibold text-gray-700 mb-2">WMTS Integration:</h4>
<ul className="text-gray-600 space-y-1">
<li> Proper KVP URL construction</li>
<li> EPSG:3857 coordinate system</li>
<li> Standard and high-res orthophoto</li>
<li> Multiple format support (JPEG/PNG)</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-gray-700 mb-2">WMS Overlays:</h4>
<ul className="text-gray-600 space-y-1">
<li> Polish government services</li>
<li> LP-Portal municipal data</li>
<li> Transparent overlay support</li>
<li> Multiple layer combinations</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-gray-700 mb-2">React/Leaflet:</h4>
<ul className="text-gray-600 space-y-1">
<li> React-Leaflet component integration</li>
<li> Dynamic layer switching</li>
<li> Responsive design</li>
<li> Performance optimized</li>
</ul>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import DebugPolishOrthophotoMap from '../../components/ui/DebugPolishOrthophotoMap';
export default function DebugPolishOrthophotoPage() {
// Test marker in Poland
const testMarkers = [
{
position: [50.0647, 19.9450], // Krakow
popup: "Kraków - Test Location"
}
];
return (
<div className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-800 mb-6">
Debug Polish Geoportal Orthophoto
</h1>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<h2 className="text-lg font-semibold text-red-800 mb-2">
Debug Mode Active
</h2>
<p className="text-red-700">
This page tests multiple URL formats for Polish Geoportal orthophoto tiles.
Check the browser console and the debug panel on the map for network request information.
</p>
</div>
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="p-4 bg-blue-600 text-white">
<h2 className="text-xl font-semibold">Debug Map with Multiple Orthophoto Options</h2>
<p className="text-blue-100 mt-2">
Try switching between different Polish orthophoto options using the layer control.
Google layers are included as working references.
</p>
</div>
<div className="h-96 md:h-[600px]">
<DebugPolishOrthophotoMap
center={[50.0647, 19.9450]} // Centered on Krakow
zoom={12}
markers={testMarkers}
/>
</div>
</div>
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
URL Formats Being Tested:
</h3>
<div className="space-y-4 text-sm"> <div className="bg-gray-50 p-3 rounded">
<strong>Option 1 (WMTS KVP EPSG:3857):</strong>
<code className="block mt-1 text-xs">
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
</code>
<span className="text-gray-600">Standard Web Mercator projection</span>
</div>
<div className="bg-gray-50 p-3 rounded">
<strong>Option 2 (WMTS KVP EPSG:2180):</strong>
<code className="block mt-1 text-xs">
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:2180&TILEMATRIX=EPSG:2180:{z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
</code>
<span className="text-gray-600">Polish coordinate system</span>
</div>
<div className="bg-gray-50 p-3 rounded">
<strong>Option 3 (Alternative TILEMATRIXSET):</strong>
<code className="block mt-1 text-xs">
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=GoogleMapsCompatible&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
</code>
<span className="text-gray-600">Google Maps compatible matrix</span>
</div>
<div className="bg-gray-50 p-3 rounded">
<strong>Option 4 (PNG format):</strong>
<code className="block mt-1 text-xs">
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png
</code>
<span className="text-gray-600">PNG format instead of JPEG</span>
</div>
</div>
</div>
<div className="mt-8 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
Debug Instructions:
</h3>
<ol className="text-yellow-700 space-y-2">
<li><strong>1.</strong> Open browser Developer Tools (F12) and go to Network tab</li>
<li><strong>2.</strong> Switch between different Polish orthophoto options in the layer control</li>
<li><strong>3.</strong> Look for requests to geoportal.gov.pl in the Network tab</li>
<li><strong>4.</strong> Check the debug panel on the map for request/response info</li>
<li><strong>5.</strong> Note which options return 200 OK vs 404/403 errors</li>
<li><strong>6.</strong> Compare with working Google layers</li>
</ol>
</div>
</div>
</div>
);
}

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`}
>
<Navigation />
<main>{children}</main>
<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">
<span className="text-sm font-medium text-gray-500">
{n.note_date}
</span>
<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

@@ -1,99 +0,0 @@
"use client";
import ImprovedPolishOrthophotoMap from '../../components/ui/ImprovedPolishOrthophotoMap';
export default function ImprovedPolishOrthophotoPage() {
const testMarkers = [
{
position: [50.0647, 19.9450], // Krakow
popup: "Kraków - Testing WMTS"
}
];
return (
<div className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-800 mb-6">
Improved Polish WMTS Implementation
</h1>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<h2 className="text-lg font-semibold text-green-800 mb-2">
Custom WMTS Layer Implementation
</h2>
<p className="text-green-700">
This version uses a custom WMTS layer that properly constructs KVP URLs based on the GetCapabilities response.
Check the debug panel on the map to see the actual requests being made.
</p>
</div>
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="p-4 bg-blue-600 text-white">
<h2 className="text-xl font-semibold">Custom WMTS Layer with Proper KVP URLs</h2>
<p className="text-blue-100 mt-2">
This implementation builds proper WMTS GetTile requests with all required parameters.
Monitor the debug panel and browser network tab for request details.
</p>
</div>
<div className="h-96 md:h-[600px]">
<ImprovedPolishOrthophotoMap
center={[50.0647, 19.9450]}
zoom={12}
markers={testMarkers}
/>
</div>
</div>
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
WMTS Parameters Being Tested:
</h3>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div className="bg-gray-50 p-3 rounded">
<strong>Tile Matrix Sets Available:</strong>
<ul className="mt-2 space-y-1">
<li> EPSG:3857 (Web Mercator)</li>
<li> EPSG:4326 (WGS84)</li>
<li> EPSG:2180 (Polish National Grid)</li>
</ul>
</div>
<div className="bg-gray-50 p-3 rounded">
<strong>Formats Available:</strong>
<ul className="mt-2 space-y-1">
<li> image/jpeg (default)</li>
<li> image/png</li>
</ul>
</div>
</div>
</div>
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-800 mb-2">
Testing Instructions:
</h3>
<ol className="text-blue-700 space-y-2">
<li><strong>1.</strong> Open Browser Developer Tools (F12) Network tab</li>
<li><strong>2.</strong> Filter by "geoportal.gov.pl" to see WMTS requests</li>
<li><strong>3.</strong> Switch between different Polish WMTS options</li>
<li><strong>4.</strong> Check if requests return 200 OK or error codes</li>
<li><strong>5.</strong> Compare with Google Satellite (known working)</li>
<li><strong>6.</strong> Monitor the debug panel for request URLs</li>
</ol>
</div>
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
Expected Behavior:
</h3>
<p className="text-yellow-700">
If the Polish orthophoto tiles appear, you should see aerial imagery of Poland.
If they don't load, check the network requests - they should show proper WMTS GetTile URLs
with all required parameters (SERVICE, REQUEST, LAYER, TILEMATRIXSET, etc.).
</p>
</div>
</div>
</div>
);
}

View File

@@ -1,202 +0,0 @@
"use client";
import { useState } from 'react';
import PolishOrthophotoMap from '../../components/ui/PolishOrthophotoMap';
import AdvancedPolishOrthophotoMap from '../../components/ui/AdvancedPolishOrthophotoMap';
export default function PolishOrthophotoTestPage() {
const [activeMap, setActiveMap] = useState('basic');
// Test markers - various locations in Poland
const testMarkers = [
{
position: [50.0647, 19.9450], // Krakow
popup: "Kraków - Main Market Square"
},
{
position: [52.2297, 21.0122], // Warsaw
popup: "Warszawa - Palace of Culture and Science"
},
{
position: [54.3520, 18.6466], // Gdansk
popup: "Gdańsk - Old Town"
}
];
return (
<div className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-800 mb-6">
Polish Geoportal Orthophoto Integration
</h1>
{/* Map Type Selector */}
<div className="mb-6 bg-white rounded-lg shadow-lg p-4">
<h2 className="text-lg font-semibold text-gray-800 mb-3">
Choose Map Implementation:
</h2>
<div className="flex space-x-4">
<button
onClick={() => setActiveMap('basic')}
className={`px-4 py-2 rounded-lg transition-colors ${
activeMap === 'basic'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Basic Polish Orthophoto
</button>
<button
onClick={() => setActiveMap('advanced')}
className={`px-4 py-2 rounded-lg transition-colors ${
activeMap === 'advanced'
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Advanced with WMS Overlays
</button>
</div>
</div>
{/* Map Container */}
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="p-4 bg-blue-600 text-white">
<h2 className="text-xl font-semibold">
{activeMap === 'basic'
? 'Basic Polish Orthophoto Map'
: 'Advanced Polish Orthophoto with WMS Overlays'
}
</h2>
<p className="text-blue-100 mt-2">
{activeMap === 'basic'
? 'Demonstrates working Polish Geoportal orthophoto tiles with multiple base layer options.'
: 'Advanced version includes Polish cadastral data (działki) and spatial planning (MPZT) as overlay layers.'
}
</p>
</div>
<div className="h-96 md:h-[600px]">
{activeMap === 'basic' ? (
<PolishOrthophotoMap
center={[50.0647, 19.9450]} // Centered on Krakow
zoom={12}
markers={testMarkers}
showLayerControl={true}
/>
) : (
<AdvancedPolishOrthophotoMap
center={[50.0647, 19.9450]} // Centered on Krakow
zoom={12}
markers={testMarkers}
showLayerControl={true}
/>
)}
</div>
</div>
{/* Features Overview */}
<div className="mt-8 grid md:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Basic Map Features:
</h3>
<ul className="space-y-2 text-gray-600">
<li className="flex items-center">
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
Polish Geoportal Orthophoto (Working)
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-blue-500 rounded-full mr-3"></span>
OpenStreetMap base layer
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-red-500 rounded-full mr-3"></span>
Google Satellite imagery
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-3"></span>
Google Roads overlay
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-purple-500 rounded-full mr-3"></span>
Esri World Imagery
</li>
</ul>
</div>
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Advanced Map Features:
</h3>
<ul className="space-y-2 text-gray-600">
<li className="flex items-center">
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
Standard & High Resolution Orthophoto
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-orange-500 rounded-full mr-3"></span>
Polish Cadastral Data (WMS)
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-teal-500 rounded-full mr-3"></span>
Spatial Planning Data (MPZT)
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-indigo-500 rounded-full mr-3"></span>
Overlay layer support
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-pink-500 rounded-full mr-3"></span>
Multiple base layers
</li>
</ul>
</div>
</div>
{/* Technical Implementation Details */}
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Technical Implementation:
</h3>
<div className="grid md:grid-cols-2 gap-6">
<div>
<h4 className="font-semibold text-gray-700 mb-2">Key Improvements:</h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> Uses REST tile service instead of WMTS for better compatibility</li>
<li> Proper tile size (512px) with zoomOffset=-1</li>
<li> proj4 integration for EPSG:2180 coordinate system</li>
<li> Multiple fallback layers for reliability</li>
<li> WMS overlay support for cadastral data</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-gray-700 mb-2">Based on OpenLayers Code:</h4>
<ul className="text-sm text-gray-600 space-y-1">
<li> Converted from OpenLayers to Leaflet implementation</li>
<li> Maintains same layer structure and URLs</li>
<li> Includes Polish projection definitions</li>
<li> Compatible with existing React/Next.js setup</li>
<li> Extensible for additional WMS services</li>
</ul>
</div>
</div>
</div>
{/* Usage Instructions */}
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-800 mb-2">
How to Use:
</h3>
<div className="text-blue-700 space-y-2">
<p><strong>1.</strong> Use the layer control (top-right) to switch between base layers</p>
<p><strong>2.</strong> In advanced mode, enable overlay layers for cadastral/planning data</p>
<p><strong>3.</strong> Click on markers to see location information</p>
<p><strong>4.</strong> Zoom in to see high-resolution orthophoto details</p>
<p><strong>5.</strong> Combine orthophoto with cadastral overlay for property boundaries</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,98 +0,0 @@
"use client";
import PolishOrthophotoMap from '../../components/ui/PolishOrthophotoMap';
export default function TestPolishOrthophotoPage() {
// Test markers - various locations in Poland
const testMarkers = [
{
position: [50.0647, 19.9450], // Krakow
popup: "Kraków - Main Market Square"
},
{
position: [52.2297, 21.0122], // Warsaw
popup: "Warszawa - Palace of Culture and Science"
},
{
position: [54.3520, 18.6466], // Gdansk
popup: "Gdańsk - Old Town"
}
];
return (
<div className="min-h-screen bg-gray-100">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-gray-800 mb-6">
Polish Geoportal Orthophoto Map Test
</h1>
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="p-4 bg-blue-600 text-white">
<h2 className="text-xl font-semibold">Interactive Map with Polish Orthophoto</h2>
<p className="text-blue-100 mt-2">
This map demonstrates working Polish Geoportal orthophoto tiles.
Use the layer control (top-right) to switch between different map layers.
</p>
</div>
<div className="h-96 md:h-[600px]">
<PolishOrthophotoMap
center={[50.0647, 19.9450]} // Centered on Krakow
zoom={12}
markers={testMarkers}
showLayerControl={true}
/>
</div>
</div>
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
Map Layers Available:
</h3>
<ul className="space-y-2 text-gray-600">
<li className="flex items-center">
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
<strong>Polish Geoportal Orthophoto:</strong> High-resolution aerial imagery from Polish Geoportal
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-blue-500 rounded-full mr-3"></span>
<strong>OpenStreetMap:</strong> Standard OpenStreetMap tiles
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-red-500 rounded-full mr-3"></span>
<strong>Google Satellite:</strong> Google satellite imagery
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-3"></span>
<strong>Google Roads:</strong> Google road overlay
</li>
<li className="flex items-center">
<span className="w-3 h-3 bg-purple-500 rounded-full mr-3"></span>
<strong>Esri Satellite:</strong> Esri world imagery
</li>
</ul>
</div>
<div className="mt-8 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
Implementation Notes:
</h3>
<div className="text-yellow-700 space-y-2">
<p>
The Polish Geoportal orthophoto uses REST tile service instead of WMTS for better compatibility
</p>
<p>
Tile size is set to 512px with zoomOffset=-1 for proper tile alignment
</p>
<p>
proj4 library is included for coordinate system transformations (EPSG:2180)
</p>
<p>
Multiple fallback layers are provided for comparison and reliability
</p>
</div>
</div>
</div>
</div>
);
}

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,20 +49,51 @@ const Navigation = () => {
</Link>
</div>
<div className="flex space-x-8">
{navItems.map((item) => (
<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
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive(item.href)
? "bg-blue-100 text-blue-700"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
}`}
>
{item.label}
</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
key={item.href}
href={item.href}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive(item.href)
? "bg-blue-100 text-blue-700"
: "text-gray-600 hover:text-gray-900 hover:bg-gray-50"
}`}
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"
>
{item.label}
Sign In
</Link>
))}
)}
</div>
</div>
</div>

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).*)",
],
};