From 9f912aed2419903383f29d1b5db297670e471b42 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 31 Jan 2026 12:43:14 -0500 Subject: [PATCH] feat: add authentication, user management, and admin features Added comprehensive authentication and authorization system: Authentication System: - Better Auth integration with session management - Login/logout pages and API routes - Middleware for route protection - Auth utilities and client libraries User Management: - User list, detail, and invite pages - User API endpoints (CRUD operations) - Session management for users - Profile settings page Role-Based Access Control: - Role management pages (list, create, edit) - Permission system with granular controls - Role assignment to users - Role API endpoints Admin Features: - Audit log page for tracking system events - Admin settings page - Audit service for logging user actions Additional Features: - Quotes management pages and components - SalesBldr API integration - Email service for notifications Configuration & Documentation: - Updated docker-compose.yml - MCP server configuration (mcp.json) - CVE-2025-55182 security review documentation - Standards guide and PRD documents - Re-enabling authentication documentation Database Migrations: - 012: Auth tables (users, sessions, accounts, verifications) - 013: Role tables (roles, permissions, role_permissions, user_roles) - 014: Admin settings table UI Updates: - Updated dashboard layout - Enhanced app layout with auth integration --- app/admin/audit-log/page.tsx | 38 ++ app/admin/roles/[id]/page.tsx | 52 +++ app/admin/roles/new/page.tsx | 20 + app/admin/roles/page.tsx | 15 + app/admin/settings/page.tsx | 173 ++++++++ app/admin/users/[id]/page.tsx | 98 +++++ app/admin/users/invite/page.tsx | 20 + app/admin/users/page.tsx | 39 ++ app/api/admin/audit-log/route.ts | 105 +++++ app/api/admin/roles/[id]/route.ts | 171 ++++++++ app/api/admin/roles/route.ts | 80 ++++ app/api/admin/settings/route.ts | 88 ++++ app/api/admin/users/[id]/route.ts | 178 ++++++++ .../users/[id]/sessions/[sessionId]/route.ts | 37 ++ app/api/admin/users/[id]/sessions/route.ts | 30 ++ app/api/admin/users/invite/route.ts | 72 ++++ app/api/admin/users/route.ts | 129 ++++++ app/api/auth/[...all]/route.ts | 4 + app/api/salesbldr/quotes/route.ts | 32 ++ app/api/settings/profile/route.ts | 39 ++ app/auth/2fa/page.tsx | 39 ++ app/auth/layout.tsx | 18 + app/auth/setup/page.tsx | 99 +++++ app/auth/sign-in/page.tsx | 23 + app/auth/verify/page.tsx | 94 ++++ app/dashboard/page.tsx | 42 +- app/layout.tsx | 3 + app/quotes/page.tsx | 310 ++++++++++++++ app/settings/page.tsx | 35 ++ app/settings/security/page.tsx | 50 +++ components/admin/audit/audit-log-table.tsx | 265 ++++++++++++ components/admin/roles/permission-picker.tsx | 109 +++++ components/admin/roles/role-form.tsx | 176 ++++++++ components/admin/roles/role-table.tsx | 208 +++++++++ components/admin/users/invite-user-form.tsx | 174 ++++++++ components/admin/users/role-badge.tsx | 41 ++ components/admin/users/user-actions.tsx | 213 +++++++++ components/admin/users/user-form.tsx | 180 ++++++++ components/admin/users/user-sessions.tsx | 216 ++++++++++ components/admin/users/user-table.tsx | 259 +++++++++++ components/auth/auth-provider.tsx | 43 ++ components/auth/magic-link-form.tsx | 134 ++++++ components/auth/microsoft-button.tsx | 74 ++++ components/auth/sign-in-form.tsx | 29 ++ components/auth/two-factor-form.tsx | 132 ++++++ components/quotes/ticket-detail-modal.tsx | 267 ++++++++++++ components/settings/active-sessions.tsx | 139 ++++++ components/settings/profile-form.tsx | 112 +++++ components/settings/two-factor-setup.tsx | 270 ++++++++++++ dev/CVE-2025-55182-React2Shell-Review.md | 270 ++++++++++++ dev/StandardsGuide.pdf | Bin 0 -> 2533079 bytes docker-compose.yml | 4 + docs/re-enabling-authentication.md | 242 +++++++++++ lib/auth-client.ts | 40 ++ lib/auth-utils.ts | 169 ++++++++ lib/auth.ts | 92 ++++ lib/bootstrap.ts | 103 +++++ lib/permissions.ts | 97 +++++ lib/services/audit.ts | 197 +++++++++ lib/services/email.ts | 165 +++++++ lib/services/salesbldr-client.ts | 208 +++++++++ mcp.json | 12 + middleware.ts | 70 +++ migrations/012_create_auth_tables.sql | 76 ++++ migrations/013_create_role_tables.sql | 43 ++ migrations/014_create_admin_settings.sql | 78 ++++ tasks/prd-auth-user-management.md | 405 ++++++++++++++++++ tasks/tasks-prd-auth-user-management.md | 209 +++++++++ 68 files changed, 7651 insertions(+), 3 deletions(-) create mode 100644 app/admin/audit-log/page.tsx create mode 100644 app/admin/roles/[id]/page.tsx create mode 100644 app/admin/roles/new/page.tsx create mode 100644 app/admin/roles/page.tsx create mode 100644 app/admin/settings/page.tsx create mode 100644 app/admin/users/[id]/page.tsx create mode 100644 app/admin/users/invite/page.tsx create mode 100644 app/admin/users/page.tsx create mode 100644 app/api/admin/audit-log/route.ts create mode 100644 app/api/admin/roles/[id]/route.ts create mode 100644 app/api/admin/roles/route.ts create mode 100644 app/api/admin/settings/route.ts create mode 100644 app/api/admin/users/[id]/route.ts create mode 100644 app/api/admin/users/[id]/sessions/[sessionId]/route.ts create mode 100644 app/api/admin/users/[id]/sessions/route.ts create mode 100644 app/api/admin/users/invite/route.ts create mode 100644 app/api/admin/users/route.ts create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/salesbldr/quotes/route.ts create mode 100644 app/api/settings/profile/route.ts create mode 100644 app/auth/2fa/page.tsx create mode 100644 app/auth/layout.tsx create mode 100644 app/auth/setup/page.tsx create mode 100644 app/auth/sign-in/page.tsx create mode 100644 app/auth/verify/page.tsx create mode 100644 app/quotes/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/settings/security/page.tsx create mode 100644 components/admin/audit/audit-log-table.tsx create mode 100644 components/admin/roles/permission-picker.tsx create mode 100644 components/admin/roles/role-form.tsx create mode 100644 components/admin/roles/role-table.tsx create mode 100644 components/admin/users/invite-user-form.tsx create mode 100644 components/admin/users/role-badge.tsx create mode 100644 components/admin/users/user-actions.tsx create mode 100644 components/admin/users/user-form.tsx create mode 100644 components/admin/users/user-sessions.tsx create mode 100644 components/admin/users/user-table.tsx create mode 100644 components/auth/auth-provider.tsx create mode 100644 components/auth/magic-link-form.tsx create mode 100644 components/auth/microsoft-button.tsx create mode 100644 components/auth/sign-in-form.tsx create mode 100644 components/auth/two-factor-form.tsx create mode 100644 components/quotes/ticket-detail-modal.tsx create mode 100644 components/settings/active-sessions.tsx create mode 100644 components/settings/profile-form.tsx create mode 100644 components/settings/two-factor-setup.tsx create mode 100644 dev/CVE-2025-55182-React2Shell-Review.md create mode 100644 dev/StandardsGuide.pdf create mode 100644 docs/re-enabling-authentication.md create mode 100644 lib/auth-client.ts create mode 100644 lib/auth-utils.ts create mode 100644 lib/auth.ts create mode 100644 lib/bootstrap.ts create mode 100644 lib/permissions.ts create mode 100644 lib/services/audit.ts create mode 100644 lib/services/email.ts create mode 100644 lib/services/salesbldr-client.ts create mode 100644 mcp.json create mode 100644 middleware.ts create mode 100644 migrations/012_create_auth_tables.sql create mode 100644 migrations/013_create_role_tables.sql create mode 100644 migrations/014_create_admin_settings.sql create mode 100644 tasks/prd-auth-user-management.md create mode 100644 tasks/tasks-prd-auth-user-management.md diff --git a/app/admin/audit-log/page.tsx b/app/admin/audit-log/page.tsx new file mode 100644 index 0000000..449351c --- /dev/null +++ b/app/admin/audit-log/page.tsx @@ -0,0 +1,38 @@ +import { Suspense } from "react"; +import { AuditLogTable } from "@/components/admin/audit/audit-log-table"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function AuditLogPage() { + return ( +
+
+

Audit Log

+

+ View system activity and security events +

+
+ }> + + +
+ ); +} + +function AuditLogSkeleton() { + return ( +
+
+ + + +
+
+
+ {[...Array(10)].map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/app/admin/roles/[id]/page.tsx b/app/admin/roles/[id]/page.tsx new file mode 100644 index 0000000..875c42f --- /dev/null +++ b/app/admin/roles/[id]/page.tsx @@ -0,0 +1,52 @@ +import { notFound } from "next/navigation"; +import { Pool } from "pg"; +import { RoleForm } from "@/components/admin/roles/role-form"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +interface PageProps { + params: Promise<{ id: string }>; +} + +async function getRole(id: string) { + const result = await pool.query( + `SELECT id, name, description, permissions, is_system, created_at, updated_at + FROM "role" WHERE id = $1`, + [id] + ); + return result.rows[0] || null; +} + +export default async function EditRolePage({ params }: PageProps) { + const { id } = await params; + const role = await getRole(id); + + if (!role) { + notFound(); + } + + return ( +
+ + +
+ Edit Role: {role.name} + {role.is_system && System} +
+ + {role.is_system + ? "System roles cannot be renamed or deleted, but permissions can be modified" + : "Update role name, description, and permissions"} + +
+ + + +
+
+ ); +} diff --git a/app/admin/roles/new/page.tsx b/app/admin/roles/new/page.tsx new file mode 100644 index 0000000..6217ade --- /dev/null +++ b/app/admin/roles/new/page.tsx @@ -0,0 +1,20 @@ +import { RoleForm } from "@/components/admin/roles/role-form"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function NewRolePage() { + return ( +
+ + + Create Role + + Create a new role with custom permissions + + + + + + +
+ ); +} diff --git a/app/admin/roles/page.tsx b/app/admin/roles/page.tsx new file mode 100644 index 0000000..0ef05ed --- /dev/null +++ b/app/admin/roles/page.tsx @@ -0,0 +1,15 @@ +import { RoleTable } from "@/components/admin/roles/role-table"; + +export default function RolesPage() { + return ( +
+
+

Role Management

+

+ Manage roles and their permissions +

+
+ +
+ ); +} diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx new file mode 100644 index 0000000..5056d5f --- /dev/null +++ b/app/admin/settings/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function SettingsPage() { + const [settings, setSettings] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + fetchSettings(); + }, []); + + async function fetchSettings() { + try { + const response = await fetch("/api/admin/settings"); + if (!response.ok) throw new Error("Failed to fetch settings"); + const data = await response.json(); + setSettings(data.settings); + } catch (error) { + toast.error("Failed to load settings"); + } finally { + setIsLoading(false); + } + } + + async function handleSave() { + setIsSaving(true); + try { + const response = await fetch("/api/admin/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings }), + }); + + if (!response.ok) throw new Error("Failed to save settings"); + toast.success("Settings saved successfully"); + } catch (error) { + toast.error("Failed to save settings"); + } finally { + setIsSaving(false); + } + } + + function updateSetting(key: string, value: string) { + setSettings((prev) => ({ ...prev, [key]: value })); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

Settings

+

+ Configure application settings +

+
+ + + + Microsoft + Sessions + Audit + + + + + + Microsoft Entra ID + + Configure Microsoft 365 authentication settings + + + +
+ + updateSetting("microsoft_tenant_id", e.target.value)} + placeholder="common or your-tenant-id" + /> +

+ Use "common" for multi-tenant or specify your organization's tenant ID +

+
+
+
+
+ + + + + Session Settings + + Configure session timeout and security policies + + + +
+ + updateSetting("default_session_timeout", e.target.value)} + /> +

+ Default: 86400 (24 hours) +

+
+
+
+
+ + + + + Audit Log Settings + + Configure audit log retention + + + +
+ + updateSetting("audit_log_retention_days", e.target.value)} + /> +

+ Audit logs older than this will be automatically deleted +

+
+
+
+
+
+ +
+ +
+
+ ); +} diff --git a/app/admin/users/[id]/page.tsx b/app/admin/users/[id]/page.tsx new file mode 100644 index 0000000..6c0e6aa --- /dev/null +++ b/app/admin/users/[id]/page.tsx @@ -0,0 +1,98 @@ +import { notFound } from "next/navigation"; +import { Pool } from "pg"; +import { getSession } from "@/lib/auth-utils"; +import { UserForm } from "@/components/admin/users/user-form"; +import { UserSessions } from "@/components/admin/users/user-sessions"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { RoleBadge } from "@/components/admin/users/role-badge"; +import { Badge } from "@/components/ui/badge"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +interface PageProps { + params: Promise<{ id: string }>; +} + +async function getUser(id: string) { + const result = await pool.query( + `SELECT id, name, email, email_verified, image, role, banned, + banned_reason, ban_expires, requires_setup, created_at, updated_at + FROM "user" WHERE id = $1`, + [id] + ); + return result.rows[0] || null; +} + +async function getUserSessions(userId: string) { + const result = await pool.query( + `SELECT id, ip_address, user_agent, created_at, expires_at + FROM "session" WHERE user_id = $1 ORDER BY created_at DESC`, + [userId] + ); + return result.rows; +} + +export default async function UserDetailPage({ params }: PageProps) { + const { id } = await params; + const session = await getSession(); + const user = await getUser(id); + + if (!user) { + notFound(); + } + + const sessions = await getUserSessions(id); + const isCurrentUser = session?.user?.id === id; + + return ( +
+
+
+

{user.name}

+ + {user.banned && Banned} + {isCurrentUser && You} +
+

{user.email}

+
+ + + + Details + Sessions ({sessions.length}) + + + + + + User Details + + Update user information and role + + + + + + + + + + + + Active Sessions + + View and manage user's active sessions + + + + + + + + +
+ ); +} diff --git a/app/admin/users/invite/page.tsx b/app/admin/users/invite/page.tsx new file mode 100644 index 0000000..0996950 --- /dev/null +++ b/app/admin/users/invite/page.tsx @@ -0,0 +1,20 @@ +import { InviteUserForm } from "@/components/admin/users/invite-user-form"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function InviteUserPage() { + return ( +
+ + + Invite User + + Send an invitation email to add a new user to the system + + + + + + +
+ ); +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..2e42b70 --- /dev/null +++ b/app/admin/users/page.tsx @@ -0,0 +1,39 @@ +import { Suspense } from "react"; +import { UserTable } from "@/components/admin/users/user-table"; +import { Skeleton } from "@/components/ui/skeleton"; + +export default function UsersPage() { + return ( +
+
+

User Management

+

+ Manage users, roles, and permissions +

+
+ }> + + +
+ ); +} + +function UserTableSkeleton() { + return ( +
+
+ + + + +
+
+
+ {[...Array(5)].map((_, i) => ( + + ))} +
+
+
+ ); +} diff --git a/app/api/admin/audit-log/route.ts b/app/api/admin/audit-log/route.ts new file mode 100644 index 0000000..bb2c3fe --- /dev/null +++ b/app/api/admin/audit-log/route.ts @@ -0,0 +1,105 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin } from "@/lib/auth-utils"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// GET /api/admin/audit-log - Get audit logs with pagination and filters +export async function GET(request: NextRequest) { + const { error } = await requireAdmin(); + if (error) return error; + + try { + const searchParams = request.nextUrl.searchParams; + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "50"); + const offset = (page - 1) * limit; + + const userId = searchParams.get("userId"); + const action = searchParams.get("action"); + const resource = searchParams.get("resource"); + const startDate = searchParams.get("startDate"); + const endDate = searchParams.get("endDate"); + + let query = ` + SELECT + al.id, al.timestamp, al.user_id, al.user_email, al.action, + al.resource, al.resource_id, al.details, al.ip_address, + u.name as user_name + FROM "audit_log" al + LEFT JOIN "user" u ON al.user_id = u.id + WHERE 1=1 + `; + const params: (string | number)[] = []; + let paramIndex = 1; + + if (userId) { + query += ` AND al.user_id = $${paramIndex++}`; + params.push(userId); + } + + if (action) { + query += ` AND al.action = $${paramIndex++}`; + params.push(action); + } + + if (resource) { + query += ` AND al.resource = $${paramIndex++}`; + params.push(resource); + } + + if (startDate) { + query += ` AND al.timestamp >= $${paramIndex++}`; + params.push(startDate); + } + + if (endDate) { + query += ` AND al.timestamp <= $${paramIndex++}`; + params.push(endDate); + } + + // Get total count + const countQuery = query.replace( + /SELECT[\s\S]*?FROM/, + "SELECT COUNT(*) as total FROM" + ); + const countResult = await pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // Add pagination + query += ` ORDER BY al.timestamp DESC LIMIT $${paramIndex++} OFFSET $${paramIndex}`; + params.push(limit, offset); + + const result = await pool.query(query, params); + + // Get unique actions and resources for filters + const actionsResult = await pool.query( + 'SELECT DISTINCT action FROM "audit_log" ORDER BY action' + ); + const resourcesResult = await pool.query( + 'SELECT DISTINCT resource FROM "audit_log" ORDER BY resource' + ); + + return NextResponse.json({ + logs: result.rows, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + filters: { + actions: actionsResult.rows.map((r) => r.action), + resources: resourcesResult.rows.map((r) => r.resource), + }, + }); + } catch (error) { + console.error("Error fetching audit logs:", error); + return NextResponse.json( + { error: "Failed to fetch audit logs" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/roles/[id]/route.ts b/app/api/admin/roles/[id]/route.ts new file mode 100644 index 0000000..7992612 --- /dev/null +++ b/app/api/admin/roles/[id]/route.ts @@ -0,0 +1,171 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin, requireSuperAdmin } from "@/lib/auth-utils"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// GET /api/admin/roles/[id] - Get a single role +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAdmin(); + if (error) return error; + + try { + const { id } = await params; + + const result = await pool.query( + `SELECT + r.id, r.name, r.description, r.permissions, r.is_system, r.created_at, r.updated_at, + COUNT(DISTINCT u.id) as user_count + FROM "role" r + LEFT JOIN "user" u ON u.role = r.name + WHERE r.id = $1 + GROUP BY r.id`, + [id] + ); + + if (result.rows.length === 0) { + return NextResponse.json({ error: "Role not found" }, { status: 404 }); + } + + return NextResponse.json({ role: result.rows[0] }); + } catch (error) { + console.error("Error fetching role:", error); + return NextResponse.json( + { error: "Failed to fetch role" }, + { status: 500 } + ); + } +} + +// PATCH /api/admin/roles/[id] - Update a role +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireSuperAdmin(); + if (error) return error; + + try { + const { id } = await params; + const body = await request.json(); + const { name, description, permissions } = body; + + // Check if role exists + const existingRole = await pool.query( + 'SELECT id, is_system, name FROM "role" WHERE id = $1', + [id] + ); + + if (existingRole.rows.length === 0) { + return NextResponse.json({ error: "Role not found" }, { status: 404 }); + } + + // Prevent modifying system roles' names + if (existingRole.rows[0].is_system && name && name !== existingRole.rows[0].name) { + return NextResponse.json( + { error: "Cannot rename system roles" }, + { status: 400 } + ); + } + + // Build update query + const updates: string[] = []; + const values: (string | null)[] = []; + let paramIndex = 1; + + if (name !== undefined) { + updates.push(`name = $${paramIndex++}`); + values.push(name); + } + if (description !== undefined) { + updates.push(`description = $${paramIndex++}`); + values.push(description); + } + if (permissions !== undefined) { + updates.push(`permissions = $${paramIndex++}`); + values.push(JSON.stringify(permissions)); + } + + if (updates.length === 0) { + return NextResponse.json( + { error: "No fields to update" }, + { status: 400 } + ); + } + + updates.push(`updated_at = NOW()`); + values.push(id); + + const result = await pool.query( + `UPDATE "role" SET ${updates.join(", ")} WHERE id = $${paramIndex} + RETURNING id, name, description, permissions, is_system, updated_at`, + values + ); + + return NextResponse.json({ role: result.rows[0] }); + } catch (error) { + console.error("Error updating role:", error); + return NextResponse.json( + { error: "Failed to update role" }, + { status: 500 } + ); + } +} + +// DELETE /api/admin/roles/[id] - Delete a role +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireSuperAdmin(); + if (error) return error; + + try { + const { id } = await params; + + // Check if role exists and is not a system role + const existingRole = await pool.query( + 'SELECT id, is_system, name FROM "role" WHERE id = $1', + [id] + ); + + if (existingRole.rows.length === 0) { + return NextResponse.json({ error: "Role not found" }, { status: 404 }); + } + + if (existingRole.rows[0].is_system) { + return NextResponse.json( + { error: "Cannot delete system roles" }, + { status: 400 } + ); + } + + // Check if role is assigned to any users + const usersWithRole = await pool.query( + 'SELECT COUNT(*) as count FROM "user" WHERE role = $1', + [existingRole.rows[0].name] + ); + + if (parseInt(usersWithRole.rows[0].count) > 0) { + return NextResponse.json( + { error: "Cannot delete role that is assigned to users" }, + { status: 400 } + ); + } + + await pool.query('DELETE FROM "role" WHERE id = $1', [id]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting role:", error); + return NextResponse.json( + { error: "Failed to delete role" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/roles/route.ts b/app/api/admin/roles/route.ts new file mode 100644 index 0000000..908dfbb --- /dev/null +++ b/app/api/admin/roles/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin, requireSuperAdmin } from "@/lib/auth-utils"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// GET /api/admin/roles - List all roles +export async function GET(request: NextRequest) { + const { error } = await requireAdmin(); + if (error) return error; + + try { + const result = await pool.query(` + SELECT + r.id, r.name, r.description, r.permissions, r.is_system, r.created_at, + COUNT(DISTINCT u.id) as user_count + FROM "role" r + LEFT JOIN "user" u ON u.role = r.name + GROUP BY r.id + ORDER BY r.is_system DESC, r.name ASC + `); + + return NextResponse.json({ roles: result.rows }); + } catch (error) { + console.error("Error fetching roles:", error); + return NextResponse.json( + { error: "Failed to fetch roles" }, + { status: 500 } + ); + } +} + +// POST /api/admin/roles - Create a new role +export async function POST(request: NextRequest) { + const { error } = await requireSuperAdmin(); + if (error) return error; + + try { + const body = await request.json(); + const { name, description, permissions } = body; + + if (!name) { + return NextResponse.json( + { error: "Role name is required" }, + { status: 400 } + ); + } + + // Check if role already exists + const existingRole = await pool.query( + 'SELECT id FROM "role" WHERE name = $1', + [name] + ); + + if (existingRole.rows.length > 0) { + return NextResponse.json( + { error: "Role with this name already exists" }, + { status: 409 } + ); + } + + const id = `role_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`; + const result = await pool.query( + `INSERT INTO "role" (id, name, description, permissions, is_system, created_at, updated_at) + VALUES ($1, $2, $3, $4, false, NOW(), NOW()) + RETURNING id, name, description, permissions, is_system, created_at`, + [id, name, description || null, JSON.stringify(permissions || {})] + ); + + return NextResponse.json({ role: result.rows[0] }, { status: 201 }); + } catch (error) { + console.error("Error creating role:", error); + return NextResponse.json( + { error: "Failed to create role" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts new file mode 100644 index 0000000..b3615f6 --- /dev/null +++ b/app/api/admin/settings/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin, requireSuperAdmin, getSession } from "@/lib/auth-utils"; +import { audit } from "@/lib/services/audit"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// GET /api/admin/settings - Get all app settings +export async function GET(request: NextRequest) { + const { error } = await requireAdmin(); + if (error) return error; + + try { + const result = await pool.query( + `SELECT key, value, description FROM "app_settings" ORDER BY key` + ); + + // Convert to key-value object + const settings: Record = {}; + result.rows.forEach((row) => { + settings[row.key] = row.value; + }); + + return NextResponse.json({ settings }); + } catch (error) { + console.error("Error fetching settings:", error); + return NextResponse.json( + { error: "Failed to fetch settings" }, + { status: 500 } + ); + } +} + +// PATCH /api/admin/settings - Update app settings +export async function PATCH(request: NextRequest) { + const { session, error } = await requireSuperAdmin(); + if (error) return error; + + try { + const body = await request.json(); + const { settings } = body; + + if (!settings || typeof settings !== "object") { + return NextResponse.json( + { error: "Settings object is required" }, + { status: 400 } + ); + } + + // Update each setting + for (const [key, value] of Object.entries(settings)) { + // Get old value for audit + const oldResult = await pool.query( + 'SELECT value FROM "app_settings" WHERE key = $1', + [key] + ); + const oldValue = oldResult.rows[0]?.value; + + await pool.query( + `INSERT INTO "app_settings" (id, key, value, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (key) DO UPDATE SET value = $3, updated_at = NOW()`, + [`setting_${key}`, key, value] + ); + + // Audit log + if (session?.user) { + await audit.settingsUpdated( + session.user.id, + session.user.email, + key, + oldValue, + value + ); + } + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error updating settings:", error); + return NextResponse.json( + { error: "Failed to update settings" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..573ea3a --- /dev/null +++ b/app/api/admin/users/[id]/route.ts @@ -0,0 +1,178 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin, requireSuperAdmin, getSession } from "@/lib/auth-utils"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// GET /api/admin/users/[id] - Get a single user +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAdmin(); + if (error) return error; + + try { + const { id } = await params; + + const result = await pool.query( + `SELECT + id, name, email, email_verified, image, role, banned, + banned_reason, ban_expires, requires_setup, created_at, updated_at + FROM "user" WHERE id = $1`, + [id] + ); + + if (result.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Get user's sessions + const sessions = await pool.query( + `SELECT id, ip_address, user_agent, created_at, expires_at + FROM "session" WHERE user_id = $1 ORDER BY created_at DESC`, + [id] + ); + + return NextResponse.json({ + user: result.rows[0], + sessions: sessions.rows, + }); + } catch (error) { + console.error("Error fetching user:", error); + return NextResponse.json( + { error: "Failed to fetch user" }, + { status: 500 } + ); + } +} + +// PATCH /api/admin/users/[id] - Update a user +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requireSuperAdmin(); + if (error) return error; + + try { + const { id } = await params; + const body = await request.json(); + const { name, email, role, banned, banned_reason } = body; + + // Check if user exists + const existingUser = await pool.query( + 'SELECT id, role FROM "user" WHERE id = $1', + [id] + ); + + if (existingUser.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Prevent modifying own role + if (session?.user.id === id && role && role !== existingUser.rows[0].role) { + return NextResponse.json( + { error: "Cannot modify your own role" }, + { status: 400 } + ); + } + + // Build update query dynamically + const updates: string[] = []; + const values: (string | boolean | null)[] = []; + let paramIndex = 1; + + if (name !== undefined) { + updates.push(`name = $${paramIndex++}`); + values.push(name); + } + if (email !== undefined) { + updates.push(`email = $${paramIndex++}`); + values.push(email); + } + if (role !== undefined) { + updates.push(`role = $${paramIndex++}`); + values.push(role); + } + if (banned !== undefined) { + updates.push(`banned = $${paramIndex++}`); + values.push(banned); + if (banned && banned_reason) { + updates.push(`banned_reason = $${paramIndex++}`); + values.push(banned_reason); + } else if (!banned) { + updates.push(`banned_reason = NULL`); + updates.push(`ban_expires = NULL`); + } + } + + if (updates.length === 0) { + return NextResponse.json( + { error: "No fields to update" }, + { status: 400 } + ); + } + + updates.push(`updated_at = NOW()`); + values.push(id); + + const result = await pool.query( + `UPDATE "user" SET ${updates.join(", ")} WHERE id = $${paramIndex} + RETURNING id, name, email, role, banned, banned_reason, updated_at`, + values + ); + + return NextResponse.json({ user: result.rows[0] }); + } catch (error) { + console.error("Error updating user:", error); + return NextResponse.json( + { error: "Failed to update user" }, + { status: 500 } + ); + } +} + +// DELETE /api/admin/users/[id] - Delete a user +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { session, error } = await requireSuperAdmin(); + if (error) return error; + + try { + const { id } = await params; + + // Prevent self-deletion + if (session?.user.id === id) { + return NextResponse.json( + { error: "Cannot delete your own account" }, + { status: 400 } + ); + } + + // Check if user exists + const existingUser = await pool.query( + 'SELECT id FROM "user" WHERE id = $1', + [id] + ); + + if (existingUser.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + // Delete user (cascades to sessions and accounts) + await pool.query('DELETE FROM "user" WHERE id = $1', [id]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting user:", error); + return NextResponse.json( + { error: "Failed to delete user" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/users/[id]/sessions/[sessionId]/route.ts b/app/api/admin/users/[id]/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..66bbd42 --- /dev/null +++ b/app/api/admin/users/[id]/sessions/[sessionId]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin } from "@/lib/auth-utils"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// DELETE /api/admin/users/[id]/sessions/[sessionId] - Revoke a specific session +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string; sessionId: string }> } +) { + const { error } = await requireAdmin(); + if (error) return error; + + try { + const { id, sessionId } = await params; + + const result = await pool.query( + 'DELETE FROM "session" WHERE id = $1 AND user_id = $2', + [sessionId, id] + ); + + if (result.rowCount === 0) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error revoking session:", error); + return NextResponse.json( + { error: "Failed to revoke session" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/users/[id]/sessions/route.ts b/app/api/admin/users/[id]/sessions/route.ts new file mode 100644 index 0000000..b7b470a --- /dev/null +++ b/app/api/admin/users/[id]/sessions/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin } from "@/lib/auth-utils"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// DELETE /api/admin/users/[id]/sessions - Revoke all sessions for a user +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { error } = await requireAdmin(); + if (error) return error; + + try { + const { id } = await params; + + await pool.query('DELETE FROM "session" WHERE user_id = $1', [id]); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error revoking sessions:", error); + return NextResponse.json( + { error: "Failed to revoke sessions" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/users/invite/route.ts b/app/api/admin/users/invite/route.ts new file mode 100644 index 0000000..8b8e501 --- /dev/null +++ b/app/api/admin/users/invite/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin, getSession } from "@/lib/auth-utils"; +import { sendInvitationEmail } from "@/lib/services/email"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// POST /api/admin/users/invite - Send invitation email +export async function POST(request: NextRequest) { + const { session, error } = await requireAdmin(); + if (error) return error; + + try { + const body = await request.json(); + const { email, name, role = "user" } = body; + + if (!email) { + return NextResponse.json( + { error: "Email is required" }, + { status: 400 } + ); + } + + // Check if user already exists + const existingUser = await pool.query( + 'SELECT id FROM "user" WHERE email = $1', + [email] + ); + + if (existingUser.rows.length > 0) { + return NextResponse.json( + { error: "User with this email already exists" }, + { status: 409 } + ); + } + + // Create the user with requires_setup flag + const id = crypto.randomUUID(); + await pool.query( + `INSERT INTO "user" (id, name, email, role, email_verified, requires_setup, created_at, updated_at) + VALUES ($1, $2, $3, $4, false, true, NOW(), NOW())`, + [id, name || email.split("@")[0], email, role] + ); + + // Generate invitation URL (magic link) + const baseUrl = process.env.BETTER_AUTH_URL || "http://localhost:3000"; + const inviteUrl = `${baseUrl}/auth/sign-in?email=${encodeURIComponent(email)}`; + + // Get inviter name + const inviterName = session?.user.name || "An administrator"; + + // Send invitation email + await sendInvitationEmail({ + email, + inviterName, + url: inviteUrl, + }); + + return NextResponse.json({ + success: true, + message: "Invitation sent successfully", + }); + } catch (error) { + console.error("Error sending invitation:", error); + return NextResponse.json( + { error: "Failed to send invitation" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..479da0c --- /dev/null +++ b/app/api/admin/users/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAdmin, requireSuperAdmin } from "@/lib/auth-utils"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// GET /api/admin/users - List all users +export async function GET(request: NextRequest) { + const { error } = await requireAdmin(); + if (error) return error; + + try { + const searchParams = request.nextUrl.searchParams; + const search = searchParams.get("search") || ""; + const role = searchParams.get("role") || ""; + const status = searchParams.get("status") || ""; + const page = parseInt(searchParams.get("page") || "1"); + const limit = parseInt(searchParams.get("limit") || "20"); + const offset = (page - 1) * limit; + + let query = ` + SELECT + id, name, email, email_verified, image, role, banned, + banned_reason, ban_expires, requires_setup, created_at, updated_at + FROM "user" + WHERE 1=1 + `; + const params: (string | number)[] = []; + let paramIndex = 1; + + if (search) { + query += ` AND (name ILIKE $${paramIndex} OR email ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + if (role) { + query += ` AND role = $${paramIndex}`; + params.push(role); + paramIndex++; + } + + if (status === "active") { + query += ` AND (banned = false OR banned IS NULL)`; + } else if (status === "banned") { + query += ` AND banned = true`; + } + + // Get total count + const countQuery = query.replace( + /SELECT[\s\S]*?FROM/, + "SELECT COUNT(*) as total FROM" + ); + const countResult = await pool.query(countQuery, params); + const total = parseInt(countResult.rows[0].total); + + // Add pagination + query += ` ORDER BY created_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`; + params.push(limit, offset); + + const result = await pool.query(query, params); + + return NextResponse.json({ + users: result.rows, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error("Error fetching users:", error); + return NextResponse.json( + { error: "Failed to fetch users" }, + { status: 500 } + ); + } +} + +// POST /api/admin/users - Create a new user +export async function POST(request: NextRequest) { + const { session, error } = await requireSuperAdmin(); + if (error) return error; + + try { + const body = await request.json(); + const { name, email, role = "user" } = body; + + if (!name || !email) { + return NextResponse.json( + { error: "Name and email are required" }, + { status: 400 } + ); + } + + // Check if user already exists + const existingUser = await pool.query( + 'SELECT id FROM "user" WHERE email = $1', + [email] + ); + + if (existingUser.rows.length > 0) { + return NextResponse.json( + { error: "User with this email already exists" }, + { status: 409 } + ); + } + + // Create the user + const id = crypto.randomUUID(); + const result = await pool.query( + `INSERT INTO "user" (id, name, email, role, email_verified, created_at, updated_at) + VALUES ($1, $2, $3, $4, false, NOW(), NOW()) + RETURNING id, name, email, role, email_verified, created_at`, + [id, name, email, role] + ); + + return NextResponse.json({ user: result.rows[0] }, { status: 201 }); + } catch (error) { + console.error("Error creating user:", error); + return NextResponse.json( + { error: "Failed to create user" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..5b67b06 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/app/api/salesbldr/quotes/route.ts b/app/api/salesbldr/quotes/route.ts new file mode 100644 index 0000000..6fd388c --- /dev/null +++ b/app/api/salesbldr/quotes/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { salesBldrClient } from '@/lib/services/salesbldr-client'; + +export async function GET(request: NextRequest) { + try { + const searchParams = request.nextUrl.searchParams; + const status = searchParams.get('status'); + const size = searchParams.get('size') ? parseInt(searchParams.get('size')!) : 10; + + let quotes; + + if (status === 'open') { + quotes = await salesBldrClient.getOpenQuotes(size); + } else if (status === 'approved') { + const approvedQuotes = await salesBldrClient.getApprovedQuotes(size); + quotes = { results: approvedQuotes, total: approvedQuotes.length }; + } else { + quotes = await salesBldrClient.getQuotes({ + size, + sort: '-createdAt' + }); + } + + return NextResponse.json(quotes); + } catch (error) { + console.error('Error fetching SalesBldr quotes:', error); + return NextResponse.json( + { error: 'Failed to fetch quotes', details: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ); + } +} diff --git a/app/api/settings/profile/route.ts b/app/api/settings/profile/route.ts new file mode 100644 index 0000000..a12eba7 --- /dev/null +++ b/app/api/settings/profile/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Pool } from "pg"; +import { requireAuth, getSession } from "@/lib/auth-utils"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +// PATCH /api/settings/profile - Update current user's profile +export async function PATCH(request: NextRequest) { + const { session, error } = await requireAuth(); + if (error) return error; + + try { + const body = await request.json(); + const { name } = body; + + if (!name) { + return NextResponse.json( + { error: "Name is required" }, + { status: 400 } + ); + } + + const result = await pool.query( + `UPDATE "user" SET name = $1, updated_at = NOW() WHERE id = $2 + RETURNING id, name, email`, + [name, session!.user.id] + ); + + return NextResponse.json({ user: result.rows[0] }); + } catch (error) { + console.error("Error updating profile:", error); + return NextResponse.json( + { error: "Failed to update profile" }, + { status: 500 } + ); + } +} diff --git a/app/auth/2fa/page.tsx b/app/auth/2fa/page.tsx new file mode 100644 index 0000000..e02cd68 --- /dev/null +++ b/app/auth/2fa/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Suspense } from "react"; +import { useSearchParams } from "next/navigation"; +import { TwoFactorForm } from "@/components/auth/two-factor-form"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ShieldCheck } from "lucide-react"; + +function TwoFactorContent() { + const searchParams = useSearchParams(); + const callbackURL = searchParams.get("callbackURL") || "/"; + + return ( + + +
+
+ +
+
+ Two-Factor Authentication + + Enter the 6-digit code from your authenticator app + +
+ + + +
+ ); +} + +export default function TwoFactorPage() { + return ( + Loading...}> + + + ); +} diff --git a/app/auth/layout.tsx b/app/auth/layout.tsx new file mode 100644 index 0000000..840abad --- /dev/null +++ b/app/auth/layout.tsx @@ -0,0 +1,18 @@ +import { ThemeProvider } from "@/components/theme-provider"; +import { Toaster } from "sonner"; + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+ {children} +
+ +
+ ); +} diff --git a/app/auth/setup/page.tsx b/app/auth/setup/page.tsx new file mode 100644 index 0000000..e200a6b --- /dev/null +++ b/app/auth/setup/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, ShieldCheck } from "lucide-react"; +import { toast } from "sonner"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { MicrosoftButton } from "@/components/auth/microsoft-button"; +import { Separator } from "@/components/ui/separator"; +import { authClient } from "@/lib/auth-client"; + +export default function SetupPage() { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + + async function handleSendMagicLink() { + setIsLoading(true); + try { + // Get current user's email from session + const session = await authClient.getSession(); + if (!session?.data?.user?.email) { + toast.error("Unable to get user email"); + return; + } + + const result = await authClient.signIn.magicLink({ + email: session.data.user.email, + callbackURL: "/", + }); + + if (result.error) { + toast.error(result.error.message || "Failed to send magic link"); + return; + } + + toast.success("Magic link sent! Check your email to complete setup."); + } catch (error) { + toast.error("An unexpected error occurred"); + } finally { + setIsLoading(false); + } + } + + return ( + + +
+
+ +
+
+ Complete Your Setup + + Your account was created by an administrator. Please link an authentication method to secure your account. + +
+ +
+

+ This is a one-time setup. After completing this step, you'll be able to sign in normally. +

+
+ + {/* Microsoft OAuth */} + + + {/* Divider */} +
+
+ +
+
+ + Or use email + +
+
+ + {/* Magic Link */} + +
+
+ ); +} diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx new file mode 100644 index 0000000..475924a --- /dev/null +++ b/app/auth/sign-in/page.tsx @@ -0,0 +1,23 @@ +import { SignInForm } from "@/components/auth/sign-in-form"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function SignInPage() { + return ( + + +
+
+ P +
+
+ Welcome to Pulse + + Sign in to your account to continue + +
+ + + +
+ ); +} diff --git a/app/auth/verify/page.tsx b/app/auth/verify/page.tsx new file mode 100644 index 0000000..8bc15af --- /dev/null +++ b/app/auth/verify/page.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { Loader2, CheckCircle, XCircle } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +function VerifyContent() { + const searchParams = useSearchParams(); + const router = useRouter(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + const [errorMessage, setErrorMessage] = useState(""); + + useEffect(() => { + const token = searchParams.get("token"); + const error = searchParams.get("error"); + + if (error) { + setStatus("error"); + setErrorMessage(decodeURIComponent(error)); + return; + } + + if (!token) { + setStatus("error"); + setErrorMessage("No verification token provided"); + return; + } + + // The verification is handled by Better Auth automatically + // If we reach this page with a token, it means the verification was successful + // and the user should be redirected + const callbackUrl = searchParams.get("callbackURL") || "/"; + + // Short delay to show success state + setTimeout(() => { + setStatus("success"); + setTimeout(() => { + router.push(callbackUrl); + }, 1500); + }, 500); + }, [searchParams, router]); + + return ( + + +
+
+ P +
+
+ + {status === "loading" && "Verifying..."} + {status === "success" && "Verified!"} + {status === "error" && "Verification Failed"} + + + {status === "loading" && "Please wait while we verify your magic link"} + {status === "success" && "You're being redirected..."} + {status === "error" && errorMessage} + +
+ + {status === "loading" && ( + + )} + {status === "success" && ( +
+ +
+ )} + {status === "error" && ( +
+
+ +
+ +
+ )} +
+
+ ); +} + +export default function VerifyPage() { + return ( + Loading...
}> + + + ); +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 13e5f7d..8fa821f 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -22,7 +22,8 @@ import { XCircle, Users, HardDrive, - Wifi + Wifi, + FileText } from 'lucide-react'; interface DashboardStats { @@ -44,6 +45,10 @@ interface DashboardStats { unmapped: number; }; }; + quotes: { + open: number; + total: number; + }; } export default function DashboardPage() { @@ -53,7 +58,8 @@ export default function DashboardPage() { mappings: { auvik: { mapped: 0, unmapped: 0 }, rmm: { mapped: 0, unmapped: 0 } - } + }, + quotes: { open: 0, total: 0 } }); const [loading, setLoading] = useState(true); @@ -75,6 +81,17 @@ export default function DashboardPage() { const rmmRes = await fetch('/api/rmm/site-mappings?includeUnmapped=true'); const rmmData = await rmmRes.json(); + // Fetch SalesBldr quotes + let quotesData = { results: [], total: 0 }; + try { + const quotesRes = await fetch('/api/salesbldr/quotes?status=open&size=100'); + if (quotesRes.ok) { + quotesData = await quotesRes.json(); + } + } catch (quotesError) { + console.error('Error fetching quotes:', quotesError); + } + setStats({ companies: { total: companiesData.companies?.length || 0, @@ -93,6 +110,10 @@ export default function DashboardPage() { mapped: rmmData.stats?.mapped || 0, unmapped: rmmData.stats?.unmapped || 0 } + }, + quotes: { + open: quotesData.results?.length || 0, + total: quotesData.total || 0 } }); } catch (error) { @@ -185,7 +206,7 @@ export default function DashboardPage() { {/* Stats Overview */} -
+
Total Companies @@ -233,6 +254,21 @@ export default function DashboardPage() { + + + + Open Quotes + + + +
{stats.quotes.open}
+

+ Pending approval +

+
+
+ + System Status diff --git a/app/layout.tsx b/app/layout.tsx index ce93626..6dd9077 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,8 @@ import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; import { AppNavigation } from "@/components/navigation/app-navigation"; import { Toaster } from "sonner"; +// TEMPORARY: AuthProvider disabled for private site access +// import { AuthProvider } from "@/components/auth/auth-provider"; const inter = Inter({ subsets: ["latin"] }); @@ -26,6 +28,7 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > + {/* TEMPORARY: AuthProvider removed - TODO: Re-enable when site has public access */}
{children}
diff --git a/app/quotes/page.tsx b/app/quotes/page.tsx new file mode 100644 index 0000000..b278f4f --- /dev/null +++ b/app/quotes/page.tsx @@ -0,0 +1,310 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + FileText, + ExternalLink, + Calendar, + Building2, + User, + DollarSign, + RefreshCw, + Ticket +} from 'lucide-react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { TicketDetailModal } from '@/components/quotes/ticket-detail-modal'; + +interface Quote { + id: string; + title: string; + number: string; + status: string; + createdAt: string; + sentAt?: string; + company?: { + name: string; + }; + contact?: { + name: string; + }; + owner?: { + name: string; + }; + items?: Array<{ + price?: number; + quantity: number; + }>; + link?: string; +} + +export default function QuotesPage() { + const [quotes, setQuotes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedTicketNumber, setSelectedTicketNumber] = useState(null); + const [ticketModalOpen, setTicketModalOpen] = useState(false); + + useEffect(() => { + fetchQuotes(); + }, []); + + const fetchQuotes = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch('/api/salesbldr/quotes?status=open&size=100'); + if (!response.ok) { + throw new Error('Failed to fetch quotes'); + } + const data = await response.json(); + setQuotes(data.results || []); + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + console.error('Error fetching quotes:', err); + } finally { + setLoading(false); + } + }; + + const getStatusBadge = (status: string) => { + const variants: Record = { + draft: { variant: "secondary", label: "Draft" }, + sent: { variant: "default", label: "Sent" }, + approved: { variant: "outline", label: "Approved" }, + declined: { variant: "destructive", label: "Declined" }, + }; + const config = variants[status] || { variant: "outline", label: status }; + return {config.label}; + }; + + const calculateTotal = (quote: Quote) => { + if (!quote.items || quote.items.length === 0) return 0; + return quote.items.reduce((sum, item) => { + return sum + ((item.price || 0) * item.quantity); + }, 0); + }; + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const extractTicketNumber = (title: string): { ticketNumber: string; ticketId: string } | null => { + // Match ticket numbers in format T20260109.0122 + const match = title.match(/T(\d{8})\.(\d{4})/); + if (match) { + return { + ticketNumber: match[0], + ticketId: match[0].substring(1) // Remove the 'T' prefix for the ID + }; + } + return null; + }; + + const handleTicketClick = (ticketNumber: string) => { + setSelectedTicketNumber(ticketNumber); + setTicketModalOpen(true); + }; + + const renderTitleWithTicketLink = (title: string) => { + const ticketInfo = extractTicketNumber(title); + if (!ticketInfo) { + return
{title}
; + } + + // Split the title into parts before and after the ticket number + const parts = title.split(ticketInfo.ticketNumber); + + return ( +
+ {parts[0]} + { + e.stopPropagation(); + handleTicketClick(ticketInfo.ticketNumber); + }} + title={`View ticket ${ticketInfo.ticketNumber} in Autotask`} + > + + {ticketInfo.ticketNumber} + + {parts[1]} +
+ ); + }; + + return ( +
+ {/* Header */} +
+
+

+ + Open Quotes +

+

+ View and manage quotes from SalesBldr +

+
+ +
+ + {/* Stats */} +
+ + + Total Open Quotes + + + +
{quotes.length}
+
+
+ + + + Sent Quotes + + + +
+ {quotes.filter(q => q.status === 'sent').length} +
+
+
+ + + + Draft Quotes + + + +
+ {quotes.filter(q => q.status === 'draft').length} +
+
+
+
+ + {/* Quotes Table */} + + + Quotes List + + All open quotes from SalesBldr + + + + {loading ? ( +
+ +
+ ) : error ? ( +
+

Error: {error}

+ +
+ ) : quotes.length === 0 ? ( +
+ +

No open quotes found

+
+ ) : ( + + + + Quote # + Title + Company + Owner + Status + Date + Total + + + + + {quotes.map((quote) => ( + + {quote.number} + + {renderTitleWithTicketLink(quote.title)} + + +
+ + {quote.company?.name || 'N/A'} +
+
+ +
+ + {quote.owner?.name || 'N/A'} +
+
+ {getStatusBadge(quote.status)} + {formatDate(quote.sentAt || quote.createdAt)} + + {formatCurrency(calculateTotal(quote))} + + + {quote.link && ( + + )} + +
+ ))} +
+
+ )} +
+
+ + {/* Ticket Detail Modal */} + {selectedTicketNumber && ( + + )} +
+ ); +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..9b3aac1 --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,35 @@ +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth-utils"; +import { ProfileForm } from "@/components/settings/profile-form"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default async function SettingsPage() { + const session = await getSession(); + + if (!session) { + redirect("/auth/sign-in"); + } + + return ( +
+
+

Settings

+

+ Manage your account settings +

+
+ + + + Profile + + Update your personal information + + + + + + +
+ ); +} diff --git a/app/settings/security/page.tsx b/app/settings/security/page.tsx new file mode 100644 index 0000000..e35227c --- /dev/null +++ b/app/settings/security/page.tsx @@ -0,0 +1,50 @@ +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth-utils"; +import { TwoFactorSetup } from "@/components/settings/two-factor-setup"; +import { ActiveSessions } from "@/components/settings/active-sessions"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; + +export default async function SecuritySettingsPage() { + const session = await getSession(); + + if (!session) { + redirect("/auth/sign-in"); + } + + return ( +
+
+

Security Settings

+

+ Manage your security preferences +

+
+ +
+ + + Two-Factor Authentication + + Add an extra layer of security to your account + + + + + + + + + + Active Sessions + + Manage your active sessions across devices + + + + + + +
+
+ ); +} diff --git a/components/admin/audit/audit-log-table.tsx b/components/admin/audit/audit-log-table.tsx new file mode 100644 index 0000000..007e9cc --- /dev/null +++ b/components/admin/audit/audit-log-table.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Loader2, Filter, Calendar } from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +interface AuditLog { + id: string; + timestamp: string; + user_id: string | null; + user_email: string | null; + user_name: string | null; + action: string; + resource: string; + resource_id: string | null; + details: Record | null; + ip_address: string | null; +} + +interface Pagination { + page: number; + limit: number; + total: number; + totalPages: number; +} + +interface Filters { + actions: string[]; + resources: string[]; +} + +const actionColors: Record = { + create: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + update: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + delete: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + sign_in: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200", + sign_out: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200", + sign_in_failed: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200", + ban: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + unban: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + role_change: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", +}; + +export function AuditLogTable() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const [logs, setLogs] = useState([]); + const [pagination, setPagination] = useState(null); + const [filters, setFilters] = useState({ actions: [], resources: [] }); + const [isLoading, setIsLoading] = useState(true); + + const [actionFilter, setActionFilter] = useState(searchParams.get("action") || "all"); + const [resourceFilter, setResourceFilter] = useState(searchParams.get("resource") || "all"); + + async function fetchLogs() { + setIsLoading(true); + try { + const params = new URLSearchParams(); + if (actionFilter && actionFilter !== "all") params.set("action", actionFilter); + if (resourceFilter && resourceFilter !== "all") params.set("resource", resourceFilter); + params.set("page", searchParams.get("page") || "1"); + + const response = await fetch(`/api/admin/audit-log?${params.toString()}`); + if (!response.ok) throw new Error("Failed to fetch audit logs"); + + const data = await response.json(); + setLogs(data.logs); + setPagination(data.pagination); + setFilters(data.filters); + } catch (error) { + console.error("Error fetching audit logs:", error); + } finally { + setIsLoading(false); + } + } + + useEffect(() => { + fetchLogs(); + }, [searchParams]); + + function handleFilterChange(type: "action" | "resource", value: string) { + const params = new URLSearchParams(searchParams.toString()); + if (value && value !== "all") { + params.set(type, value); + } else { + params.delete(type); + } + params.set("page", "1"); + + if (type === "action") setActionFilter(value); + if (type === "resource") setResourceFilter(value); + + router.push(`/admin/audit-log?${params.toString()}`); + } + + function formatAction(action: string): string { + return action.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + } + + return ( +
+ {/* Filters */} +
+ + + +
+ + {/* Table */} +
+ + + + Timestamp + User + Action + Resource + Details + IP Address + + + + {isLoading ? ( + + + + + + ) : logs.length === 0 ? ( + + + No audit logs found + + + ) : ( + logs.map((log) => ( + + + {new Date(log.timestamp).toLocaleString()} + + + {log.user_name || log.user_email || "System"} + + + + {formatAction(log.action)} + + + {log.resource} + + {log.details ? ( + + + + + +
+                            {JSON.stringify(log.details, null, 2)}
+                          
+
+
+ ) : ( + "-" + )} +
+ + {log.ip_address || "-"} + +
+ )) + )} +
+
+
+ + {/* Pagination */} + {pagination && pagination.totalPages > 1 && ( +
+

+ Showing {(pagination.page - 1) * pagination.limit + 1} to{" "} + {Math.min(pagination.page * pagination.limit, pagination.total)} of{" "} + {pagination.total} entries +

+
+ + +
+
+ )} +
+ ); +} diff --git a/components/admin/roles/permission-picker.tsx b/components/admin/roles/permission-picker.tsx new file mode 100644 index 0000000..977ed9c --- /dev/null +++ b/components/admin/roles/permission-picker.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { statement } from "@/lib/permissions"; + +interface PermissionPickerProps { + value: Record; + onChange: (permissions: Record) => void; + disabled?: boolean; +} + +const resourceLabels: Record = { + tickets: "Tickets", + configItems: "Configuration Items", + admin: "Admin Panel", + users: "User Management", + roles: "Role Management", + auditLog: "Audit Log", + settings: "Settings", +}; + +const actionLabels: Record = { + create: "Create", + read: "Read", + update: "Update", + delete: "Delete", + access: "Access", + invite: "Invite", + ban: "Ban", +}; + +export function PermissionPicker({ value, onChange, disabled }: PermissionPickerProps) { + const handleToggle = (resource: string, action: string, checked: boolean) => { + const currentActions = value[resource] || []; + let newActions: string[]; + + if (checked) { + newActions = [...currentActions, action]; + } else { + newActions = currentActions.filter((a) => a !== action); + } + + onChange({ + ...value, + [resource]: newActions, + }); + }; + + const handleToggleAll = (resource: string, checked: boolean) => { + const allActions = statement[resource as keyof typeof statement] as readonly string[]; + onChange({ + ...value, + [resource]: checked ? [...allActions] : [], + }); + }; + + return ( +
+ {Object.entries(statement).map(([resource, actions]) => { + const currentActions = value[resource] || []; + const allChecked = actions.every((a) => currentActions.includes(a)); + const someChecked = actions.some((a) => currentActions.includes(a)); + + return ( +
+
+ + handleToggleAll(resource, checked as boolean) + } + disabled={disabled} + className={someChecked && !allChecked ? "data-[state=checked]:bg-muted" : ""} + /> + +
+
+ {(actions as readonly string[]).map((action) => ( +
+ + handleToggle(resource, action, checked as boolean) + } + disabled={disabled} + /> + +
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/components/admin/roles/role-form.tsx b/components/admin/roles/role-form.tsx new file mode 100644 index 0000000..55819ea --- /dev/null +++ b/components/admin/roles/role-form.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Loader2, Save } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { PermissionPicker } from "./permission-picker"; + +const formSchema = z.object({ + name: z.string().min(1, "Name is required").regex(/^[a-z0-9-]+$/, "Name must be lowercase with hyphens only"), + description: z.string().optional(), + permissions: z.record(z.string(), z.array(z.string())), +}); + +type FormData = z.infer; + +interface Role { + id: string; + name: string; + description: string | null; + permissions: Record; + is_system: boolean; +} + +interface RoleFormProps { + role?: Role; +} + +export function RoleForm({ role }: RoleFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const isEditing = !!role; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + name: role?.name || "", + description: role?.description || "", + permissions: role?.permissions || {}, + }, + }); + + async function onSubmit(data: FormData) { + setIsLoading(true); + try { + const url = isEditing ? `/api/admin/roles/${role.id}` : "/api/admin/roles"; + const method = isEditing ? "PATCH" : "POST"; + + const response = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const result = await response.json(); + throw new Error(result.error || `Failed to ${isEditing ? "update" : "create"} role`); + } + + toast.success(`Role ${isEditing ? "updated" : "created"} successfully`); + router.push("/admin/roles"); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "An error occurred"); + } finally { + setIsLoading(false); + } + } + + return ( +
+ + ( + + Name + + + + + Lowercase letters, numbers, and hyphens only + + + + )} + /> + + ( + + Description + +