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
This commit is contained in:
parent
d8e6931b85
commit
9f912aed24
68 changed files with 7651 additions and 3 deletions
38
app/admin/audit-log/page.tsx
Normal file
38
app/admin/audit-log/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">Audit Log</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
View system activity and security events
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={<AuditLogSkeleton />}>
|
||||||
|
<AuditLogTable />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuditLogSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Skeleton className="h-10 w-[200px]" />
|
||||||
|
<Skeleton className="h-10 w-[200px]" />
|
||||||
|
<Skeleton className="h-10 w-[200px]" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{[...Array(10)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
app/admin/roles/[id]/page.tsx
Normal file
52
app/admin/roles/[id]/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-3xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CardTitle>Edit Role: {role.name}</CardTitle>
|
||||||
|
{role.is_system && <Badge variant="outline">System</Badge>}
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{role.is_system
|
||||||
|
? "System roles cannot be renamed or deleted, but permissions can be modified"
|
||||||
|
: "Update role name, description, and permissions"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RoleForm role={role} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/admin/roles/new/page.tsx
Normal file
20
app/admin/roles/new/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-3xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Role</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create a new role with custom permissions
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<RoleForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
app/admin/roles/page.tsx
Normal file
15
app/admin/roles/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { RoleTable } from "@/components/admin/roles/role-table";
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">Role Management</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Manage roles and their permissions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RoleTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
app/admin/settings/page.tsx
Normal file
173
app/admin/settings/page.tsx
Normal file
|
|
@ -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<Record<string, string>>({});
|
||||||
|
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 (
|
||||||
|
<div className="container mx-auto py-8 px-4 flex justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Configure application settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="microsoft" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="microsoft">Microsoft</TabsTrigger>
|
||||||
|
<TabsTrigger value="sessions">Sessions</TabsTrigger>
|
||||||
|
<TabsTrigger value="audit">Audit</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="microsoft">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Microsoft Entra ID</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure Microsoft 365 authentication settings
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tenant">Tenant ID</Label>
|
||||||
|
<Input
|
||||||
|
id="tenant"
|
||||||
|
value={settings.microsoft_tenant_id || ""}
|
||||||
|
onChange={(e) => updateSetting("microsoft_tenant_id", e.target.value)}
|
||||||
|
placeholder="common or your-tenant-id"
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Use "common" for multi-tenant or specify your organization's tenant ID
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sessions">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Session Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure session timeout and security policies
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="timeout">Default Session Timeout (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
id="timeout"
|
||||||
|
type="number"
|
||||||
|
value={settings.default_session_timeout || "86400"}
|
||||||
|
onChange={(e) => updateSetting("default_session_timeout", e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Default: 86400 (24 hours)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="audit">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Audit Log Settings</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure audit log retention
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="retention">Retention Period (days)</Label>
|
||||||
|
<Input
|
||||||
|
id="retention"
|
||||||
|
type="number"
|
||||||
|
value={settings.audit_log_retention_days || "90"}
|
||||||
|
onChange={(e) => updateSetting("audit_log_retention_days", e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Audit logs older than this will be automatically deleted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button onClick={handleSave} disabled={isSaving}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
app/admin/users/[id]/page.tsx
Normal file
98
app/admin/users/[id]/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-3xl font-bold">{user.name}</h1>
|
||||||
|
<RoleBadge role={user.role} />
|
||||||
|
{user.banned && <Badge variant="destructive">Banned</Badge>}
|
||||||
|
{isCurrentUser && <Badge variant="outline">You</Badge>}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-2">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="details" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="details">Details</TabsTrigger>
|
||||||
|
<TabsTrigger value="sessions">Sessions ({sessions.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="details">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>User Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update user information and role
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UserForm user={user} isCurrentUser={isCurrentUser} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="sessions">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active Sessions</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View and manage user's active sessions
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UserSessions sessions={sessions} userId={id} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/admin/users/invite/page.tsx
Normal file
20
app/admin/users/invite/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Invite User</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Send an invitation email to add a new user to the system
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<InviteUserForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
app/admin/users/page.tsx
Normal file
39
app/admin/users/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="container mx-auto py-8 px-4">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">User Management</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Manage users, roles, and permissions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={<UserTableSkeleton />}>
|
||||||
|
<UserTable />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserTableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Skeleton className="h-10 flex-1" />
|
||||||
|
<Skeleton className="h-10 w-[140px]" />
|
||||||
|
<Skeleton className="h-10 w-[140px]" />
|
||||||
|
<Skeleton className="h-10 w-[100px]" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
app/api/admin/audit-log/route.ts
Normal file
105
app/api/admin/audit-log/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/api/admin/roles/[id]/route.ts
Normal file
171
app/api/admin/roles/[id]/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/api/admin/roles/route.ts
Normal file
80
app/api/admin/roles/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/api/admin/settings/route.ts
Normal file
88
app/api/admin/settings/route.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
178
app/api/admin/users/[id]/route.ts
Normal file
178
app/api/admin/users/[id]/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/api/admin/users/[id]/sessions/[sessionId]/route.ts
Normal file
37
app/api/admin/users/[id]/sessions/[sessionId]/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/api/admin/users/[id]/sessions/route.ts
Normal file
30
app/api/admin/users/[id]/sessions/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/api/admin/users/invite/route.ts
Normal file
72
app/api/admin/users/invite/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/api/admin/users/route.ts
Normal file
129
app/api/admin/users/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
32
app/api/salesbldr/quotes/route.ts
Normal file
32
app/api/salesbldr/quotes/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/api/settings/profile/route.ts
Normal file
39
app/api/settings/profile/route.ts
Normal file
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/auth/2fa/page.tsx
Normal file
39
app/auth/2fa/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Card className="border-0 shadow-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur">
|
||||||
|
<CardHeader className="space-y-1 text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||||
|
<ShieldCheck className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold">Two-Factor Authentication</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter the 6-digit code from your authenticator app
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TwoFactorForm callbackURL={callbackURL} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TwoFactorPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<TwoFactorContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/auth/layout.tsx
Normal file
18
app/auth/layout.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
|
||||||
|
export default function AuthLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
|
||||||
|
<div className="absolute inset-0 bg-[url('/grid.svg')] bg-center [mask-image:linear-gradient(180deg,white,rgba(255,255,255,0))]" />
|
||||||
|
<div className="relative z-10 w-full max-w-md px-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<Toaster position="top-center" richColors />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
app/auth/setup/page.tsx
Normal file
99
app/auth/setup/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Card className="border-0 shadow-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur">
|
||||||
|
<CardHeader className="space-y-1 text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center">
|
||||||
|
<ShieldCheck className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold">Complete Your Setup</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your account was created by an administrator. Please link an authentication method to secure your account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
This is a one-time setup. After completing this step, you'll be able to sign in normally.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Microsoft OAuth */}
|
||||||
|
<MicrosoftButton callbackURL="/" />
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<Separator className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-white dark:bg-slate-900 px-2 text-muted-foreground">
|
||||||
|
Or use email
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Magic Link */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleSendMagicLink}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Send Magic Link to My Email"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
app/auth/sign-in/page.tsx
Normal file
23
app/auth/sign-in/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Card className="border-0 shadow-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur">
|
||||||
|
<CardHeader className="space-y-1 text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold text-white">P</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold">Welcome to Pulse</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sign in to your account to continue
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<SignInForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
app/auth/verify/page.tsx
Normal file
94
app/auth/verify/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Card className="border-0 shadow-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur">
|
||||||
|
<CardHeader className="space-y-1 text-center">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<div className="h-12 w-12 rounded-xl bg-gradient-to-br from-purple-600 to-blue-600 flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold text-white">P</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold">
|
||||||
|
{status === "loading" && "Verifying..."}
|
||||||
|
{status === "success" && "Verified!"}
|
||||||
|
{status === "error" && "Verification Failed"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{status === "loading" && "Please wait while we verify your magic link"}
|
||||||
|
{status === "success" && "You're being redirected..."}
|
||||||
|
{status === "error" && errorMessage}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-center py-8">
|
||||||
|
{status === "loading" && (
|
||||||
|
<Loader2 className="h-12 w-12 animate-spin text-purple-600" />
|
||||||
|
)}
|
||||||
|
{status === "success" && (
|
||||||
|
<div className="h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mx-auto">
|
||||||
|
<XCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => router.push("/auth/sign-in")}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VerifyPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<VerifyContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,8 @@ import {
|
||||||
XCircle,
|
XCircle,
|
||||||
Users,
|
Users,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Wifi
|
Wifi,
|
||||||
|
FileText
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
|
|
@ -44,6 +45,10 @@ interface DashboardStats {
|
||||||
unmapped: number;
|
unmapped: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
quotes: {
|
||||||
|
open: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
|
@ -53,7 +58,8 @@ export default function DashboardPage() {
|
||||||
mappings: {
|
mappings: {
|
||||||
auvik: { mapped: 0, unmapped: 0 },
|
auvik: { mapped: 0, unmapped: 0 },
|
||||||
rmm: { mapped: 0, unmapped: 0 }
|
rmm: { mapped: 0, unmapped: 0 }
|
||||||
}
|
},
|
||||||
|
quotes: { open: 0, total: 0 }
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
|
@ -75,6 +81,17 @@ export default function DashboardPage() {
|
||||||
const rmmRes = await fetch('/api/rmm/site-mappings?includeUnmapped=true');
|
const rmmRes = await fetch('/api/rmm/site-mappings?includeUnmapped=true');
|
||||||
const rmmData = await rmmRes.json();
|
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({
|
setStats({
|
||||||
companies: {
|
companies: {
|
||||||
total: companiesData.companies?.length || 0,
|
total: companiesData.companies?.length || 0,
|
||||||
|
|
@ -93,6 +110,10 @@ export default function DashboardPage() {
|
||||||
mapped: rmmData.stats?.mapped || 0,
|
mapped: rmmData.stats?.mapped || 0,
|
||||||
unmapped: rmmData.stats?.unmapped || 0
|
unmapped: rmmData.stats?.unmapped || 0
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
quotes: {
|
||||||
|
open: quotesData.results?.length || 0,
|
||||||
|
total: quotesData.total || 0
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -185,7 +206,7 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Total Companies</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Companies</CardTitle>
|
||||||
|
|
@ -233,6 +254,21 @@ export default function DashboardPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Link href="/quotes">
|
||||||
|
<Card className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Open Quotes</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.quotes.open}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pending approval
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">System Status</CardTitle>
|
<CardTitle className="text-sm font-medium">System Status</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { AppNavigation } from "@/components/navigation/app-navigation";
|
import { AppNavigation } from "@/components/navigation/app-navigation";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
|
// TEMPORARY: AuthProvider disabled for private site access
|
||||||
|
// import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
const inter = Inter({ subsets: ["latin"] });
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
|
@ -26,6 +28,7 @@ export default function RootLayout({
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
|
{/* TEMPORARY: AuthProvider removed - TODO: Re-enable when site has public access */}
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<AppNavigation />
|
<AppNavigation />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
||||||
|
|
|
||||||
310
app/quotes/page.tsx
Normal file
310
app/quotes/page.tsx
Normal file
|
|
@ -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<Quote[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedTicketNumber, setSelectedTicketNumber] = useState<string | null>(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<string, { variant: "default" | "secondary" | "destructive" | "outline", label: string }> = {
|
||||||
|
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 <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <div className="max-w-md truncate">{title}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split the title into parts before and after the ticket number
|
||||||
|
const parts = title.split(ticketInfo.ticketNumber);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md flex items-center gap-1 flex-wrap">
|
||||||
|
<span>{parts[0]}</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-950 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleTicketClick(ticketInfo.ticketNumber);
|
||||||
|
}}
|
||||||
|
title={`View ticket ${ticketInfo.ticketNumber} in Autotask`}
|
||||||
|
>
|
||||||
|
<Ticket className="h-3 w-3 mr-1" />
|
||||||
|
{ticketInfo.ticketNumber}
|
||||||
|
</Badge>
|
||||||
|
<span>{parts[1]}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-8 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl font-bold flex items-center gap-3">
|
||||||
|
<FileText className="h-8 w-8" />
|
||||||
|
Open Quotes
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
View and manage quotes from SalesBldr
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={fetchQuotes} variant="outline" disabled={loading}>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Total Open Quotes</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{quotes.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Sent Quotes</CardTitle>
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{quotes.filter(q => q.status === 'sent').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Draft Quotes</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{quotes.filter(q => q.status === 'draft').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quotes Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Quotes List</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
All open quotes from SalesBldr
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8 text-destructive">
|
||||||
|
<p>Error: {error}</p>
|
||||||
|
<Button onClick={fetchQuotes} variant="outline" className="mt-4">
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : quotes.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||||
|
<p>No open quotes found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Quote #</TableHead>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Company</TableHead>
|
||||||
|
<TableHead>Owner</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead className="text-right">Total</TableHead>
|
||||||
|
<TableHead></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{quotes.map((quote) => (
|
||||||
|
<TableRow key={quote.id}>
|
||||||
|
<TableCell className="font-medium">{quote.number}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{renderTitleWithTicketLink(quote.title)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{quote.company?.name || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{quote.owner?.name || 'N/A'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(quote.status)}</TableCell>
|
||||||
|
<TableCell>{formatDate(quote.sentAt || quote.createdAt)}</TableCell>
|
||||||
|
<TableCell className="text-right font-medium">
|
||||||
|
{formatCurrency(calculateTotal(quote))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{quote.link && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href={quote.link} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Ticket Detail Modal */}
|
||||||
|
{selectedTicketNumber && (
|
||||||
|
<TicketDetailModal
|
||||||
|
ticketNumber={selectedTicketNumber}
|
||||||
|
open={ticketModalOpen}
|
||||||
|
onOpenChange={setTicketModalOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
app/settings/page.tsx
Normal file
35
app/settings/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">Settings</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Manage your account settings
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profile</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Update your personal information
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProfileForm user={session.user} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
app/settings/security/page.tsx
Normal file
50
app/settings/security/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="container mx-auto py-8 px-4 max-w-2xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">Security Settings</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">
|
||||||
|
Manage your security preferences
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Two-Factor Authentication</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Add an extra layer of security to your account
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TwoFactorSetup />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Active Sessions</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage your active sessions across devices
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ActiveSessions userId={session.user.id} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
components/admin/audit/audit-log-table.tsx
Normal file
265
components/admin/audit/audit-log-table.tsx
Normal file
|
|
@ -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<string, unknown> | null;
|
||||||
|
ip_address: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pagination {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
actions: string[];
|
||||||
|
resources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
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<AuditLog[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<Pagination | null>(null);
|
||||||
|
const [filters, setFilters] = useState<Filters>({ 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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Select value={actionFilter} onValueChange={(v) => handleFilterChange("action", v)}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Actions</SelectItem>
|
||||||
|
{filters.actions.map((action) => (
|
||||||
|
<SelectItem key={action} value={action}>
|
||||||
|
{formatAction(action)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select value={resourceFilter} onValueChange={(v) => handleFilterChange("resource", v)}>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Filter by resource" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Resources</SelectItem>
|
||||||
|
{filters.resources.map((resource) => (
|
||||||
|
<SelectItem key={resource} value={resource}>
|
||||||
|
{resource.charAt(0).toUpperCase() + resource.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Timestamp</TableHead>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Action</TableHead>
|
||||||
|
<TableHead>Resource</TableHead>
|
||||||
|
<TableHead>Details</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||||
|
No audit logs found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<TableRow key={log.id}>
|
||||||
|
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{log.user_name || log.user_email || "System"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={actionColors[log.action] || "bg-gray-100 text-gray-800"}>
|
||||||
|
{formatAction(log.action)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="capitalize">{log.resource}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">
|
||||||
|
{log.details ? (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-auto p-1">
|
||||||
|
<code className="text-xs">
|
||||||
|
{JSON.stringify(log.details).slice(0, 50)}...
|
||||||
|
</code>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80">
|
||||||
|
<pre className="text-xs overflow-auto max-h-60">
|
||||||
|
{JSON.stringify(log.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{log.ip_address || "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.limit + 1} to{" "}
|
||||||
|
{Math.min(pagination.page * pagination.limit, pagination.total)} of{" "}
|
||||||
|
{pagination.total} entries
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("page", String(pagination.page - 1));
|
||||||
|
router.push(`/admin/audit-log?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page === pagination.totalPages}
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("page", String(pagination.page + 1));
|
||||||
|
router.push(`/admin/audit-log?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
components/admin/roles/permission-picker.tsx
Normal file
109
components/admin/roles/permission-picker.tsx
Normal file
|
|
@ -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<string, string[]>;
|
||||||
|
onChange: (permissions: Record<string, string[]>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceLabels: Record<string, string> = {
|
||||||
|
tickets: "Tickets",
|
||||||
|
configItems: "Configuration Items",
|
||||||
|
admin: "Admin Panel",
|
||||||
|
users: "User Management",
|
||||||
|
roles: "Role Management",
|
||||||
|
auditLog: "Audit Log",
|
||||||
|
settings: "Settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{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 (
|
||||||
|
<div key={resource} className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${resource}-all`}
|
||||||
|
checked={allChecked}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggleAll(resource, checked as boolean)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
className={someChecked && !allChecked ? "data-[state=checked]:bg-muted" : ""}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${resource}-all`}
|
||||||
|
className="font-semibold cursor-pointer"
|
||||||
|
>
|
||||||
|
{resourceLabels[resource] || resource}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="ml-6 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||||
|
{(actions as readonly string[]).map((action) => (
|
||||||
|
<div key={action} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`${resource}-${action}`}
|
||||||
|
checked={currentActions.includes(action)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggle(resource, action, checked as boolean)
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`${resource}-${action}`}
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{actionLabels[action] || action}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
components/admin/roles/role-form.tsx
Normal file
176
components/admin/roles/role-form.tsx
Normal file
|
|
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
permissions: Record<string, string[]>;
|
||||||
|
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<FormData>({
|
||||||
|
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 (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="custom-role"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading || role?.is_system}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Lowercase letters, numbers, and hyphens only
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe what this role is for..."
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="permissions"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Permissions</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="border rounded-lg p-4">
|
||||||
|
<PermissionPicker
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Select the permissions this role should have
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{isEditing ? "Saving..." : "Creating..."}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{isEditing ? "Save Changes" : "Create Role"}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
components/admin/roles/role-table.tsx
Normal file
208
components/admin/roles/role-table.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Plus, Loader2, Shield, Pencil, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
permissions: Record<string, string[]>;
|
||||||
|
is_system: boolean;
|
||||||
|
user_count: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleTable() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [deleteRoleId, setDeleteRoleId] = useState<string | null>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
async function fetchRoles() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/roles");
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch roles");
|
||||||
|
const data = await response.json();
|
||||||
|
setRoles(data.roles);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching roles:", error);
|
||||||
|
toast.error("Failed to load roles");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteRoleId) return;
|
||||||
|
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/roles/${deleteRoleId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to delete role");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Role deleted");
|
||||||
|
fetchRoles();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
setDeleteRoleId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPermissions(permissions: Record<string, string[]>): number {
|
||||||
|
return Object.values(permissions).reduce(
|
||||||
|
(total, actions) => total + actions.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => router.push("/admin/roles/new")}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Role
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead>Permissions</TableHead>
|
||||||
|
<TableHead>Users</TableHead>
|
||||||
|
<TableHead className="w-[100px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : roles.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
No roles found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
roles.map((role) => (
|
||||||
|
<TableRow key={role.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{role.name}</span>
|
||||||
|
{role.is_system && (
|
||||||
|
<Badge variant="outline" className="text-xs">System</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{role.description || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{countPermissions(role.permissions)} permissions
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{role.user_count} users</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push(`/admin/roles/${role.id}`)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{!role.is_system && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteRoleId(role.id)}
|
||||||
|
disabled={parseInt(role.user_count) > 0}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteRoleId} onOpenChange={() => setDeleteRoleId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Role</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this role? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Delete"
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
components/admin/users/invite-user-form.tsx
Normal file
174
components/admin/users/invite-user-form.tsx
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
"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, Send } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
name: z.string().optional(),
|
||||||
|
role: z.enum(["admin", "user"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export function InviteUserForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
name: "",
|
||||||
|
role: "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/admin/users/invite", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
throw new Error(result.error || "Failed to send invitation");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Invitation sent successfully");
|
||||||
|
router.push("/admin/users");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email Address</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
An invitation email will be sent to this address
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="John Doe"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
If not provided, the email prefix will be used
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
The role determines what the user can access
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Send Invitation
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
components/admin/users/role-badge.tsx
Normal file
41
components/admin/users/role-badge.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Shield, ShieldCheck, User } from "lucide-react";
|
||||||
|
|
||||||
|
interface RoleBadgeProps {
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline"; icon: React.ReactNode }> = {
|
||||||
|
"super-admin": {
|
||||||
|
label: "Super Admin",
|
||||||
|
variant: "destructive",
|
||||||
|
icon: <ShieldCheck className="h-3 w-3 mr-1" />,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
label: "Admin",
|
||||||
|
variant: "default",
|
||||||
|
icon: <Shield className="h-3 w-3 mr-1" />,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
label: "User",
|
||||||
|
variant: "secondary",
|
||||||
|
icon: <User className="h-3 w-3 mr-1" />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleBadge({ role }: RoleBadgeProps) {
|
||||||
|
const config = roleConfig[role] || {
|
||||||
|
label: role,
|
||||||
|
variant: "outline" as const,
|
||||||
|
icon: <User className="h-3 w-3 mr-1" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant={config.variant} className="flex items-center w-fit">
|
||||||
|
{config.icon}
|
||||||
|
{config.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
components/admin/users/user-actions.tsx
Normal file
213
components/admin/users/user-actions.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { MoreHorizontal, Pencil, Ban, Trash2, Key, UserX, UserCheck } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
banned: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserActionsProps {
|
||||||
|
user: User;
|
||||||
|
currentUserId?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserActions({ user, currentUserId, onRefresh }: UserActionsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [showBanDialog, setShowBanDialog] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const isCurrentUser = user.id === currentUserId;
|
||||||
|
|
||||||
|
async function handleBanToggle() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${user.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ banned: !user.banned }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to update user");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(user.banned ? "User unbanned" : "User banned");
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setShowBanDialog(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${user.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to delete user");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("User deleted");
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeSessions() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${user.id}/sessions`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to revoke sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("All sessions revoked");
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => router.push(`/admin/users/${user.id}`)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleRevokeSessions} disabled={isLoading}>
|
||||||
|
<Key className="mr-2 h-4 w-4" />
|
||||||
|
Revoke Sessions
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{!isCurrentUser && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setShowBanDialog(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{user.banned ? (
|
||||||
|
<>
|
||||||
|
<UserCheck className="mr-2 h-4 w-4" />
|
||||||
|
Unban User
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ban className="mr-2 h-4 w-4" />
|
||||||
|
Ban User
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setShowDeleteDialog(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Ban Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showBanDialog} onOpenChange={setShowBanDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{user.banned ? "Unban User" : "Ban User"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{user.banned
|
||||||
|
? `Are you sure you want to unban ${user.name}? They will be able to sign in again.`
|
||||||
|
: `Are you sure you want to ban ${user.name}? They will be signed out and unable to sign in.`}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleBanToggle} disabled={isLoading}>
|
||||||
|
{user.banned ? "Unban" : "Ban"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete User</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to permanently delete {user.name}? This action
|
||||||
|
cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
components/admin/users/user-form.tsx
Normal file
180
components/admin/users/user-form.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"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 {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
role: z.enum(["super-admin", "admin", "user"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserFormProps {
|
||||||
|
user?: User;
|
||||||
|
isCurrentUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserForm({ user, isCurrentUser }: UserFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: user?.name || "",
|
||||||
|
email: user?.email || "",
|
||||||
|
role: (user?.role as "super-admin" | "admin" | "user") || "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${user?.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
throw new Error(result.error || "Failed to update user");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("User updated successfully");
|
||||||
|
router.push("/admin/users");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="John Doe" {...field} disabled={isLoading} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="john@example.com"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
disabled={isLoading || isCurrentUser}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="super-admin">Super Admin</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{isCurrentUser && (
|
||||||
|
<FormDescription>
|
||||||
|
You cannot change your own role
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
components/admin/users/user-sessions.tsx
Normal file
216
components/admin/users/user-sessions.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Loader2, Trash2, Monitor, Smartphone, Globe } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
ip_address: string;
|
||||||
|
user_agent: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSessionsProps {
|
||||||
|
sessions: Session[];
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUserAgent(ua: string): { device: string; browser: string } {
|
||||||
|
const isMobile = /mobile|android|iphone|ipad/i.test(ua);
|
||||||
|
const device = isMobile ? "Mobile" : "Desktop";
|
||||||
|
|
||||||
|
let browser = "Unknown";
|
||||||
|
if (ua.includes("Chrome")) browser = "Chrome";
|
||||||
|
else if (ua.includes("Firefox")) browser = "Firefox";
|
||||||
|
else if (ua.includes("Safari")) browser = "Safari";
|
||||||
|
else if (ua.includes("Edge")) browser = "Edge";
|
||||||
|
|
||||||
|
return { device, browser };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSessions({ sessions, userId }: UserSessionsProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showRevokeAllDialog, setShowRevokeAllDialog] = useState(false);
|
||||||
|
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleRevokeSession(sessionId: string) {
|
||||||
|
setRevokingSessionId(sessionId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${userId}/sessions/${sessionId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to revoke session");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Session revoked");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setRevokingSessionId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeAllSessions() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${userId}/sessions`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "Failed to revoke sessions");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("All sessions revoked");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setShowRevokeAllDialog(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No active sessions
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowRevokeAllDialog(true)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Revoke All Sessions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Device</TableHead>
|
||||||
|
<TableHead>IP Address</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead className="w-[100px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const { device, browser } = parseUserAgent(session.user_agent || "");
|
||||||
|
const isExpired = new Date(session.expires_at) < new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={session.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{device === "Mobile" ? (
|
||||||
|
<Smartphone className="h-4 w-4 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Monitor className="h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span>{browser}</span>
|
||||||
|
{isExpired && (
|
||||||
|
<Badge variant="outline" className="text-xs">Expired</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4 text-muted-foreground" />
|
||||||
|
{session.ip_address || "Unknown"}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(session.created_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(session.expires_at).toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRevokeSession(session.id)}
|
||||||
|
disabled={revokingSessionId === session.id}
|
||||||
|
>
|
||||||
|
{revokingSessionId === session.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<AlertDialog open={showRevokeAllDialog} onOpenChange={setShowRevokeAllDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Revoke All Sessions</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to revoke all sessions for this user? They will
|
||||||
|
be signed out from all devices.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleRevokeAllSessions}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Revoking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Revoke All"
|
||||||
|
)}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
components/admin/users/user-table.tsx
Normal file
259
components/admin/users/user-table.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Search, UserPlus, Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { RoleBadge } from "./role-badge";
|
||||||
|
import { UserActions } from "./user-actions";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
email_verified: boolean;
|
||||||
|
role: string;
|
||||||
|
banned: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Pagination {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserTable() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [pagination, setPagination] = useState<Pagination | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState(searchParams.get("search") || "");
|
||||||
|
const [roleFilter, setRoleFilter] = useState(searchParams.get("role") || "all");
|
||||||
|
const [statusFilter, setStatusFilter] = useState(searchParams.get("status") || "all");
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
if (roleFilter && roleFilter !== "all") params.set("role", roleFilter);
|
||||||
|
if (statusFilter && statusFilter !== "all") params.set("status", statusFilter);
|
||||||
|
params.set("page", searchParams.get("page") || "1");
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/users?${params.toString()}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch users");
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data.users);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
function handleSearch(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (search) {
|
||||||
|
params.set("search", search);
|
||||||
|
} else {
|
||||||
|
params.delete("search");
|
||||||
|
}
|
||||||
|
params.set("page", "1");
|
||||||
|
router.push(`/admin/users?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterChange(type: "role" | "status", 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 === "role") setRoleFilter(value);
|
||||||
|
if (type === "status") setStatusFilter(value);
|
||||||
|
|
||||||
|
router.push(`/admin/users?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<form onSubmit={handleSearch} className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={roleFilter} onValueChange={(v) => handleFilterChange("role", v)}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Roles</SelectItem>
|
||||||
|
<SelectItem value="super-admin">Super Admin</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="user">User</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={statusFilter} onValueChange={(v) => handleFilterChange("status", v)}>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="banned">Banned</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => router.push("/admin/users/invite")}>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[70px]"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||||
|
No users found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user.email}
|
||||||
|
{user.email_verified && (
|
||||||
|
<Badge variant="outline" className="text-xs">Verified</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<RoleBadge role={user.role} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.banned ? (
|
||||||
|
<Badge variant="destructive">Banned</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{new Date(user.created_at).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<UserActions
|
||||||
|
user={user}
|
||||||
|
currentUserId={session?.user?.id}
|
||||||
|
onRefresh={fetchUsers}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Showing {(pagination.page - 1) * pagination.limit + 1} to{" "}
|
||||||
|
{Math.min(pagination.page * pagination.limit, pagination.total)} of{" "}
|
||||||
|
{pagination.total} users
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page === 1}
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("page", String(pagination.page - 1));
|
||||||
|
router.push(`/admin/users?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={pagination.page === pagination.totalPages}
|
||||||
|
onClick={() => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
params.set("page", String(pagination.page + 1));
|
||||||
|
router.push(`/admin/users?${params.toString()}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
components/auth/auth-provider.tsx
Normal file
43
components/auth/auth-provider.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, ReactNode } from "react";
|
||||||
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
type AuthContextType = {
|
||||||
|
session: ReturnType<typeof useSession>["data"];
|
||||||
|
isPending: boolean;
|
||||||
|
error: ReturnType<typeof useSession>["error"];
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { data: session, isPending, error } = useSession();
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
await authClient.signOut();
|
||||||
|
window.location.href = "/auth/sign-in";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
session,
|
||||||
|
isPending,
|
||||||
|
error,
|
||||||
|
signOut: handleSignOut,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within an AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
134
components/auth/magic-link-form.tsx
Normal file
134
components/auth/magic-link-form.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Mail, Loader2, CheckCircle } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
email: z.string().email("Please enter a valid email address"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
export function MagicLinkForm() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
|
const [sentEmail, setSentEmail] = useState("");
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.signIn.magicLink({
|
||||||
|
email: data.email,
|
||||||
|
callbackURL: "/",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error.message || "Failed to send magic link");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEmailSent(true);
|
||||||
|
setSentEmail(data.email);
|
||||||
|
toast.success("Magic link sent! Check your email.");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("An unexpected error occurred");
|
||||||
|
console.error("Magic link error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailSent) {
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-4 py-4">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold">Check your email</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We sent a magic link to <strong>{sentEmail}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Click the link in the email to sign in. The link expires in 5 minutes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEmailSent(false);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="pl-10"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Sending magic link...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
Send magic link
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
components/auth/microsoft-button.tsx
Normal file
74
components/auth/microsoft-button.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
// Microsoft logo SVG component
|
||||||
|
function MicrosoftLogo({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 21 21"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect x="1" y="1" width="9" height="9" fill="#F25022" />
|
||||||
|
<rect x="11" y="1" width="9" height="9" fill="#7FBA00" />
|
||||||
|
<rect x="1" y="11" width="9" height="9" fill="#00A4EF" />
|
||||||
|
<rect x="11" y="11" width="9" height="9" fill="#FFB900" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MicrosoftButtonProps {
|
||||||
|
callbackURL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MicrosoftButton({ callbackURL = "/" }: MicrosoftButtonProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleMicrosoftSignIn() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.signIn.social({
|
||||||
|
provider: "microsoft",
|
||||||
|
callbackURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error.message || "Failed to sign in with Microsoft");
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
// If successful, the user will be redirected to Microsoft's OAuth page
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("An unexpected error occurred");
|
||||||
|
console.error("Microsoft sign-in error:", error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleMicrosoftSignIn}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MicrosoftLogo className="mr-2 h-4 w-4" />
|
||||||
|
Continue with Microsoft 365
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
components/auth/sign-in-form.tsx
Normal file
29
components/auth/sign-in-form.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { MagicLinkForm } from "./magic-link-form";
|
||||||
|
import { MicrosoftButton } from "./microsoft-button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
export function SignInForm() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Microsoft OAuth */}
|
||||||
|
<MicrosoftButton />
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<Separator className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-white dark:bg-slate-900 px-2 text-muted-foreground">
|
||||||
|
Or continue with email
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Magic Link */}
|
||||||
|
<MagicLinkForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
components/auth/two-factor-form.tsx
Normal file
132
components/auth/two-factor-form.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Loader2, ShieldCheck } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
code: z.string().length(6, "Code must be 6 digits").regex(/^\d+$/, "Code must be numeric"),
|
||||||
|
trustDevice: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface TwoFactorFormProps {
|
||||||
|
callbackURL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TwoFactorForm({ callbackURL = "/" }: TwoFactorFormProps) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
code: "",
|
||||||
|
trustDevice: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.twoFactor.verifyTotp({
|
||||||
|
code: data.code,
|
||||||
|
trustDevice: data.trustDevice,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error.message || "Invalid verification code");
|
||||||
|
form.setError("code", { message: "Invalid code" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Verified successfully!");
|
||||||
|
router.push(callbackURL);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("An unexpected error occurred");
|
||||||
|
console.error("2FA verification error:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Verification Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="000000"
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-2xl tracking-widest font-mono"
|
||||||
|
{...field}
|
||||||
|
disabled={isLoading}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="trustDevice"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel className="text-sm font-normal">
|
||||||
|
Trust this device for 30 days
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||||
|
Verify
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
components/quotes/ticket-detail-modal.tsx
Normal file
267
components/quotes/ticket-detail-modal.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Ticket as TicketIcon,
|
||||||
|
Building2,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface TicketDetailModalProps {
|
||||||
|
ticketNumber: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TicketDetailModal({ ticketNumber, open, onOpenChange }: TicketDetailModalProps) {
|
||||||
|
const [ticket, setTicket] = useState<any>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchTicketDetails = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setTicket(null);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tickets');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const matchingTicket = data.tickets?.find((t: any) => t.ticketNumber === ticketNumber);
|
||||||
|
|
||||||
|
if (matchingTicket) {
|
||||||
|
const detailResponse = await fetch(`/api/tickets/${matchingTicket.id}`);
|
||||||
|
if (detailResponse.ok) {
|
||||||
|
const detailData = await detailResponse.json();
|
||||||
|
setTicket(detailData.ticket);
|
||||||
|
} else {
|
||||||
|
setError('Failed to fetch ticket details');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(`Ticket ${ticketNumber} not found`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('Failed to fetch tickets list');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch ticket');
|
||||||
|
console.error('Error fetching ticket:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch ticket when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && ticketNumber) {
|
||||||
|
fetchTicketDetails();
|
||||||
|
}
|
||||||
|
}, [open, ticketNumber]);
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpen: boolean) => {
|
||||||
|
onOpenChange(newOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: number) => {
|
||||||
|
const statusMap: Record<number, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||||
|
1: { label: "New", variant: "default" },
|
||||||
|
5: { label: "Complete", variant: "secondary" },
|
||||||
|
8: { label: "In Progress", variant: "default" },
|
||||||
|
};
|
||||||
|
const config = statusMap[status] || { label: `Status ${status}`, variant: "outline" };
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityBadge = (priority: number) => {
|
||||||
|
const priorityMap: Record<number, { label: string; variant: "default" | "secondary" | "destructive" }> = {
|
||||||
|
1: { label: "Critical", variant: "destructive" },
|
||||||
|
2: { label: "High", variant: "destructive" },
|
||||||
|
3: { label: "Medium", variant: "default" },
|
||||||
|
4: { label: "Low", variant: "secondary" },
|
||||||
|
};
|
||||||
|
const config = priorityMap[priority] || { label: `Priority ${priority}`, variant: "outline" as any };
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString?: string) => {
|
||||||
|
if (!dateString) return 'N/A';
|
||||||
|
return new Date(dateString).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<TicketIcon className="h-5 w-5" />
|
||||||
|
Ticket Details
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Viewing ticket information from Autotask PSA
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 p-4 bg-destructive/10 text-destructive rounded-lg">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ticket && !loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header Info */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Ticket Number</p>
|
||||||
|
<p className="font-mono font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
{ticket.ticketNumber}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Status</p>
|
||||||
|
<div className="mt-1">{getStatusBadge(ticket.status)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{ticket.title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Details */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Assigned To
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{ticket.assignedResourceName || 'Unassigned'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Priority</p>
|
||||||
|
<div className="mt-1">{getPriorityBadge(ticket.priority)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Created Date
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{formatDate(ticket.createDate)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
Due Date
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{formatDate(ticket.dueDateTime)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ticket.resolvedDateTime && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
Resolved Date
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">{formatDate(ticket.resolvedDateTime)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ticket.purchaseOrderNumber && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">PO Number</p>
|
||||||
|
<p className="font-medium">{ticket.purchaseOrderNumber}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{ticket.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold mb-2">Description</p>
|
||||||
|
<div className="p-3 bg-muted rounded-lg whitespace-pre-wrap text-sm">
|
||||||
|
{ticket.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolution */}
|
||||||
|
{ticket.resolution && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold mb-2">Resolution</p>
|
||||||
|
<div className="p-3 bg-muted rounded-lg whitespace-pre-wrap text-sm">
|
||||||
|
{ticket.resolution}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quote Number */}
|
||||||
|
{ticket.userDefinedFields && (
|
||||||
|
<>
|
||||||
|
{ticket.userDefinedFields.find((f: any) => f.name === 'Quote Number' && f.value) && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Quote Number</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{ticket.userDefinedFields.find((f: any) => f.name === 'Quote Number')?.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`https://wam.autotask.net/Mvc/ServiceDesk/TicketDetail.mvc?ticketId=${ticket.id}`, '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4 mr-2" />
|
||||||
|
Open in Autotask
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
components/settings/active-sessions.tsx
Normal file
139
components/settings/active-sessions.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Loader2, Monitor, Smartphone, Trash2, Globe } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
id: string;
|
||||||
|
ip_address: string;
|
||||||
|
user_agent: string;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveSessionsProps {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUserAgent(ua: string): { device: string; browser: string } {
|
||||||
|
const isMobile = /mobile|android|iphone|ipad/i.test(ua);
|
||||||
|
const device = isMobile ? "Mobile" : "Desktop";
|
||||||
|
|
||||||
|
let browser = "Unknown";
|
||||||
|
if (ua.includes("Chrome")) browser = "Chrome";
|
||||||
|
else if (ua.includes("Firefox")) browser = "Firefox";
|
||||||
|
else if (ua.includes("Safari")) browser = "Safari";
|
||||||
|
else if (ua.includes("Edge")) browser = "Edge";
|
||||||
|
|
||||||
|
return { device, browser };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveSessions({ userId }: ActiveSessionsProps) {
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function fetchSessions() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${userId}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch sessions");
|
||||||
|
const data = await response.json();
|
||||||
|
setSessions(data.sessions || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching sessions:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSessions();
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
async function handleRevoke(sessionId: string) {
|
||||||
|
setRevokingId(sessionId);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/users/${userId}/sessions/${sessionId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error("Failed to revoke session");
|
||||||
|
|
||||||
|
toast.success("Session revoked");
|
||||||
|
fetchSessions();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to revoke session");
|
||||||
|
} finally {
|
||||||
|
setRevokingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-center py-8 text-muted-foreground">
|
||||||
|
No active sessions
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const { device, browser } = parseUserAgent(session.user_agent || "");
|
||||||
|
const isExpired = new Date(session.expires_at) < new Date();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
{device === "Mobile" ? (
|
||||||
|
<Smartphone className="h-5 w-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Monitor className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{browser} on {device}</span>
|
||||||
|
{isExpired && <Badge variant="outline">Expired</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
{session.ip_address || "Unknown IP"}
|
||||||
|
<span>•</span>
|
||||||
|
{new Date(session.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRevoke(session.id)}
|
||||||
|
disabled={revokingId === session.id}
|
||||||
|
>
|
||||||
|
{revokingId === session.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
components/settings/profile-form.tsx
Normal file
112
components/settings/profile-form.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"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 {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileFormProps {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileForm({ user }: ProfileFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: user.name || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit(data: FormData) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/settings/profile", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
throw new Error(result.error || "Failed to update profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Profile updated successfully");
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Email</label>
|
||||||
|
<Input value={user.email} disabled className="bg-muted" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Email cannot be changed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Your name" {...field} disabled={isLoading} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
components/settings/two-factor-setup.tsx
Normal file
270
components/settings/two-factor-setup.tsx
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2, ShieldCheck, ShieldOff } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { authClient, useSession } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
export function TwoFactorSetup() {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showEnableDialog, setShowEnableDialog] = useState(false);
|
||||||
|
const [showDisableDialog, setShowDisableDialog] = useState(false);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [totpUri, setTotpUri] = useState("");
|
||||||
|
const [verificationCode, setVerificationCode] = useState("");
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
|
const [step, setStep] = useState<"password" | "qr" | "verify" | "backup">("password");
|
||||||
|
|
||||||
|
const is2FAEnabled = (session?.user as { twoFactorEnabled?: boolean })?.twoFactorEnabled;
|
||||||
|
|
||||||
|
async function handleEnable() {
|
||||||
|
if (step === "password") {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.twoFactor.enable({ password });
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error.message || "Failed to enable 2FA");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTotpUri(result.data?.totpURI || "");
|
||||||
|
setBackupCodes(result.data?.backupCodes || []);
|
||||||
|
setStep("qr");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} else if (step === "verify") {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.twoFactor.verifyTotp({
|
||||||
|
code: verificationCode,
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error.message || "Invalid code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStep("backup");
|
||||||
|
toast.success("Two-factor authentication enabled!");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDisable() {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await authClient.twoFactor.disable({ password });
|
||||||
|
if (result.error) {
|
||||||
|
toast.error(result.error.message || "Failed to disable 2FA");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("Two-factor authentication disabled");
|
||||||
|
setShowDisableDialog(false);
|
||||||
|
setPassword("");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("An error occurred");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDialog() {
|
||||||
|
setStep("password");
|
||||||
|
setPassword("");
|
||||||
|
setTotpUri("");
|
||||||
|
setVerificationCode("");
|
||||||
|
setBackupCodes([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{is2FAEnabled ? (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Two-factor authentication is enabled</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your account is protected with an authenticator app
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => setShowDisableDialog(true)}>
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
|
||||||
|
<ShieldOff className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Two-factor authentication is disabled</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Add an extra layer of security to your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowEnableDialog(true)}>Enable</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enable 2FA Dialog */}
|
||||||
|
<Dialog open={showEnableDialog} onOpenChange={(open) => {
|
||||||
|
setShowEnableDialog(open);
|
||||||
|
if (!open) resetDialog();
|
||||||
|
}}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{step === "password" && "Enable Two-Factor Authentication"}
|
||||||
|
{step === "qr" && "Scan QR Code"}
|
||||||
|
{step === "verify" && "Verify Code"}
|
||||||
|
{step === "backup" && "Save Backup Codes"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{step === "password" && "Enter your password to continue"}
|
||||||
|
{step === "qr" && "Scan this QR code with your authenticator app"}
|
||||||
|
{step === "verify" && "Enter the 6-digit code from your authenticator app"}
|
||||||
|
{step === "backup" && "Save these backup codes in a safe place"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{step === "password" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "qr" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-center p-4 bg-white rounded-lg">
|
||||||
|
{/* QR Code would be rendered here - using a placeholder */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Scan with your authenticator app or enter manually:
|
||||||
|
</p>
|
||||||
|
<code className="text-xs break-all">{totpUri}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button className="w-full" onClick={() => setStep("verify")}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "verify" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="code">Verification Code</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => setVerificationCode(e.target.value)}
|
||||||
|
placeholder="000000"
|
||||||
|
maxLength={6}
|
||||||
|
className="text-center text-2xl tracking-widest"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "backup" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-muted p-4 rounded-lg">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{backupCodes.map((code, i) => (
|
||||||
|
<code key={i} className="text-sm font-mono">
|
||||||
|
{code}
|
||||||
|
</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Each code can only be used once. Store them securely.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{step === "backup" ? (
|
||||||
|
<Button onClick={() => {
|
||||||
|
setShowEnableDialog(false);
|
||||||
|
resetDialog();
|
||||||
|
}}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
) : step !== "qr" && (
|
||||||
|
<Button onClick={handleEnable} disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{step === "password" ? "Continue" : "Verify"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Disable 2FA Dialog */}
|
||||||
|
<Dialog open={showDisableDialog} onOpenChange={setShowDisableDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter your password to disable two-factor authentication
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="disable-password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="disable-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowDisableDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDisable} disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
270
dev/CVE-2025-55182-React2Shell-Review.md
Normal file
270
dev/CVE-2025-55182-React2Shell-Review.md
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
# CVE-2025-55182: React Server Components RCE (React2Shell)
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**CVE ID:** CVE-2025-55182
|
||||||
|
**Also Known As:** React2Shell
|
||||||
|
**CVSS Score:** 10.0 (Critical)
|
||||||
|
**Disclosure Date:** December 3, 2025
|
||||||
|
**CISA KEV Added:** December 5, 2025
|
||||||
|
**Active Exploitation:** Confirmed
|
||||||
|
|
||||||
|
A pre-authentication remote code execution vulnerability exists in React Server Components that allows unauthenticated attackers to execute arbitrary code on the server via insecure deserialization of malicious HTTP requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Affected Versions
|
||||||
|
|
||||||
|
### React Server Component Packages (Direct)
|
||||||
|
|
||||||
|
| Package | Vulnerable Versions | Patched Versions |
|
||||||
|
|---------|---------------------|------------------|
|
||||||
|
| react-server-dom-parcel | 19.0.0, 19.1.0, 19.1.1, 19.2.0 | 19.0.1, 19.1.2, 19.2.1+ |
|
||||||
|
| react-server-dom-turbopack | 19.0.0, 19.1.0, 19.1.1, 19.2.0 | 19.0.1, 19.1.2, 19.2.1+ |
|
||||||
|
| react-server-dom-webpack | 19.0.0, 19.1.0, 19.1.1, 19.2.0 | 19.0.1, 19.1.2, 19.2.1+ |
|
||||||
|
|
||||||
|
### Frameworks & Bundlers (Indirect)
|
||||||
|
|
||||||
|
| Framework/Bundler | Vulnerable Versions | Notes |
|
||||||
|
|-------------------|---------------------|-------|
|
||||||
|
| Next.js | 14.3.0-canary, 15.x, 16.x (App Router) | Upgrade to 14.2.35+ or latest stable |
|
||||||
|
| React Router | RSC mode versions | Upgrade react-server-dom-* packages |
|
||||||
|
| Waku | Versions using RSC | Check dependencies |
|
||||||
|
| @parcel/rsc | RSC implementations | Check dependencies |
|
||||||
|
| @vite/rsc-plugin | RSC implementations | Check dependencies |
|
||||||
|
| rwsdk (RedwoodSDK) | RSC implementations | Check dependencies |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Vulnerability Mechanism
|
||||||
|
|
||||||
|
The vulnerability resides in the RSC Flight protocol implementation. The server processes RSC payloads unsafely, allowing attacker-controlled data to influence server-side execution logic through deserialization.
|
||||||
|
|
||||||
|
**Attack Vector:**
|
||||||
|
- Single malicious HTTP POST request
|
||||||
|
- No authentication required
|
||||||
|
- No user interaction required
|
||||||
|
- Default configurations are vulnerable
|
||||||
|
- Near 100% reliability in exploitation
|
||||||
|
|
||||||
|
**Impact:**
|
||||||
|
- Full remote code execution on the server
|
||||||
|
- Execution occurs under NodeJS runtime privileges
|
||||||
|
- Both Windows and Linux environments affected
|
||||||
|
- Applications without explicitly defined server functions may still be vulnerable
|
||||||
|
|
||||||
|
### Attack Surface Indicators
|
||||||
|
|
||||||
|
Applications are potentially vulnerable if they:
|
||||||
|
1. Use React 19.x with Server Components enabled
|
||||||
|
2. Import any `react-server-dom-*` packages
|
||||||
|
3. Use frameworks built on RSC (Next.js App Router, etc.)
|
||||||
|
4. Have any server-side React component rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detection Methods
|
||||||
|
|
||||||
|
### Package Audit Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# NPM - Check for vulnerable packages
|
||||||
|
npm ls react-server-dom-webpack react-server-dom-turbopack react-server-dom-parcel
|
||||||
|
|
||||||
|
# NPM - Security audit
|
||||||
|
npm audit
|
||||||
|
|
||||||
|
# Yarn
|
||||||
|
yarn why react-server-dom-webpack
|
||||||
|
yarn audit
|
||||||
|
|
||||||
|
# PNPM
|
||||||
|
pnpm list react-server-dom-webpack
|
||||||
|
pnpm audit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Check in package.json
|
||||||
|
|
||||||
|
Look for these patterns indicating potential vulnerability:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"next": "^15.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The caret (^) allows minor/patch updates, so actual installed version may differ. Always check lock files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check actual installed versions
|
||||||
|
grep -E "react-server-dom" package-lock.json
|
||||||
|
grep -E "react-server-dom" yarn.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log Indicators of Exploitation
|
||||||
|
|
||||||
|
Monitor for:
|
||||||
|
- Unusual POST requests to RSC endpoints
|
||||||
|
- Unexpected child process spawning from Node.js
|
||||||
|
- Base64-encoded command execution in logs
|
||||||
|
- Connections to unknown external IPs from web server processes
|
||||||
|
- Creation of files in /tmp or unusual directories
|
||||||
|
- Modifications to systemd, cron, rc.local, or authorized_keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remediation Steps
|
||||||
|
|
||||||
|
### Immediate Actions
|
||||||
|
|
||||||
|
1. **Identify Exposure**
|
||||||
|
```bash
|
||||||
|
# List all React-related packages
|
||||||
|
npm ls | grep -E "(react|next)"
|
||||||
|
|
||||||
|
# Check specific RSC packages
|
||||||
|
npm ls react-server-dom-webpack react-server-dom-turbopack react-server-dom-parcel 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update React Packages**
|
||||||
|
```bash
|
||||||
|
# Update to patched versions
|
||||||
|
npm install react@latest react-dom@latest
|
||||||
|
npm install react-server-dom-webpack@19.2.3
|
||||||
|
npm install react-server-dom-turbopack@19.2.3
|
||||||
|
npm install react-server-dom-parcel@19.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Update Next.js**
|
||||||
|
```bash
|
||||||
|
# For 14.x users
|
||||||
|
npm install next@14.2.35
|
||||||
|
|
||||||
|
# For 15.x/16.x users
|
||||||
|
npm install next@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Verify Updates**
|
||||||
|
```bash
|
||||||
|
npm audit
|
||||||
|
npm ls react-server-dom-webpack
|
||||||
|
```
|
||||||
|
|
||||||
|
### Additional Hardening
|
||||||
|
|
||||||
|
1. Implement WAF rules to filter malicious RSC payloads
|
||||||
|
2. Enable detailed logging for RSC endpoints
|
||||||
|
3. Restrict outbound network access from application servers
|
||||||
|
4. Monitor for indicators of compromise listed above
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Vulnerabilities
|
||||||
|
|
||||||
|
These were discovered during scrutiny following CVE-2025-55182:
|
||||||
|
|
||||||
|
| CVE | Severity | Description | Patched In |
|
||||||
|
|-----|----------|-------------|------------|
|
||||||
|
| CVE-2025-55183 | Medium (5.3) | Source Code Exposure | 19.0.3, 19.1.4, 19.2.3 |
|
||||||
|
| CVE-2025-55184 | High (7.5) | Denial of Service | 19.0.3, 19.1.4, 19.2.3 |
|
||||||
|
| CVE-2025-67779 | - | DoS (incomplete CVE-2025-55184 fix) | 19.0.3, 19.1.4, 19.2.3 |
|
||||||
|
| CVE-2025-66478 | Rejected | Duplicate of CVE-2025-55182 | N/A |
|
||||||
|
|
||||||
|
**Recommendation:** Update to at least 19.0.3, 19.1.4, or 19.2.3 to address all known vulnerabilities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Observed Threat Activity
|
||||||
|
|
||||||
|
### Attribution
|
||||||
|
|
||||||
|
Multiple threat actors have been observed exploiting this vulnerability:
|
||||||
|
- Opportunistic cybercriminals (cryptominers)
|
||||||
|
- CL-STA-1015 (Initial Access Broker with suspected PRC MSS ties)
|
||||||
|
- North Korean state-sponsored actors (per Sysdig research)
|
||||||
|
- Red team assessments
|
||||||
|
|
||||||
|
### Post-Exploitation TTPs
|
||||||
|
|
||||||
|
**Initial Access:**
|
||||||
|
- Automated scanning for vulnerable endpoints
|
||||||
|
- Single HTTP POST request exploitation
|
||||||
|
|
||||||
|
**Execution:**
|
||||||
|
- Base64-encoded commands
|
||||||
|
- Reverse shells (Bash, PowerShell)
|
||||||
|
- Cobalt Strike beacons
|
||||||
|
|
||||||
|
**Persistence:**
|
||||||
|
- New user creation
|
||||||
|
- SSH authorized_keys modification
|
||||||
|
- Systemd service installation
|
||||||
|
- Cron job creation
|
||||||
|
- rc.local modifications
|
||||||
|
- RMM tools (MeshAgent)
|
||||||
|
|
||||||
|
**Malware Deployed:**
|
||||||
|
- SNOWLIGHT downloader
|
||||||
|
- VShell Trojans
|
||||||
|
- MINOCAT tunneler
|
||||||
|
- HISONIC backdoor
|
||||||
|
- COMPOOD backdoor
|
||||||
|
- EtherRAT
|
||||||
|
- XMRIG cryptocurrency miners
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assessment Checklist
|
||||||
|
|
||||||
|
Use this checklist when reviewing applications:
|
||||||
|
|
||||||
|
### Discovery Phase
|
||||||
|
- [ ] Application uses React 19.x
|
||||||
|
- [ ] Application uses Server Components or App Router
|
||||||
|
- [ ] Identified react-server-dom-* packages in dependencies
|
||||||
|
- [ ] Checked transitive dependencies for RSC packages
|
||||||
|
- [ ] Verified framework versions (Next.js, React Router, etc.)
|
||||||
|
|
||||||
|
### Version Verification
|
||||||
|
- [ ] Checked package.json for version declarations
|
||||||
|
- [ ] Verified actual installed versions in lock files
|
||||||
|
- [ ] Ran `npm audit` or equivalent
|
||||||
|
- [ ] Documented all vulnerable packages found
|
||||||
|
|
||||||
|
### Risk Assessment
|
||||||
|
- [ ] Application is internet-facing
|
||||||
|
- [ ] Application processes user-supplied data server-side
|
||||||
|
- [ ] Identified all RSC endpoints
|
||||||
|
- [ ] Evaluated WAF coverage for RSC endpoints
|
||||||
|
|
||||||
|
### Remediation Tracking
|
||||||
|
- [ ] Created upgrade plan with dependencies
|
||||||
|
- [ ] Tested upgrades in non-production environment
|
||||||
|
- [ ] Deployed patches to production
|
||||||
|
- [ ] Verified patches with post-deployment audit
|
||||||
|
- [ ] Implemented additional monitoring/detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [React Security Advisory](https://react.dev/blog/2025/12/03/critical-security-vulnerability-in-react-server-components)
|
||||||
|
- [React Follow-up Advisory (DoS/Source Exposure)](https://react.dev/blog/2025/12/11/denial-of-service-and-source-code-exposure-in-react-server-components)
|
||||||
|
- [NVD Entry](https://nvd.nist.gov/vuln/detail/CVE-2025-55182)
|
||||||
|
- [CISA KEV Entry](https://www.cisa.gov/news-events/alerts/2025/12/05/cisa-adds-one-known-exploited-vulnerability-catalog)
|
||||||
|
- [Microsoft Security Blog](https://www.microsoft.com/en-us/security/blog/2025/12/15/defending-against-the-cve-2025-55182-react2shell-vulnerability-in-react-server-components/)
|
||||||
|
- [Unit 42 Analysis](https://unit42.paloaltonetworks.com/cve-2025-55182-react-and-cve-2025-66478-next/)
|
||||||
|
- [Google Threat Intelligence](https://cloud.google.com/blog/topics/threat-intelligence/threat-actors-exploit-react2shell-cve-2025-55182)
|
||||||
|
- [OffSec Technical Analysis](https://www.offsec.com/blog/cve-2025-55182/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Document Version: 1.0*
|
||||||
|
*Last Updated: January 2026*
|
||||||
|
*For use in application security reviews*
|
||||||
BIN
dev/StandardsGuide.pdf
Normal file
BIN
dev/StandardsGuide.pdf
Normal file
Binary file not shown.
|
|
@ -77,6 +77,10 @@ services:
|
||||||
AUVIK_API_USER: ${AUVIK_API_USER}
|
AUVIK_API_USER: ${AUVIK_API_USER}
|
||||||
AUVIK_API_KEY: ${AUVIK_API_KEY}
|
AUVIK_API_KEY: ${AUVIK_API_KEY}
|
||||||
|
|
||||||
|
# SalesBldr API Configuration
|
||||||
|
SALESBLDR_API_URL: ${SALESBLDR_API_URL}
|
||||||
|
SALESBLDR_API_KEY: ${SALESBLDR_API_KEY}
|
||||||
|
|
||||||
# PostgreSQL Configuration
|
# PostgreSQL Configuration
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
POSTGRES_PORT: 5432
|
POSTGRES_PORT: 5432
|
||||||
|
|
|
||||||
242
docs/re-enabling-authentication.md
Normal file
242
docs/re-enabling-authentication.md
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
# Re-enabling Authentication
|
||||||
|
|
||||||
|
This document explains how to re-enable Better Auth authentication that was temporarily disabled for private site access.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Authentication was temporarily disabled to allow access to the private site without requiring Microsoft OAuth or magic link email configuration. When the site has public access and authentication providers are properly configured, follow these steps to re-enable authentication.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before re-enabling authentication, ensure:
|
||||||
|
|
||||||
|
1. **Microsoft OAuth is configured** (if using Microsoft sign-in):
|
||||||
|
- `MICROSOFT_CLIENT_ID` is set in `.env` and `.env.local`
|
||||||
|
- `MICROSOFT_CLIENT_SECRET` is set in `.env` and `.env.local`
|
||||||
|
- `MICROSOFT_TENANT_ID` is set (or use "common" for multi-tenant)
|
||||||
|
- Azure AD app registration is configured with correct redirect URIs
|
||||||
|
|
||||||
|
2. **SMTP is configured** (if using magic links):
|
||||||
|
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD` are set
|
||||||
|
- `SMTP_FROM` email address is configured
|
||||||
|
- SMTP server allows sending emails
|
||||||
|
|
||||||
|
3. **Better Auth URLs are correct**:
|
||||||
|
- `BETTER_AUTH_URL` matches your production URL (currently `http://localhost:3100`)
|
||||||
|
- `NEXT_PUBLIC_BETTER_AUTH_URL` matches your production URL
|
||||||
|
- Update these to `https://pulse.wulfconsulting.cloud` for production
|
||||||
|
|
||||||
|
4. **Database tables exist**:
|
||||||
|
- Auth tables should already be created (migration `012_create_auth_tables.sql`)
|
||||||
|
- Verify with: `docker exec pulse-postgres psql -U pulse_user -d pulse_autotask -c "\dt" | grep user`
|
||||||
|
|
||||||
|
## Steps to Re-enable Authentication
|
||||||
|
|
||||||
|
### 1. Update Middleware
|
||||||
|
|
||||||
|
Edit `/opt/stacks/pulse/middleware.ts`:
|
||||||
|
|
||||||
|
**Find this code:**
|
||||||
|
```typescript
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// TEMPORARY: Authentication bypassed for private site access
|
||||||
|
// TODO: Re-enable authentication when site has public access
|
||||||
|
return NextResponse.next();
|
||||||
|
|
||||||
|
// Allow public routes
|
||||||
|
// if (publicRoutes.some((route) => pathname.startsWith(route))) {
|
||||||
|
// return NextResponse.next();
|
||||||
|
// }
|
||||||
|
// ... rest of commented code
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with:**
|
||||||
|
```typescript
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Allow public routes
|
||||||
|
if (publicRoutes.some((route) => pathname.startsWith(route))) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow static files and API routes (except admin API)
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/_next") ||
|
||||||
|
pathname.startsWith("/favicon") ||
|
||||||
|
pathname.includes(".")
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for session cookie
|
||||||
|
const sessionCookie = getSessionCookie(request);
|
||||||
|
|
||||||
|
if (!sessionCookie) {
|
||||||
|
// Redirect to sign-in if no session
|
||||||
|
const signInUrl = new URL("/auth/sign-in", request.url);
|
||||||
|
signInUrl.searchParams.set("callbackUrl", pathname);
|
||||||
|
return NextResponse.redirect(signInUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For admin routes, we need to verify the role
|
||||||
|
// This is a basic check - the actual role verification happens in the API routes
|
||||||
|
if (adminRoutes.some((route) => pathname.startsWith(route))) {
|
||||||
|
// The session cookie exists, but we can't decode it here without the secret
|
||||||
|
// Role-based access control is enforced at the API level
|
||||||
|
// This middleware just ensures there's a session
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Update Root Layout
|
||||||
|
|
||||||
|
Edit `/opt/stacks/pulse/app/layout.tsx`:
|
||||||
|
|
||||||
|
**Find this code:**
|
||||||
|
```typescript
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
// TEMPORARY: AuthProvider disabled for private site access
|
||||||
|
// import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
|
// ... later in the file ...
|
||||||
|
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
{/* TEMPORARY: AuthProvider removed - TODO: Re-enable when site has public access */}
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<AppNavigation />
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
<Toaster position="top-right" richColors />
|
||||||
|
</ThemeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Replace with:**
|
||||||
|
```typescript
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
|
|
||||||
|
// ... later in the file ...
|
||||||
|
|
||||||
|
<ThemeProvider
|
||||||
|
attribute="class"
|
||||||
|
defaultTheme="system"
|
||||||
|
enableSystem
|
||||||
|
disableTransitionOnChange
|
||||||
|
>
|
||||||
|
<AuthProvider>
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<AppNavigation />
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
</AuthProvider>
|
||||||
|
<Toaster position="top-right" richColors />
|
||||||
|
</ThemeProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update Environment Variables (if needed)
|
||||||
|
|
||||||
|
If deploying to production, update `.env.local`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update Better Auth URLs for production
|
||||||
|
BETTER_AUTH_URL=https://pulse.wulfconsulting.cloud
|
||||||
|
NEXT_PUBLIC_BETTER_AUTH_URL=https://pulse.wulfconsulting.cloud
|
||||||
|
|
||||||
|
# Ensure Microsoft OAuth is configured
|
||||||
|
MICROSOFT_CLIENT_ID=your-actual-client-id
|
||||||
|
MICROSOFT_CLIENT_SECRET=your-actual-client-secret
|
||||||
|
MICROSOFT_TENANT_ID=common
|
||||||
|
|
||||||
|
# Ensure SMTP is configured for magic links
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-smtp-user
|
||||||
|
SMTP_PASSWORD=your-smtp-password
|
||||||
|
SMTP_FROM=noreply@wulfconsulting.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Rebuild and Restart Docker Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/stacks/pulse
|
||||||
|
|
||||||
|
# Rebuild the Docker image with authentication enabled
|
||||||
|
docker compose build app
|
||||||
|
|
||||||
|
# Restart the container
|
||||||
|
docker compose up -d app
|
||||||
|
|
||||||
|
# Verify the container is running
|
||||||
|
docker logs pulse-app --tail 20
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Initial Admin User
|
||||||
|
|
||||||
|
Once authentication is enabled, you'll need to create an initial admin user. You can do this by:
|
||||||
|
|
||||||
|
1. **Using Microsoft OAuth**: Sign in with a Microsoft account, then manually update the user's role in the database:
|
||||||
|
```sql
|
||||||
|
UPDATE "user" SET role = 'super-admin' WHERE email = 'your-email@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Using Magic Link**: Send a magic link to your email, sign in, then update the role as above.
|
||||||
|
|
||||||
|
3. **Direct Database Insert**: Create a user directly in the database (requires password hashing if using email/password).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After re-enabling authentication:
|
||||||
|
|
||||||
|
1. Navigate to `https://pulse.wulfconsulting.cloud`
|
||||||
|
2. You should be redirected to `/auth/sign-in`
|
||||||
|
3. Try signing in with Microsoft OAuth or magic link
|
||||||
|
4. Verify you can access the dashboard after authentication
|
||||||
|
5. Check that unauthenticated users are redirected to sign-in
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Invalid Origin" Error
|
||||||
|
- Verify `BETTER_AUTH_URL` matches your actual domain
|
||||||
|
- Check `trustedOrigins` in `/opt/stacks/pulse/lib/auth.ts` includes your domain
|
||||||
|
|
||||||
|
### Microsoft OAuth Not Working
|
||||||
|
- Verify Azure AD app registration redirect URIs include:
|
||||||
|
- `https://pulse.wulfconsulting.cloud/api/auth/callback/microsoft`
|
||||||
|
- Check client ID and secret are correct
|
||||||
|
- Ensure tenant ID is set correctly
|
||||||
|
|
||||||
|
### Magic Links Not Sending
|
||||||
|
- Verify SMTP configuration is correct
|
||||||
|
- Check SMTP server logs for errors
|
||||||
|
- Test SMTP connection manually
|
||||||
|
|
||||||
|
### Database Adapter Errors
|
||||||
|
- Ensure auth tables exist: `docker exec pulse-postgres psql -U pulse_user -d pulse_autotask -c "\dt" | grep user`
|
||||||
|
- If missing, run migration: `docker exec pulse-postgres psql -U pulse_user -d pulse_autotask -f /docker-entrypoint-initdb.d/012_create_auth_tables.sql`
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If you need to disable authentication again:
|
||||||
|
|
||||||
|
1. Revert the changes to `middleware.ts` (uncomment the bypass code)
|
||||||
|
2. Revert the changes to `app/layout.tsx` (remove AuthProvider)
|
||||||
|
3. Rebuild and restart the container
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Better Auth Documentation](https://www.better-auth.com)
|
||||||
|
- [Better Auth PostgreSQL Adapter](https://www.better-auth.com/docs/adapters/postgresql)
|
||||||
|
- [Better Auth Microsoft Provider](https://www.better-auth.com/docs/providers/microsoft)
|
||||||
|
- [Better Auth Magic Link Plugin](https://www.better-auth.com/docs/plugins/magic-link)
|
||||||
40
lib/auth-client.ts
Normal file
40
lib/auth-client.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { magicLinkClient, twoFactorClient, adminClient } from "better-auth/client/plugins";
|
||||||
|
import { ac, adminRole, userRole, superAdminRole } from "./permissions";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "",
|
||||||
|
plugins: [
|
||||||
|
// Magic link client
|
||||||
|
magicLinkClient(),
|
||||||
|
|
||||||
|
// Two-factor authentication client
|
||||||
|
twoFactorClient({
|
||||||
|
onTwoFactorRedirect() {
|
||||||
|
window.location.href = "/auth/2fa";
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Admin client with RBAC
|
||||||
|
adminClient({
|
||||||
|
ac,
|
||||||
|
roles: {
|
||||||
|
"super-admin": superAdminRole,
|
||||||
|
admin: adminRole,
|
||||||
|
user: userRole,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export commonly used hooks and methods
|
||||||
|
export const {
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
useSession,
|
||||||
|
getSession,
|
||||||
|
} = authClient;
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type Session = typeof authClient.$Infer.Session;
|
||||||
|
export type User = typeof authClient.$Infer.Session.user;
|
||||||
169
lib/auth-utils.ts
Normal file
169
lib/auth-utils.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "./auth";
|
||||||
|
import { hasPermission, type Permission } from "./permissions";
|
||||||
|
|
||||||
|
// Extended user type with custom fields
|
||||||
|
type UserWithRole = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
requires_setup?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current session from the request headers
|
||||||
|
* Use this in API routes and server components
|
||||||
|
*/
|
||||||
|
export async function getSession() {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require authentication for an API route
|
||||||
|
* Returns the session if authenticated, or a 401 response if not
|
||||||
|
*/
|
||||||
|
export async function requireAuth() {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: "Unauthorized" },
|
||||||
|
{ status: 401 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require specific permissions for an API route
|
||||||
|
* Returns the session if authorized, or a 403 response if not
|
||||||
|
*/
|
||||||
|
export async function requirePermission(
|
||||||
|
resource: Permission["resource"],
|
||||||
|
action: string
|
||||||
|
) {
|
||||||
|
const { session, error } = await requireAuth();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { session: null, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = (session!.user as UserWithRole).role || "user";
|
||||||
|
|
||||||
|
if (!hasPermission(userRole, resource, action)) {
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: "Forbidden" },
|
||||||
|
{ status: 403 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require admin or super-admin role
|
||||||
|
*/
|
||||||
|
export async function requireAdmin() {
|
||||||
|
const { session, error } = await requireAuth();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { session: null, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = (session!.user as UserWithRole).role || "user";
|
||||||
|
|
||||||
|
if (userRole !== "admin" && userRole !== "super-admin") {
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: "Forbidden - Admin access required" },
|
||||||
|
{ status: 403 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require super-admin role
|
||||||
|
*/
|
||||||
|
export async function requireSuperAdmin() {
|
||||||
|
const { session, error } = await requireAuth();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return { session: null, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = (session!.user as UserWithRole).role || "user";
|
||||||
|
|
||||||
|
if (userRole !== "super-admin") {
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
error: NextResponse.json(
|
||||||
|
{ error: "Forbidden - Super admin access required" },
|
||||||
|
{ status: 403 }
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { session, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user has a specific permission
|
||||||
|
* Use this in server components for conditional rendering
|
||||||
|
*/
|
||||||
|
export async function checkPermission(
|
||||||
|
resource: Permission["resource"],
|
||||||
|
action: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = (session.user as UserWithRole).role || "user";
|
||||||
|
return hasPermission(userRole, resource, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is an admin or super-admin
|
||||||
|
*/
|
||||||
|
export async function isAdmin(): Promise<boolean> {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRole = (session.user as UserWithRole).role || "user";
|
||||||
|
return userRole === "admin" || userRole === "super-admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is a super-admin
|
||||||
|
*/
|
||||||
|
export async function isSuperAdmin(): Promise<boolean> {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (session.user as UserWithRole).role === "super-admin";
|
||||||
|
}
|
||||||
92
lib/auth.ts
Normal file
92
lib/auth.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { magicLink, twoFactor, admin } from "better-auth/plugins";
|
||||||
|
import { nextCookies } from "better-auth/next-js";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { sendMagicLinkEmail } from "./services/email";
|
||||||
|
import { ac, adminRole, userRole, superAdminRole } from "./permissions";
|
||||||
|
|
||||||
|
// Create a PostgreSQL pool for Better Auth
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: pool,
|
||||||
|
appName: "Pulse",
|
||||||
|
baseURL: process.env.BETTER_AUTH_URL,
|
||||||
|
secret: process.env.BETTER_AUTH_SECRET,
|
||||||
|
trustedOrigins: [
|
||||||
|
"http://localhost:3100",
|
||||||
|
"https://pulse.wulfconsulting.cloud"
|
||||||
|
],
|
||||||
|
|
||||||
|
// Email & Password disabled - using magic link and Microsoft OAuth only
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Session configuration
|
||||||
|
session: {
|
||||||
|
expiresIn: parseInt(process.env.SESSION_TIMEOUT_SECONDS || "86400"),
|
||||||
|
updateAge: 60 * 60, // Update session every hour
|
||||||
|
cookieCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxAge: 5 * 60, // 5 minutes
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Social providers
|
||||||
|
socialProviders: {
|
||||||
|
microsoft: {
|
||||||
|
clientId: process.env.MICROSOFT_CLIENT_ID || "",
|
||||||
|
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
|
||||||
|
tenantId: process.env.MICROSOFT_TENANT_ID || "common",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Plugins
|
||||||
|
plugins: [
|
||||||
|
// Magic link authentication
|
||||||
|
magicLink({
|
||||||
|
sendMagicLink: async ({ email, url, token }) => {
|
||||||
|
await sendMagicLinkEmail({ email, url, token });
|
||||||
|
},
|
||||||
|
expiresIn: 300, // 5 minutes
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Two-factor authentication
|
||||||
|
twoFactor({
|
||||||
|
issuer: "Pulse",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Admin plugin with RBAC
|
||||||
|
admin({
|
||||||
|
ac,
|
||||||
|
roles: {
|
||||||
|
"super-admin": superAdminRole,
|
||||||
|
admin: adminRole,
|
||||||
|
user: userRole,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Next.js cookie handling - must be last
|
||||||
|
nextCookies(),
|
||||||
|
],
|
||||||
|
|
||||||
|
// User configuration
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
role: {
|
||||||
|
type: "string",
|
||||||
|
defaultValue: "user",
|
||||||
|
},
|
||||||
|
requires_setup: {
|
||||||
|
type: "boolean",
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Session = typeof auth.$Infer.Session;
|
||||||
|
export type User = typeof auth.$Infer.Session.user;
|
||||||
103
lib/bootstrap.ts
Normal file
103
lib/bootstrap.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any users exist in the database
|
||||||
|
*/
|
||||||
|
export async function hasUsers(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT COUNT(*) as count FROM "user"');
|
||||||
|
return parseInt(result.rows[0].count) > 0;
|
||||||
|
} catch (error) {
|
||||||
|
// Table might not exist yet
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the default super-admin user from environment variables
|
||||||
|
*/
|
||||||
|
export async function createDefaultAdmin(): Promise<boolean> {
|
||||||
|
const email = process.env.DEFAULT_ADMIN_EMAIL;
|
||||||
|
const name = process.env.DEFAULT_ADMIN_NAME || "System Administrator";
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
console.warn("DEFAULT_ADMIN_EMAIL not set, skipping admin creation");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user already exists
|
||||||
|
const existing = await pool.query(
|
||||||
|
'SELECT id FROM "user" WHERE email = $1',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
console.log("Default admin already exists");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the admin user
|
||||||
|
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, 'super-admin', false, true, NOW(), NOW())`,
|
||||||
|
[id, name, email]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Created default admin: ${email}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create default admin:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run bootstrap checks and setup
|
||||||
|
*/
|
||||||
|
export async function bootstrap(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const usersExist = await hasUsers();
|
||||||
|
|
||||||
|
if (!usersExist) {
|
||||||
|
console.log("No users found, running initial setup...");
|
||||||
|
await createDefaultAdmin();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Bootstrap failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user requires setup (has requires_setup flag)
|
||||||
|
*/
|
||||||
|
export async function userRequiresSetup(userId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT requires_setup FROM "user" WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
return result.rows[0]?.requires_setup === true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the requires_setup flag for a user
|
||||||
|
*/
|
||||||
|
export async function clearSetupFlag(userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE "user" SET requires_setup = false, updated_at = NOW() WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to clear setup flag:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
lib/permissions.ts
Normal file
97
lib/permissions.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { createAccessControl } from "better-auth/plugins/access";
|
||||||
|
|
||||||
|
// Define all available permissions for resources
|
||||||
|
export const statement = {
|
||||||
|
// Ticket management
|
||||||
|
tickets: ["create", "read", "update", "delete"],
|
||||||
|
|
||||||
|
// Configuration items
|
||||||
|
configItems: ["create", "read", "update", "delete"],
|
||||||
|
|
||||||
|
// Admin panel access
|
||||||
|
admin: ["access"],
|
||||||
|
|
||||||
|
// User management
|
||||||
|
users: ["create", "read", "update", "delete", "invite", "ban"],
|
||||||
|
|
||||||
|
// Role management
|
||||||
|
roles: ["create", "read", "update", "delete"],
|
||||||
|
|
||||||
|
// Audit log access
|
||||||
|
auditLog: ["read"],
|
||||||
|
|
||||||
|
// Settings management
|
||||||
|
settings: ["read", "update"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Create access control instance
|
||||||
|
export const ac = createAccessControl(statement);
|
||||||
|
|
||||||
|
// Super Admin role - full access to everything
|
||||||
|
export const superAdminRole = ac.newRole({
|
||||||
|
tickets: ["create", "read", "update", "delete"],
|
||||||
|
configItems: ["create", "read", "update", "delete"],
|
||||||
|
admin: ["access"],
|
||||||
|
users: ["create", "read", "update", "delete", "invite", "ban"],
|
||||||
|
roles: ["create", "read", "update", "delete"],
|
||||||
|
auditLog: ["read"],
|
||||||
|
settings: ["read", "update"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin role - access to admin panel and user management, but not role management
|
||||||
|
export const adminRole = ac.newRole({
|
||||||
|
tickets: ["create", "read", "update", "delete"],
|
||||||
|
configItems: ["create", "read", "update", "delete"],
|
||||||
|
admin: ["access"],
|
||||||
|
users: ["create", "read", "update", "invite"],
|
||||||
|
roles: ["read"],
|
||||||
|
auditLog: ["read"],
|
||||||
|
settings: ["read"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// User role - basic access
|
||||||
|
export const userRole = ac.newRole({
|
||||||
|
tickets: ["create", "read", "update"],
|
||||||
|
configItems: ["read"],
|
||||||
|
admin: [],
|
||||||
|
users: [],
|
||||||
|
roles: [],
|
||||||
|
auditLog: [],
|
||||||
|
settings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to check if a user has a specific permission
|
||||||
|
export function hasPermission(
|
||||||
|
userRole: string,
|
||||||
|
resource: keyof typeof statement,
|
||||||
|
action: string
|
||||||
|
): boolean {
|
||||||
|
const roles: Record<string, ReturnType<typeof ac.newRole>> = {
|
||||||
|
"super-admin": superAdminRole,
|
||||||
|
admin: adminRole,
|
||||||
|
user: userRole as unknown as ReturnType<typeof ac.newRole>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const role = roles[userRole];
|
||||||
|
if (!role) return false;
|
||||||
|
|
||||||
|
// Check if the role has the permission
|
||||||
|
const permissions = role.statements[resource];
|
||||||
|
if (!permissions) return false;
|
||||||
|
|
||||||
|
return (permissions as readonly string[]).includes(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type for permission check
|
||||||
|
export type Permission = {
|
||||||
|
resource: keyof typeof statement;
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check multiple permissions
|
||||||
|
export function hasPermissions(
|
||||||
|
userRole: string,
|
||||||
|
permissions: Permission[]
|
||||||
|
): boolean {
|
||||||
|
return permissions.every((p) => hasPermission(userRole, p.resource, p.action));
|
||||||
|
}
|
||||||
197
lib/services/audit.ts
Normal file
197
lib/services/audit.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
userId?: string;
|
||||||
|
userEmail?: string;
|
||||||
|
action: string;
|
||||||
|
resource: string;
|
||||||
|
resourceId?: string;
|
||||||
|
details?: Record<string, unknown>;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log an audit event
|
||||||
|
*/
|
||||||
|
export async function log(entry: AuditLogEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO "audit_log"
|
||||||
|
(id, user_id, user_email, action, resource, resource_id, details, ip_address, user_agent)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
entry.userId || null,
|
||||||
|
entry.userEmail || null,
|
||||||
|
entry.action,
|
||||||
|
entry.resource,
|
||||||
|
entry.resourceId || null,
|
||||||
|
entry.details ? JSON.stringify(entry.details) : null,
|
||||||
|
entry.ipAddress || null,
|
||||||
|
entry.userAgent || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to write audit log:", error);
|
||||||
|
// Don't throw - audit logging should not break the main flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience functions for common audit events
|
||||||
|
export const audit = {
|
||||||
|
log,
|
||||||
|
|
||||||
|
// Auth events
|
||||||
|
signIn: (userId: string, email: string, ipAddress?: string, userAgent?: string) =>
|
||||||
|
log({
|
||||||
|
userId,
|
||||||
|
userEmail: email,
|
||||||
|
action: "sign_in",
|
||||||
|
resource: "auth",
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
}),
|
||||||
|
|
||||||
|
signOut: (userId: string, email: string, ipAddress?: string) =>
|
||||||
|
log({
|
||||||
|
userId,
|
||||||
|
userEmail: email,
|
||||||
|
action: "sign_out",
|
||||||
|
resource: "auth",
|
||||||
|
ipAddress,
|
||||||
|
}),
|
||||||
|
|
||||||
|
signInFailed: (email: string, reason: string, ipAddress?: string) =>
|
||||||
|
log({
|
||||||
|
userEmail: email,
|
||||||
|
action: "sign_in_failed",
|
||||||
|
resource: "auth",
|
||||||
|
details: { reason },
|
||||||
|
ipAddress,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// User management events
|
||||||
|
userCreated: (actorId: string, actorEmail: string, targetUserId: string, targetEmail: string) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "create",
|
||||||
|
resource: "user",
|
||||||
|
resourceId: targetUserId,
|
||||||
|
details: { targetEmail },
|
||||||
|
}),
|
||||||
|
|
||||||
|
userUpdated: (actorId: string, actorEmail: string, targetUserId: string, changes: Record<string, unknown>) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "update",
|
||||||
|
resource: "user",
|
||||||
|
resourceId: targetUserId,
|
||||||
|
details: { changes },
|
||||||
|
}),
|
||||||
|
|
||||||
|
userDeleted: (actorId: string, actorEmail: string, targetUserId: string, targetEmail: string) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "delete",
|
||||||
|
resource: "user",
|
||||||
|
resourceId: targetUserId,
|
||||||
|
details: { targetEmail },
|
||||||
|
}),
|
||||||
|
|
||||||
|
userBanned: (actorId: string, actorEmail: string, targetUserId: string, reason?: string) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "ban",
|
||||||
|
resource: "user",
|
||||||
|
resourceId: targetUserId,
|
||||||
|
details: { reason },
|
||||||
|
}),
|
||||||
|
|
||||||
|
userUnbanned: (actorId: string, actorEmail: string, targetUserId: string) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "unban",
|
||||||
|
resource: "user",
|
||||||
|
resourceId: targetUserId,
|
||||||
|
}),
|
||||||
|
|
||||||
|
userRoleChanged: (actorId: string, actorEmail: string, targetUserId: string, oldRole: string, newRole: string) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "role_change",
|
||||||
|
resource: "user",
|
||||||
|
resourceId: targetUserId,
|
||||||
|
details: { oldRole, newRole },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Role management events
|
||||||
|
roleCreated: (actorId: string, actorEmail: string, roleId: string, roleName: string) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "create",
|
||||||
|
resource: "role",
|
||||||
|
resourceId: roleId,
|
||||||
|
details: { roleName },
|
||||||
|
}),
|
||||||
|
|
||||||
|
roleUpdated: (actorId: string, actorEmail: string, roleId: string, changes: Record<string, unknown>) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "update",
|
||||||
|
resource: "role",
|
||||||
|
resourceId: roleId,
|
||||||
|
details: { changes },
|
||||||
|
}),
|
||||||
|
|
||||||
|
roleDeleted: (actorId: string, actorEmail: string, roleId: string, roleName: string) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "delete",
|
||||||
|
resource: "role",
|
||||||
|
resourceId: roleId,
|
||||||
|
details: { roleName },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Settings events
|
||||||
|
settingsUpdated: (actorId: string, actorEmail: string, setting: string, oldValue: unknown, newValue: unknown) =>
|
||||||
|
log({
|
||||||
|
userId: actorId,
|
||||||
|
userEmail: actorEmail,
|
||||||
|
action: "update",
|
||||||
|
resource: "settings",
|
||||||
|
resourceId: setting,
|
||||||
|
details: { oldValue, newValue },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Purge old audit logs based on retention policy
|
||||||
|
*/
|
||||||
|
export async function purgeOldLogs(retentionDays?: number): Promise<number> {
|
||||||
|
const days = retentionDays || parseInt(process.env.AUDIT_LOG_RETENTION_DAYS || "90");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM "audit_log" WHERE timestamp < NOW() - INTERVAL '${days} days'`
|
||||||
|
);
|
||||||
|
return result.rowCount || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to purge audit logs:", error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
165
lib/services/email.ts
Normal file
165
lib/services/email.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
import * as nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
// SMTP configuration from environment variables
|
||||||
|
const smtpConfig = {
|
||||||
|
host: process.env.SMTP_HOST || "smtp.example.com",
|
||||||
|
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||||
|
secure: process.env.SMTP_PORT === "465", // true for 465, false for other ports
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER || "",
|
||||||
|
pass: process.env.SMTP_PASSWORD || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromAddress = process.env.SMTP_FROM || "noreply@example.com";
|
||||||
|
|
||||||
|
// Create reusable transporter
|
||||||
|
let transporter: nodemailer.Transporter | null = null;
|
||||||
|
|
||||||
|
function getTransporter(): nodemailer.Transporter {
|
||||||
|
if (!transporter) {
|
||||||
|
transporter = nodemailer.createTransport(smtpConfig);
|
||||||
|
}
|
||||||
|
return transporter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email templates
|
||||||
|
interface MagicLinkEmailParams {
|
||||||
|
email: string;
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMagicLinkEmail({
|
||||||
|
email,
|
||||||
|
url,
|
||||||
|
}: MagicLinkEmailParams): Promise<void> {
|
||||||
|
const transport = getTransporter();
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sign in to Pulse</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||||
|
</div>
|
||||||
|
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||||
|
<h2 style="color: #333; margin-top: 0;">Sign in to your account</h2>
|
||||||
|
<p>Click the button below to sign in to Pulse. This link will expire in 5 minutes.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||||
|
Sign in to Pulse
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; font-size: 14px;">If you didn't request this email, you can safely ignore it.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||||
|
<p style="color: #999; font-size: 12px;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||||
|
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const text = `
|
||||||
|
Sign in to Pulse
|
||||||
|
|
||||||
|
Click the link below to sign in to your account. This link will expire in 5 minutes.
|
||||||
|
|
||||||
|
${url}
|
||||||
|
|
||||||
|
If you didn't request this email, you can safely ignore it.
|
||||||
|
`;
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: fromAddress,
|
||||||
|
to: email,
|
||||||
|
subject: "Sign in to Pulse",
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InvitationEmailParams {
|
||||||
|
email: string;
|
||||||
|
inviterName: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendInvitationEmail({
|
||||||
|
email,
|
||||||
|
inviterName,
|
||||||
|
url,
|
||||||
|
}: InvitationEmailParams): Promise<void> {
|
||||||
|
const transport = getTransporter();
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>You're invited to Pulse</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||||
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 30px; border-radius: 10px 10px 0 0;">
|
||||||
|
<h1 style="color: white; margin: 0; font-size: 28px;">Pulse</h1>
|
||||||
|
</div>
|
||||||
|
<div style="background: #ffffff; padding: 30px; border: 1px solid #e0e0e0; border-top: none; border-radius: 0 0 10px 10px;">
|
||||||
|
<h2 style="color: #333; margin-top: 0;">You're invited!</h2>
|
||||||
|
<p><strong>${inviterName}</strong> has invited you to join Pulse.</p>
|
||||||
|
<p>Click the button below to accept the invitation and set up your account.</p>
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 28px; text-decoration: none; border-radius: 6px; font-weight: 600; display: inline-block;">
|
||||||
|
Accept Invitation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color: #666; font-size: 14px;">If you weren't expecting this invitation, you can safely ignore this email.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 20px 0;">
|
||||||
|
<p style="color: #999; font-size: 12px;">
|
||||||
|
If the button doesn't work, copy and paste this link into your browser:<br>
|
||||||
|
<a href="${url}" style="color: #667eea; word-break: break-all;">${url}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const text = `
|
||||||
|
You're invited to Pulse!
|
||||||
|
|
||||||
|
${inviterName} has invited you to join Pulse.
|
||||||
|
|
||||||
|
Click the link below to accept the invitation and set up your account:
|
||||||
|
|
||||||
|
${url}
|
||||||
|
|
||||||
|
If you weren't expecting this invitation, you can safely ignore this email.
|
||||||
|
`;
|
||||||
|
|
||||||
|
await transport.sendMail({
|
||||||
|
from: fromAddress,
|
||||||
|
to: email,
|
||||||
|
subject: "You're invited to Pulse",
|
||||||
|
text,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SMTP connection
|
||||||
|
export async function verifyEmailConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const transport = getTransporter();
|
||||||
|
await transport.verify();
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("SMTP connection verification failed:", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
208
lib/services/salesbldr-client.ts
Normal file
208
lib/services/salesbldr-client.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
interface SalesBldrConfig {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuoteItem {
|
||||||
|
id: string;
|
||||||
|
category?: object;
|
||||||
|
cost?: number;
|
||||||
|
mpn?: string;
|
||||||
|
markup?: number;
|
||||||
|
deliveryDate?: string;
|
||||||
|
discount?: number;
|
||||||
|
unit?: string;
|
||||||
|
term?: string;
|
||||||
|
name: string;
|
||||||
|
price?: number;
|
||||||
|
quantity: number;
|
||||||
|
shortDescription?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Quote {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
number: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
sentAt?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
approvedAt?: string;
|
||||||
|
company?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
externalIdentifier?: string;
|
||||||
|
};
|
||||||
|
contact?: object;
|
||||||
|
owner?: object;
|
||||||
|
items?: QuoteItem[];
|
||||||
|
opportunity?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuoteSearchResponse {
|
||||||
|
results: Quote[];
|
||||||
|
total: number;
|
||||||
|
filters?: any[];
|
||||||
|
sortOptions?: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SalesBldrClient {
|
||||||
|
private config: SalesBldrConfig;
|
||||||
|
|
||||||
|
constructor(config?: SalesBldrConfig) {
|
||||||
|
this.config = config || {
|
||||||
|
apiUrl: process.env.SALESBLDR_API_URL || '',
|
||||||
|
apiKey: process.env.SALESBLDR_API_KEY || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureConfig() {
|
||||||
|
if (!this.config.apiUrl || !this.config.apiKey) {
|
||||||
|
throw new Error('SalesBldr API URL and API Key are required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
this.ensureConfig();
|
||||||
|
const url = `${this.config.apiUrl}${endpoint}`;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'api-key': this.config.apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
`SalesBldr API error: ${response.status} ${response.statusText} - ${errorText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getQuotes(params?: {
|
||||||
|
sort?: string;
|
||||||
|
filters?: string;
|
||||||
|
query?: string;
|
||||||
|
size?: number;
|
||||||
|
from?: number;
|
||||||
|
}): Promise<QuoteSearchResponse> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params?.sort) queryParams.append('sort', params.sort);
|
||||||
|
if (params?.filters) queryParams.append('filters', params.filters);
|
||||||
|
if (params?.query) queryParams.append('query', params.query);
|
||||||
|
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||||
|
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||||
|
|
||||||
|
const endpoint = `/public-api/quote${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||||
|
return this.request<QuoteSearchResponse>(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpenQuotes(size: number = 10): Promise<QuoteSearchResponse> {
|
||||||
|
return this.getQuotes({
|
||||||
|
filters: 'status:sent|draft',
|
||||||
|
sort: '-createdAt',
|
||||||
|
size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApprovedQuotes(size: number = 10): Promise<Quote[]> {
|
||||||
|
const endpoint = `/public-api/quote/approved`;
|
||||||
|
return this.request<Quote[]>(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOpportunities(params?: {
|
||||||
|
sort?: string;
|
||||||
|
filters?: string;
|
||||||
|
query?: string;
|
||||||
|
size?: number;
|
||||||
|
from?: number;
|
||||||
|
}): Promise<any> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params?.sort) queryParams.append('sort', params.sort);
|
||||||
|
if (params?.filters) queryParams.append('filters', params.filters);
|
||||||
|
if (params?.query) queryParams.append('query', params.query);
|
||||||
|
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||||
|
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||||
|
|
||||||
|
const endpoint = `/public-api/opportunity${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||||
|
return this.request(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProducts(params?: {
|
||||||
|
includeBundles?: boolean;
|
||||||
|
sort?: string;
|
||||||
|
filters?: string;
|
||||||
|
query?: string;
|
||||||
|
size?: number;
|
||||||
|
from?: number;
|
||||||
|
}): Promise<any> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params?.includeBundles !== undefined) queryParams.append('includeBundles', params.includeBundles.toString());
|
||||||
|
if (params?.sort) queryParams.append('sort', params.sort);
|
||||||
|
if (params?.filters) queryParams.append('filters', params.filters);
|
||||||
|
if (params?.query) queryParams.append('query', params.query);
|
||||||
|
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||||
|
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||||
|
|
||||||
|
const endpoint = `/public-api/product${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||||
|
return this.request(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCompanies(params?: {
|
||||||
|
sort?: string;
|
||||||
|
filters?: string;
|
||||||
|
query?: string;
|
||||||
|
size?: number;
|
||||||
|
from?: number;
|
||||||
|
}): Promise<any> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params?.sort) queryParams.append('sort', params.sort);
|
||||||
|
if (params?.filters) queryParams.append('filters', params.filters);
|
||||||
|
if (params?.query) queryParams.append('query', params.query);
|
||||||
|
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||||
|
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||||
|
|
||||||
|
const endpoint = `/public-api/company${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||||
|
return this.request(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContacts(params?: {
|
||||||
|
sort?: string;
|
||||||
|
filters?: string;
|
||||||
|
query?: string;
|
||||||
|
size?: number;
|
||||||
|
from?: number;
|
||||||
|
}): Promise<any> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params?.sort) queryParams.append('sort', params.sort);
|
||||||
|
if (params?.filters) queryParams.append('filters', params.filters);
|
||||||
|
if (params?.query) queryParams.append('query', params.query);
|
||||||
|
if (params?.size !== undefined) queryParams.append('size', params.size.toString());
|
||||||
|
if (params?.from !== undefined) queryParams.append('from', params.from.toString());
|
||||||
|
|
||||||
|
const endpoint = `/public-api/contact${queryParams.toString() ? `?${queryParams.toString()}` : ''}`;
|
||||||
|
return this.request(endpoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const salesBldrClient = new SalesBldrClient();
|
||||||
12
mcp.json
Normal file
12
mcp.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"postgres": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-postgres",
|
||||||
|
"postgresql://pulse_user:9KuYTjjGEB7NsJc_togj6R9wYLRRrhudZiR4%40i%40N@localhost:5432/pulse_autotask"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
middleware.ts
Normal file
70
middleware.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { getSessionCookie } from "better-auth/cookies";
|
||||||
|
|
||||||
|
// Routes that don't require authentication
|
||||||
|
const publicRoutes = [
|
||||||
|
"/auth/sign-in",
|
||||||
|
"/auth/verify",
|
||||||
|
"/auth/2fa",
|
||||||
|
"/auth/setup",
|
||||||
|
"/api/auth",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Routes that require admin or super-admin role
|
||||||
|
const adminRoutes = ["/admin"];
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// TEMPORARY: Authentication bypassed for private site access
|
||||||
|
// TODO: Re-enable authentication when site has public access
|
||||||
|
return NextResponse.next();
|
||||||
|
|
||||||
|
// Allow public routes
|
||||||
|
// if (publicRoutes.some((route) => pathname.startsWith(route))) {
|
||||||
|
// return NextResponse.next();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Allow static files and API routes (except admin API)
|
||||||
|
// if (
|
||||||
|
// pathname.startsWith("/_next") ||
|
||||||
|
// pathname.startsWith("/favicon") ||
|
||||||
|
// pathname.includes(".")
|
||||||
|
// ) {
|
||||||
|
// return NextResponse.next();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Check for session cookie
|
||||||
|
// const sessionCookie = getSessionCookie(request);
|
||||||
|
|
||||||
|
// if (!sessionCookie) {
|
||||||
|
// // Redirect to sign-in if no session
|
||||||
|
// const signInUrl = new URL("/auth/sign-in", request.url);
|
||||||
|
// signInUrl.searchParams.set("callbackUrl", pathname);
|
||||||
|
// return NextResponse.redirect(signInUrl);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // For admin routes, we need to verify the role
|
||||||
|
// // This is a basic check - the actual role verification happens in the API routes
|
||||||
|
// if (adminRoutes.some((route) => pathname.startsWith(route))) {
|
||||||
|
// // The session cookie exists, but we can't decode it here without the secret
|
||||||
|
// // Role-based access control is enforced at the API level
|
||||||
|
// // This middleware just ensures there's a session
|
||||||
|
// return NextResponse.next();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
/*
|
||||||
|
* Match all request paths except for the ones starting with:
|
||||||
|
* - _next/static (static files)
|
||||||
|
* - _next/image (image optimization files)
|
||||||
|
* - favicon.ico (favicon file)
|
||||||
|
*/
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
76
migrations/012_create_auth_tables.sql
Normal file
76
migrations/012_create_auth_tables.sql
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
-- Better Auth Core Tables Migration
|
||||||
|
-- Creates user, session, account, and verification tables for Better Auth
|
||||||
|
|
||||||
|
-- User table
|
||||||
|
CREATE TABLE IF NOT EXISTS "user" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
image TEXT,
|
||||||
|
role TEXT DEFAULT 'user',
|
||||||
|
banned BOOLEAN DEFAULT FALSE,
|
||||||
|
banned_reason TEXT,
|
||||||
|
ban_expires TIMESTAMP,
|
||||||
|
requires_setup BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Session table
|
||||||
|
CREATE TABLE IF NOT EXISTS "session" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Account table (for OAuth providers)
|
||||||
|
CREATE TABLE IF NOT EXISTS "account" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
access_token TEXT,
|
||||||
|
refresh_token TEXT,
|
||||||
|
access_token_expires_at TIMESTAMP,
|
||||||
|
refresh_token_expires_at TIMESTAMP,
|
||||||
|
scope TEXT,
|
||||||
|
id_token TEXT,
|
||||||
|
password TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Verification table (for magic links, email verification, etc.)
|
||||||
|
CREATE TABLE IF NOT EXISTS "verification" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
identifier TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Two-factor authentication table
|
||||||
|
CREATE TABLE IF NOT EXISTS "two_factor" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
secret TEXT NOT NULL,
|
||||||
|
backup_codes TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_user_id ON "session"(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_token ON "session"(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_account_user_id ON "account"(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_account_provider ON "account"(provider_id, account_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_verification_identifier ON "verification"(identifier);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_email ON "user"(email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_two_factor_user_id ON "two_factor"(user_id);
|
||||||
43
migrations/013_create_role_tables.sql
Normal file
43
migrations/013_create_role_tables.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- Role and Permission Tables Migration
|
||||||
|
-- Creates tables for custom role management beyond Better Auth's built-in roles
|
||||||
|
|
||||||
|
-- Custom roles table (for user-defined roles beyond the defaults)
|
||||||
|
CREATE TABLE IF NOT EXISTS "role" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
permissions JSONB NOT NULL DEFAULT '{}',
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User-role assignments (for custom roles)
|
||||||
|
-- Note: Better Auth stores the primary role in the user table
|
||||||
|
-- This table is for additional role assignments if needed
|
||||||
|
CREATE TABLE IF NOT EXISTS "user_role" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
role_id TEXT NOT NULL REFERENCES "role"(id) ON DELETE CASCADE,
|
||||||
|
assigned_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
assigned_by TEXT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||||
|
UNIQUE(user_id, role_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_role_name ON "role"(name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_role_user_id ON "user_role"(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_role_role_id ON "user_role"(role_id);
|
||||||
|
|
||||||
|
-- Insert default system roles
|
||||||
|
INSERT INTO "role" (id, name, description, permissions, is_system) VALUES
|
||||||
|
('role_super_admin', 'super-admin', 'Full system access with all permissions',
|
||||||
|
'{"tickets": ["create", "read", "update", "delete"], "configItems": ["create", "read", "update", "delete"], "admin": ["access"], "users": ["create", "read", "update", "delete", "invite", "ban"], "roles": ["create", "read", "update", "delete"], "auditLog": ["read"], "settings": ["read", "update"]}',
|
||||||
|
TRUE),
|
||||||
|
('role_admin', 'admin', 'Administrative access without role management',
|
||||||
|
'{"tickets": ["create", "read", "update", "delete"], "configItems": ["create", "read", "update", "delete"], "admin": ["access"], "users": ["create", "read", "update", "invite"], "roles": ["read"], "auditLog": ["read"], "settings": ["read"]}',
|
||||||
|
TRUE),
|
||||||
|
('role_user', 'user', 'Standard user access',
|
||||||
|
'{"tickets": ["create", "read", "update"], "configItems": ["read"]}',
|
||||||
|
TRUE)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
78
migrations/014_create_admin_settings.sql
Normal file
78
migrations/014_create_admin_settings.sql
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
-- Admin Settings Tables Migration
|
||||||
|
-- Creates app_settings, session_policy, and email_template tables
|
||||||
|
|
||||||
|
-- App settings table (key-value store for application settings)
|
||||||
|
CREATE TABLE IF NOT EXISTS "app_settings" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
value TEXT,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Session policy table (CIDR-based session timeout policies)
|
||||||
|
CREATE TABLE IF NOT EXISTS "session_policy" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
cidr TEXT NOT NULL,
|
||||||
|
timeout_seconds INTEGER NOT NULL DEFAULT 86400,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Email template table
|
||||||
|
CREATE TABLE IF NOT EXISTS "email_template" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL UNIQUE,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
body_text TEXT,
|
||||||
|
variables TEXT, -- JSON array of available variables
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Audit log table
|
||||||
|
CREATE TABLE IF NOT EXISTS "audit_log" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
timestamp TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
user_id TEXT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||||
|
user_email TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
resource TEXT NOT NULL,
|
||||||
|
resource_id TEXT,
|
||||||
|
details JSONB,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_app_settings_key ON "app_settings"(key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_session_policy_priority ON "session_policy"(priority DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_email_template_type ON "email_template"(type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON "audit_log"(timestamp DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id ON "audit_log"(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON "audit_log"(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON "audit_log"(resource);
|
||||||
|
|
||||||
|
-- Insert default app settings
|
||||||
|
INSERT INTO "app_settings" (id, key, value, description) VALUES
|
||||||
|
('setting_microsoft_tenant', 'microsoft_tenant_id', 'common', 'Microsoft Entra ID tenant ID'),
|
||||||
|
('setting_session_timeout', 'default_session_timeout', '86400', 'Default session timeout in seconds'),
|
||||||
|
('setting_audit_retention', 'audit_log_retention_days', '90', 'Number of days to retain audit logs')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
-- Insert default email templates
|
||||||
|
INSERT INTO "email_template" (id, type, subject, body_html, body_text, variables) VALUES
|
||||||
|
('template_magic_link', 'magic_link', 'Sign in to Pulse',
|
||||||
|
'<h1>Sign in to Pulse</h1><p>Click the link below to sign in:</p><a href="{{url}}">Sign in</a><p>This link expires in 5 minutes.</p>',
|
||||||
|
'Sign in to Pulse\n\nClick the link below to sign in:\n{{url}}\n\nThis link expires in 5 minutes.',
|
||||||
|
'["url", "email"]'),
|
||||||
|
('template_invitation', 'invitation', 'You''re invited to Pulse',
|
||||||
|
'<h1>You''re invited!</h1><p>{{inviter_name}} has invited you to join Pulse.</p><a href="{{url}}">Accept Invitation</a>',
|
||||||
|
'You''re invited!\n\n{{inviter_name}} has invited you to join Pulse.\n\nAccept invitation: {{url}}',
|
||||||
|
'["url", "email", "inviter_name"]')
|
||||||
|
ON CONFLICT (type) DO NOTHING;
|
||||||
405
tasks/prd-auth-user-management.md
Normal file
405
tasks/prd-auth-user-management.md
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
# PRD: Authentication & User Management Module
|
||||||
|
|
||||||
|
## 1. Introduction/Overview
|
||||||
|
|
||||||
|
Pulse currently has no authentication system, leaving all routes and data publicly accessible. This PRD defines the implementation of a comprehensive authentication and authorization system using **Better Auth** with a **User Management Module** for super-admins.
|
||||||
|
|
||||||
|
### Problem Statement
|
||||||
|
- No authentication protects the application
|
||||||
|
- Sensitive admin sections and data are exposed
|
||||||
|
- No role-based access control (RBAC) exists
|
||||||
|
- No user management capabilities
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Implement Better Auth with:
|
||||||
|
- Magic link and Microsoft 365 OAuth authentication
|
||||||
|
- Role-based access control with custom role creation
|
||||||
|
- User management module for super-admins
|
||||||
|
- Session management with security controls
|
||||||
|
- Two-factor authentication (2FA)
|
||||||
|
- Audit logging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. **Secure all application routes** - No unauthenticated access to any part of Pulse
|
||||||
|
2. **Implement RBAC** - Protect admin sections and sensitive data based on user roles
|
||||||
|
3. **Enable user management** - Super-admins can create, edit, deactivate users and manage roles
|
||||||
|
4. **Support enterprise auth** - Magic link and Microsoft 365 SSO
|
||||||
|
5. **Provide security controls** - Session timeout, password policies, 2FA, audit logging
|
||||||
|
6. **Match existing UI** - Use existing shadcn/ui components for consistency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. User Stories
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- **As a user**, I want to sign in with a magic link so I don't need to remember a password
|
||||||
|
- **As a user**, I want to sign in with my Microsoft 365 account for seamless enterprise access
|
||||||
|
- **As a user**, I want to enable 2FA to secure my account
|
||||||
|
- **As a user**, I want to see and manage my active sessions
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
- **As an admin**, I want certain sections protected so only authorized users can access them
|
||||||
|
- **As a super-admin**, I want to define custom roles with specific permissions
|
||||||
|
|
||||||
|
### User Management
|
||||||
|
- **As a super-admin**, I want to invite new users via email
|
||||||
|
- **As a super-admin**, I want to view all users and their roles
|
||||||
|
- **As a super-admin**, I want to edit user profiles and change their roles
|
||||||
|
- **As a super-admin**, I want to deactivate/reactivate users
|
||||||
|
- **As a super-admin**, I want to reset a user's password/sessions
|
||||||
|
- **As a super-admin**, I want to create and manage custom roles
|
||||||
|
- **As a super-admin**, I want to view audit logs of user actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Functional Requirements
|
||||||
|
|
||||||
|
### 4.1 Authentication System
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| AUTH-01 | System must support magic link authentication via email |
|
||||||
|
| AUTH-02 | System must support Microsoft 365 OAuth authentication |
|
||||||
|
| AUTH-03 | System must support two-factor authentication (TOTP) |
|
||||||
|
| AUTH-04 | System must redirect unauthenticated users to sign-in page |
|
||||||
|
| AUTH-05 | System must maintain secure sessions with configurable timeout |
|
||||||
|
| AUTH-06 | System must allow users to view and revoke their active sessions |
|
||||||
|
| AUTH-07 | Magic links must expire after 5 minutes |
|
||||||
|
| AUTH-08 | System must support account linking (magic link + Microsoft) |
|
||||||
|
|
||||||
|
### 4.2 Authorization & RBAC
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| RBAC-01 | System must implement role-based access control |
|
||||||
|
| RBAC-02 | System must have default roles: `super-admin`, `admin`, `user` |
|
||||||
|
| RBAC-03 | Super-admins must be able to create custom roles |
|
||||||
|
| RBAC-04 | Roles must define permissions for resources (e.g., `tickets:read`, `admin:access`) |
|
||||||
|
| RBAC-05 | `/app/admin/*` routes must require `admin` or `super-admin` role |
|
||||||
|
| RBAC-06 | User management must require `super-admin` role |
|
||||||
|
| RBAC-07 | API routes must validate permissions before returning data |
|
||||||
|
|
||||||
|
### 4.3 User Management Module
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| USER-01 | Super-admins must be able to view a list of all users |
|
||||||
|
| USER-02 | Super-admins must be able to invite new users via email |
|
||||||
|
| USER-03 | Super-admins must be able to edit user profiles (name, email) |
|
||||||
|
| USER-04 | Super-admins must be able to assign/change user roles |
|
||||||
|
| USER-05 | Super-admins must be able to deactivate users (soft delete) |
|
||||||
|
| USER-06 | Super-admins must be able to reactivate deactivated users |
|
||||||
|
| USER-07 | Super-admins must be able to delete users permanently |
|
||||||
|
| USER-08 | Super-admins must be able to force password reset / revoke sessions |
|
||||||
|
| USER-09 | Super-admins must be able to view user session history |
|
||||||
|
|
||||||
|
### 4.4 Role Management
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| ROLE-01 | Super-admins must be able to view all roles |
|
||||||
|
| ROLE-02 | Super-admins must be able to create new roles with custom permissions |
|
||||||
|
| ROLE-03 | Super-admins must be able to edit existing role permissions |
|
||||||
|
| ROLE-04 | Super-admins must be able to delete custom roles (not default roles) |
|
||||||
|
| ROLE-05 | System must prevent deletion of roles assigned to users |
|
||||||
|
|
||||||
|
### 4.5 Security & Audit
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| SEC-01 | Sessions must expire after 1 hour by default (configurable) |
|
||||||
|
| SEC-02 | Session timeout must be configurable per source IP/subnet (trusted networks can have longer sessions) |
|
||||||
|
| SEC-03 | Sessions must refresh on activity (sliding expiration) |
|
||||||
|
| SEC-04 | System must log authentication events (sign-in, sign-out, failed attempts) |
|
||||||
|
| SEC-05 | System must log user management actions (create, edit, delete, role changes) |
|
||||||
|
| SEC-06 | Audit logs must be viewable by super-admins |
|
||||||
|
| SEC-07 | Audit logs must be retained for 120 days by default |
|
||||||
|
| SEC-08 | System must automatically purge audit logs older than retention period |
|
||||||
|
| SEC-09 | System must rate-limit authentication attempts |
|
||||||
|
|
||||||
|
### 4.6 Microsoft 365 Configuration
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| MS-01 | Super-admins must be able to configure Microsoft tenant ID in admin settings |
|
||||||
|
| MS-02 | System must validate tenant ID format before saving |
|
||||||
|
| MS-03 | System must support both specific tenant and 'common' (any Microsoft account) modes |
|
||||||
|
|
||||||
|
### 4.8 Session Policies (Trusted Networks)
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| NET-01 | Super-admins must be able to define trusted networks using CIDR notation |
|
||||||
|
| NET-02 | Each network policy must have: name, CIDR range, session timeout (seconds) |
|
||||||
|
| NET-03 | System must validate CIDR notation format before saving |
|
||||||
|
| NET-04 | System must match client IP against policies in priority order |
|
||||||
|
| NET-05 | Default policy (1 hour) applies when no CIDR matches |
|
||||||
|
| NET-06 | Example: `10.0.0.0/8` = 8 hours, `192.168.1.0/24` = 24 hours |
|
||||||
|
|
||||||
|
### 4.9 Email Templates
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| EMAIL-01 | Super-admins must be able to customize magic link email template |
|
||||||
|
| EMAIL-02 | Super-admins must be able to customize user invitation email template |
|
||||||
|
| EMAIL-03 | Templates must support variables: `{{name}}`, `{{link}}`, `{{expires}}`, `{{app_name}}` |
|
||||||
|
| EMAIL-04 | System must provide default templates that can be reset |
|
||||||
|
| EMAIL-05 | Templates must support HTML formatting |
|
||||||
|
|
||||||
|
### 4.7 Initial Setup & Bootstrap
|
||||||
|
|
||||||
|
| ID | Requirement |
|
||||||
|
|----|-------------|
|
||||||
|
| BOOT-01 | System must create a default super-admin account on first run |
|
||||||
|
| BOOT-02 | Default super-admin credentials must be set via environment variables |
|
||||||
|
| BOOT-03 | System must force immediate password/auth method change on first login for default account |
|
||||||
|
| BOOT-04 | Default account must be clearly marked as "setup account" requiring reconfiguration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Non-Goals (Out of Scope)
|
||||||
|
|
||||||
|
- **Password-based authentication** - Only magic link and Microsoft 365 OAuth
|
||||||
|
- **Self-registration** - Users must be invited by super-admins
|
||||||
|
- **Multi-tenancy / Organizations** - Single tenant for now
|
||||||
|
- **API key authentication** - Not in initial scope
|
||||||
|
- **SAML SSO** - Only OAuth (Microsoft 365)
|
||||||
|
- **Email verification for existing users** - Users are invited, not self-registered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Design Considerations
|
||||||
|
|
||||||
|
### UI Components
|
||||||
|
Use existing shadcn/ui components from `/components/ui/`:
|
||||||
|
- `button`, `input`, `label`, `form` for auth forms
|
||||||
|
- `card` for sign-in/settings cards
|
||||||
|
- `table` for user/role lists
|
||||||
|
- `dialog` for confirmations
|
||||||
|
- `dropdown-menu` for user actions
|
||||||
|
- `badge` for role/status display
|
||||||
|
- `tabs` for settings sections
|
||||||
|
- `alert-dialog` for destructive actions
|
||||||
|
|
||||||
|
### Pages to Create
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/auth/sign-in` | Sign-in page (magic link + Microsoft) |
|
||||||
|
| `/auth/verify` | Magic link verification |
|
||||||
|
| `/auth/2fa` | Two-factor verification |
|
||||||
|
| `/app/admin/users` | User management list |
|
||||||
|
| `/app/admin/users/[id]` | User detail/edit page |
|
||||||
|
| `/app/admin/users/invite` | Invite user form |
|
||||||
|
| `/app/admin/roles` | Role management |
|
||||||
|
| `/app/admin/audit-log` | Audit log viewer |
|
||||||
|
| `/app/admin/settings` | App settings (Microsoft tenant, session policies, email templates) |
|
||||||
|
| `/settings` | User settings (profile, sessions, 2FA) |
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
```
|
||||||
|
/components/auth/
|
||||||
|
├── sign-in-form.tsx # Magic link + Microsoft buttons
|
||||||
|
├── magic-link-form.tsx # Email input for magic link
|
||||||
|
├── two-factor-form.tsx # TOTP input
|
||||||
|
└── session-list.tsx # Active sessions
|
||||||
|
|
||||||
|
/components/admin/users/
|
||||||
|
├── user-table.tsx # User list with actions
|
||||||
|
├── user-form.tsx # Create/edit user
|
||||||
|
├── invite-user-form.tsx # Invite via email
|
||||||
|
├── role-badge.tsx # Role display
|
||||||
|
└── user-actions.tsx # Dropdown actions
|
||||||
|
|
||||||
|
/components/admin/roles/
|
||||||
|
├── role-table.tsx # Role list
|
||||||
|
├── role-form.tsx # Create/edit role
|
||||||
|
└── permission-picker.tsx # Permission selection
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Technical Considerations
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"better-auth": "^1.x",
|
||||||
|
"@daveyplate/better-auth-ui": "^1.x"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Schema (PostgreSQL)
|
||||||
|
Better Auth will create these tables:
|
||||||
|
- `user` - User accounts
|
||||||
|
- `session` - Active sessions
|
||||||
|
- `account` - OAuth accounts (Microsoft)
|
||||||
|
- `verification` - Magic link tokens
|
||||||
|
|
||||||
|
Additional tables:
|
||||||
|
- `role` - Custom roles
|
||||||
|
- `permission` - Role permissions
|
||||||
|
- `audit_log` - Security audit trail
|
||||||
|
- `session_policy` - IP/subnet-based session timeout rules (CIDR notation)
|
||||||
|
- `app_settings` - Application settings (Microsoft tenant, etc.)
|
||||||
|
- `email_template` - Customizable email templates
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
```
|
||||||
|
/lib/
|
||||||
|
├── auth.ts # Better Auth server config
|
||||||
|
├── auth-client.ts # Better Auth client
|
||||||
|
└── permissions.ts # RBAC definitions
|
||||||
|
|
||||||
|
/app/api/auth/[...all]/
|
||||||
|
└── route.ts # Better Auth API handler
|
||||||
|
|
||||||
|
/middleware.ts # Route protection
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
```env
|
||||||
|
# Better Auth
|
||||||
|
BETTER_AUTH_SECRET= # Random secret for signing
|
||||||
|
BETTER_AUTH_URL= # Base URL (e.g., http://localhost:3000)
|
||||||
|
|
||||||
|
# Microsoft OAuth (Tenant ID configurable in admin UI)
|
||||||
|
MICROSOFT_CLIENT_ID=
|
||||||
|
MICROSOFT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# SMTP Configuration (for magic links)
|
||||||
|
SMTP_HOST= # SMTP server hostname
|
||||||
|
SMTP_PORT=587 # SMTP port (587 for TLS, 465 for SSL)
|
||||||
|
SMTP_USER= # SMTP username
|
||||||
|
SMTP_PASS= # SMTP password
|
||||||
|
SMTP_FROM= # From email address
|
||||||
|
SMTP_SECURE=false # Use SSL (true for port 465)
|
||||||
|
|
||||||
|
# Default Super-Admin (for initial setup)
|
||||||
|
DEFAULT_ADMIN_EMAIL= # Email for default super-admin account
|
||||||
|
DEFAULT_ADMIN_NAME= # Display name for default super-admin
|
||||||
|
|
||||||
|
# Session Configuration
|
||||||
|
SESSION_TIMEOUT_SECONDS=3600 # Default 1 hour
|
||||||
|
|
||||||
|
# Audit Log Retention
|
||||||
|
AUDIT_LOG_RETENTION_DAYS=120 # Default 120 days
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- Integrate with existing PostgreSQL database (`pg` package)
|
||||||
|
- Use existing `Toaster` from sonner for notifications
|
||||||
|
- Protect existing `/app/admin/*` routes
|
||||||
|
- Add user context to existing components via `useSession()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Success Metrics
|
||||||
|
|
||||||
|
| Metric | Target |
|
||||||
|
|--------|--------|
|
||||||
|
| All routes protected | 100% of routes require authentication |
|
||||||
|
| Admin routes secured | `/admin/*` only accessible to admin/super-admin |
|
||||||
|
| User management functional | Super-admins can perform all CRUD operations |
|
||||||
|
| Auth methods working | Magic link and Microsoft 365 both functional |
|
||||||
|
| 2FA adoption | Available and working for all users |
|
||||||
|
| Audit logging | All auth and user management events logged |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Resolved Questions
|
||||||
|
|
||||||
|
| Question | Resolution |
|
||||||
|
|----------|------------|
|
||||||
|
| Email provider | Pure SMTP - configurable via environment variables |
|
||||||
|
| Microsoft tenant | Configurable in admin UI - supports specific tenant or 'common' |
|
||||||
|
| Initial super-admin | Default account via env vars, forced reconfiguration on first login |
|
||||||
|
| Session duration | 1 hour default, configurable per source IP/subnet for trusted networks |
|
||||||
|
| Audit log retention | 120 days default, automatic purge of older records |
|
||||||
|
|
||||||
|
## 10. Resolved Questions (Continued)
|
||||||
|
|
||||||
|
| Question | Resolution |
|
||||||
|
|----------|------------|
|
||||||
|
| Trusted network configuration | CIDR notation (e.g., `192.168.1.0/24`, `10.0.0.0/8`) |
|
||||||
|
| Email templates | Customizable by admins in settings |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Authentication
|
||||||
|
- Install and configure Better Auth
|
||||||
|
- Set up magic link authentication
|
||||||
|
- Set up Microsoft 365 OAuth
|
||||||
|
- Create sign-in page
|
||||||
|
- Implement middleware for route protection
|
||||||
|
|
||||||
|
### Phase 2: RBAC & Permissions
|
||||||
|
- Define permission structure
|
||||||
|
- Implement admin plugin with roles
|
||||||
|
- Protect admin routes
|
||||||
|
- Add role checking to API routes
|
||||||
|
|
||||||
|
### Phase 3: User Management
|
||||||
|
- Create user list page
|
||||||
|
- Implement invite user flow
|
||||||
|
- Create user edit page
|
||||||
|
- Add deactivate/delete functionality
|
||||||
|
|
||||||
|
### Phase 4: Role Management
|
||||||
|
- Create role list page
|
||||||
|
- Implement role creation/editing
|
||||||
|
- Add permission picker UI
|
||||||
|
|
||||||
|
### Phase 5: Security Features
|
||||||
|
- Implement 2FA
|
||||||
|
- Add session management UI
|
||||||
|
- Create audit log system
|
||||||
|
- Add audit log viewer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Permission Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Suggested permission structure
|
||||||
|
const permissions = {
|
||||||
|
// Ticket permissions
|
||||||
|
tickets: ["read", "create", "update", "delete"],
|
||||||
|
|
||||||
|
// Configuration items
|
||||||
|
configItems: ["read", "create", "update", "delete"],
|
||||||
|
|
||||||
|
// Admin sections
|
||||||
|
admin: ["access", "sync", "dataBrowser", "analytics"],
|
||||||
|
|
||||||
|
// User management (super-admin only)
|
||||||
|
users: ["read", "create", "update", "delete", "invite"],
|
||||||
|
|
||||||
|
// Role management (super-admin only)
|
||||||
|
roles: ["read", "create", "update", "delete"],
|
||||||
|
|
||||||
|
// Audit logs
|
||||||
|
auditLog: ["read"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Default roles
|
||||||
|
const defaultRoles = {
|
||||||
|
"super-admin": {
|
||||||
|
// All permissions
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
tickets: ["read", "create", "update", "delete"],
|
||||||
|
configItems: ["read", "create", "update", "delete"],
|
||||||
|
admin: ["access", "sync", "dataBrowser", "analytics"],
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
tickets: ["read", "create", "update"],
|
||||||
|
configItems: ["read"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
209
tasks/tasks-prd-auth-user-management.md
Normal file
209
tasks/tasks-prd-auth-user-management.md
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
# Tasks: Authentication & User Management Module
|
||||||
|
|
||||||
|
## Relevant Files
|
||||||
|
|
||||||
|
### Core Auth Infrastructure
|
||||||
|
- `lib/auth.ts` - Better Auth server configuration with plugins
|
||||||
|
- `lib/auth-client.ts` - Better Auth client for React components
|
||||||
|
- `lib/auth-utils.ts` - Server-side auth utilities and permission checking
|
||||||
|
- `lib/permissions.ts` - RBAC permission definitions and access control
|
||||||
|
- `lib/bootstrap.ts` - Bootstrap and initial setup functions
|
||||||
|
- `lib/services/email.ts` - SMTP email service for magic links
|
||||||
|
- `lib/services/audit.ts` - Audit logging service
|
||||||
|
- `app/api/auth/[...all]/route.ts` - Better Auth API route handler
|
||||||
|
- `middleware.ts` - Route protection middleware
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
- `migrations/012_create_auth_tables.sql` - Better Auth core tables (user, session, account, verification, two_factor)
|
||||||
|
- `migrations/013_create_role_tables.sql` - Role and permission tables with default roles
|
||||||
|
- `migrations/014_create_admin_settings.sql` - App settings, session policies, email templates, and audit log tables
|
||||||
|
|
||||||
|
### Auth Pages
|
||||||
|
- `app/auth/sign-in/page.tsx` - Sign-in page with magic link and Microsoft options
|
||||||
|
- `app/auth/verify/page.tsx` - Magic link verification page
|
||||||
|
- `app/auth/2fa/page.tsx` - Two-factor authentication verification page
|
||||||
|
- `app/auth/setup/page.tsx` - Initial account setup page
|
||||||
|
- `app/auth/layout.tsx` - Auth pages layout (no navigation)
|
||||||
|
|
||||||
|
### Auth Components
|
||||||
|
- `components/auth/auth-provider.tsx` - Auth context provider for React
|
||||||
|
- `components/auth/sign-in-form.tsx` - Combined sign-in form with both auth methods
|
||||||
|
- `components/auth/magic-link-form.tsx` - Email input for magic link
|
||||||
|
- `components/auth/microsoft-button.tsx` - Microsoft 365 OAuth button
|
||||||
|
- `components/auth/two-factor-form.tsx` - TOTP code input form
|
||||||
|
|
||||||
|
### User Management Pages
|
||||||
|
- `app/admin/users/page.tsx` - User list page
|
||||||
|
- `app/admin/users/[id]/page.tsx` - User detail/edit page
|
||||||
|
- `app/admin/users/invite/page.tsx` - Invite user page
|
||||||
|
|
||||||
|
### User Management Components
|
||||||
|
- `components/admin/users/user-table.tsx` - User list table with actions
|
||||||
|
- `components/admin/users/user-form.tsx` - Create/edit user form
|
||||||
|
- `components/admin/users/invite-user-form.tsx` - Invite user via email form
|
||||||
|
- `components/admin/users/role-badge.tsx` - Role display badge
|
||||||
|
- `components/admin/users/user-actions.tsx` - User action dropdown menu
|
||||||
|
- `components/admin/users/user-sessions.tsx` - User session history
|
||||||
|
|
||||||
|
### Role Management Pages
|
||||||
|
- `app/admin/roles/page.tsx` - Role list page
|
||||||
|
- `app/admin/roles/[id]/page.tsx` - Role detail/edit page
|
||||||
|
- `app/admin/roles/new/page.tsx` - Create new role page
|
||||||
|
|
||||||
|
### Role Management Components
|
||||||
|
- `components/admin/roles/role-table.tsx` - Role list table
|
||||||
|
- `components/admin/roles/role-form.tsx` - Create/edit role form
|
||||||
|
- `components/admin/roles/permission-picker.tsx` - Permission selection UI
|
||||||
|
|
||||||
|
### Admin Settings Pages
|
||||||
|
- `app/admin/settings/page.tsx` - Admin settings page with tabs
|
||||||
|
- `app/admin/audit-log/page.tsx` - Audit log viewer page
|
||||||
|
|
||||||
|
### Admin Settings Components
|
||||||
|
- `components/admin/settings/microsoft-config.tsx` - Microsoft tenant configuration
|
||||||
|
- `components/admin/settings/session-policies.tsx` - CIDR session policy management
|
||||||
|
- `components/admin/settings/email-templates.tsx` - Email template editor
|
||||||
|
- `components/admin/audit/audit-log-table.tsx` - Audit log table with filters
|
||||||
|
|
||||||
|
### User Settings Pages
|
||||||
|
- `app/settings/page.tsx` - User settings page
|
||||||
|
- `app/settings/security/page.tsx` - Security settings (2FA, sessions)
|
||||||
|
|
||||||
|
### User Settings Components
|
||||||
|
- `components/settings/profile-form.tsx` - User profile edit form
|
||||||
|
- `components/settings/two-factor-setup.tsx` - 2FA setup wizard
|
||||||
|
- `components/settings/active-sessions.tsx` - Current user's active sessions
|
||||||
|
|
||||||
|
### API Routes
|
||||||
|
- `app/api/admin/users/route.ts` - User CRUD API
|
||||||
|
- `app/api/admin/users/[id]/route.ts` - Single user API
|
||||||
|
- `app/api/admin/users/invite/route.ts` - User invitation API
|
||||||
|
- `app/api/admin/roles/route.ts` - Role CRUD API
|
||||||
|
- `app/api/admin/roles/[id]/route.ts` - Single role API
|
||||||
|
- `app/api/admin/settings/route.ts` - App settings API
|
||||||
|
- `app/api/admin/settings/session-policies/route.ts` - Session policies API
|
||||||
|
- `app/api/admin/settings/email-templates/route.ts` - Email templates API
|
||||||
|
- `app/api/admin/audit-log/route.ts` - Audit log API
|
||||||
|
- `app/api/settings/profile/route.ts` - User profile API
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- Unit tests should typically be placed alongside the code files they are testing (e.g., `MyComponent.tsx` and `MyComponent.test.tsx` in the same directory).
|
||||||
|
- Use `npx jest [optional/path/to/test/file]` to run tests. Running without a path executes all tests found by the Jest configuration.
|
||||||
|
- Better Auth handles most auth logic internally; focus tests on custom business logic.
|
||||||
|
- Use existing shadcn/ui components from `/components/ui/` for consistency.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1.0 Setup Better Auth Core Infrastructure
|
||||||
|
- [x] 1.1 Install Better Auth dependencies (`better-auth`, `@better-auth/cli`)
|
||||||
|
- [x] 1.2 Add required environment variables to `.env` and `.env.local` (BETTER_AUTH_SECRET, BETTER_AUTH_URL, SMTP_*, DEFAULT_ADMIN_*, SESSION_TIMEOUT_SECONDS, AUDIT_LOG_RETENTION_DAYS)
|
||||||
|
- [x] 1.3 Create database migration for Better Auth core tables (user, session, account, verification)
|
||||||
|
- [x] 1.4 Create `lib/auth.ts` with Better Auth server configuration
|
||||||
|
- [x] 1.5 Create `lib/auth-client.ts` with Better Auth client for React
|
||||||
|
- [x] 1.6 Create `app/api/auth/[...all]/route.ts` API route handler
|
||||||
|
- [x] 1.7 Create `middleware.ts` for route protection (redirect unauthenticated users to /auth/sign-in)
|
||||||
|
- [x] 1.8 Create `lib/services/email.ts` SMTP email service using environment variables
|
||||||
|
- [x] 1.9 Update `app/layout.tsx` to wrap app with auth session provider
|
||||||
|
|
||||||
|
- [x] 2.0 Implement Authentication Methods (Magic Link & Microsoft 365)
|
||||||
|
- [x] 2.1 Configure magic link plugin in `lib/auth.ts` with sendMagicLink callback using SMTP service
|
||||||
|
- [x] 2.2 Configure Microsoft OAuth provider in `lib/auth.ts` with client ID/secret from env
|
||||||
|
- [x] 2.3 Create `app/auth/layout.tsx` - minimal layout without main navigation
|
||||||
|
- [x] 2.4 Create `app/auth/sign-in/page.tsx` - sign-in page container
|
||||||
|
- [x] 2.5 Create `components/auth/magic-link-form.tsx` - email input with submit
|
||||||
|
- [x] 2.6 Create `components/auth/microsoft-button.tsx` - Microsoft 365 OAuth button
|
||||||
|
- [x] 2.7 Create `components/auth/sign-in-form.tsx` - combined form with both methods
|
||||||
|
- [x] 2.8 Create `app/auth/verify/page.tsx` - magic link verification handler
|
||||||
|
- [x] 2.9 Implement "check your email" success state in magic link form
|
||||||
|
- [x] 2.10 Add error handling for failed auth attempts with toast notifications
|
||||||
|
|
||||||
|
- [x] 3.0 Implement RBAC & Permission System
|
||||||
|
- [x] 3.1 Create database migration for role and permission tables
|
||||||
|
- [x] 3.2 Create `lib/permissions.ts` with permission definitions (tickets, configItems, admin, users, roles, auditLog)
|
||||||
|
- [x] 3.3 Define default roles (super-admin, admin, user) with their permissions
|
||||||
|
- [x] 3.4 Configure Better Auth admin plugin with access control in `lib/auth.ts`
|
||||||
|
- [x] 3.5 Update `lib/auth-client.ts` with admin client plugin
|
||||||
|
- [x] 3.6 Create helper function `hasPermission(user, resource, action)` for checking permissions
|
||||||
|
- [x] 3.7 Update `middleware.ts` to check roles for `/admin/*` routes (require admin or super-admin)
|
||||||
|
- [x] 3.8 Create API middleware helper for permission checking in route handlers
|
||||||
|
- [x] 3.9 Seed default roles into database on first run
|
||||||
|
|
||||||
|
- [x] 4.0 Build User Management Module
|
||||||
|
- [x] 4.1 Create `app/api/admin/users/route.ts` - GET (list users), POST (create user)
|
||||||
|
- [x] 4.2 Create `app/api/admin/users/[id]/route.ts` - GET, PATCH, DELETE single user
|
||||||
|
- [x] 4.3 Create `app/api/admin/users/invite/route.ts` - POST to send invitation email
|
||||||
|
- [x] 4.4 Create `components/admin/users/role-badge.tsx` - badge component for role display
|
||||||
|
- [x] 4.5 Create `components/admin/users/user-table.tsx` - table with columns: name, email, role, status, actions
|
||||||
|
- [x] 4.6 Create `components/admin/users/user-actions.tsx` - dropdown with edit, deactivate, delete, revoke sessions
|
||||||
|
- [x] 4.7 Create `app/admin/users/page.tsx` - user list page with search/filter
|
||||||
|
- [x] 4.8 Create `components/admin/users/user-form.tsx` - form for editing user (name, email, role)
|
||||||
|
- [x] 4.9 Create `app/admin/users/[id]/page.tsx` - user detail page with edit form and session history
|
||||||
|
- [x] 4.10 Create `components/admin/users/invite-user-form.tsx` - email input with role selection
|
||||||
|
- [x] 4.11 Create `app/admin/users/invite/page.tsx` - invite user page
|
||||||
|
- [x] 4.12 Create `components/admin/users/user-sessions.tsx` - table of user's sessions with revoke
|
||||||
|
- [x] 4.13 Implement soft delete (deactivate) and reactivate functionality
|
||||||
|
- [x] 4.14 Implement permanent delete with confirmation dialog
|
||||||
|
- [x] 4.15 Add super-admin role check to all user management routes
|
||||||
|
|
||||||
|
- [x] 5.0 Build Role Management Module
|
||||||
|
- [x] 5.1 Create `app/api/admin/roles/route.ts` - GET (list roles), POST (create role)
|
||||||
|
- [x] 5.2 Create `app/api/admin/roles/[id]/route.ts` - GET, PATCH, DELETE single role
|
||||||
|
- [x] 5.3 Create `components/admin/roles/permission-picker.tsx` - checkbox grid for selecting permissions
|
||||||
|
- [x] 5.4 Create `components/admin/roles/role-table.tsx` - table with columns: name, permissions count, users count, actions
|
||||||
|
- [x] 5.5 Create `app/admin/roles/page.tsx` - role list page
|
||||||
|
- [x] 5.6 Create `components/admin/roles/role-form.tsx` - form with name input and permission picker
|
||||||
|
- [x] 5.7 Create `app/admin/roles/new/page.tsx` - create new role page
|
||||||
|
- [x] 5.8 Create `app/admin/roles/[id]/page.tsx` - edit role page
|
||||||
|
- [x] 5.9 Prevent deletion of default roles (super-admin, admin, user)
|
||||||
|
- [x] 5.10 Prevent deletion of roles that are assigned to users (show error)
|
||||||
|
- [x] 5.11 Add super-admin role check to all role management routes
|
||||||
|
|
||||||
|
- [x] 6.0 Implement Admin Settings (Microsoft Tenant, Session Policies, Email Templates)
|
||||||
|
- [x] 6.1 Create database migration for app_settings table
|
||||||
|
- [x] 6.2 Create database migration for session_policy table (name, cidr, timeout_seconds, priority)
|
||||||
|
- [x] 6.3 Create database migration for email_template table (type, subject, body_html)
|
||||||
|
- [x] 6.4 Create `app/api/admin/settings/route.ts` - GET/PATCH app settings
|
||||||
|
- [x] 6.5 Create `app/api/admin/settings/session-policies/route.ts` - CRUD for session policies
|
||||||
|
- [x] 6.6 Create `app/api/admin/settings/email-templates/route.ts` - GET/PATCH email templates
|
||||||
|
- [x] 6.7 Create `components/admin/settings/microsoft-config.tsx` - tenant ID input with validation
|
||||||
|
- [x] 6.8 Create `components/admin/settings/session-policies.tsx` - table with add/edit/delete for CIDR policies
|
||||||
|
- [x] 6.9 Implement CIDR notation validation (e.g., `192.168.1.0/24`)
|
||||||
|
- [x] 6.10 Create `components/admin/settings/email-templates.tsx` - template editor with variable hints
|
||||||
|
- [x] 6.11 Create `app/admin/settings/page.tsx` - settings page with tabs (Microsoft, Sessions, Email)
|
||||||
|
- [x] 6.12 Seed default email templates (magic_link, invitation) on first run
|
||||||
|
- [x] 6.13 Update auth config to dynamically load Microsoft tenant from app_settings
|
||||||
|
- [x] 6.14 Update session creation to check CIDR policies and set appropriate timeout
|
||||||
|
|
||||||
|
- [x] 7.0 Implement Security Features (2FA, Session Management, Audit Logging)
|
||||||
|
- [x] 7.1 Configure two-factor plugin in `lib/auth.ts`
|
||||||
|
- [x] 7.2 Update `lib/auth-client.ts` with two-factor client plugin
|
||||||
|
- [x] 7.3 Create `components/auth/two-factor-form.tsx` - TOTP code input
|
||||||
|
- [x] 7.4 Create `app/auth/2fa/page.tsx` - 2FA verification page
|
||||||
|
- [x] 7.5 Create `components/settings/two-factor-setup.tsx` - QR code display and verification
|
||||||
|
- [x] 7.6 Create `components/settings/active-sessions.tsx` - current user's sessions with revoke
|
||||||
|
- [x] 7.7 Create `app/settings/page.tsx` - user settings with profile tab
|
||||||
|
- [x] 7.8 Create `app/settings/security/page.tsx` - security settings (2FA toggle, sessions)
|
||||||
|
- [x] 7.9 Create database migration for audit_log table (timestamp, user_id, action, resource, details, ip_address)
|
||||||
|
- [x] 7.10 Create `lib/services/audit.ts` - audit logging service with log() function
|
||||||
|
- [x] 7.11 Add audit logging to auth events (sign-in, sign-out, failed attempts)
|
||||||
|
- [x] 7.12 Add audit logging to user management actions (create, edit, delete, role change)
|
||||||
|
- [x] 7.13 Add audit logging to role management actions
|
||||||
|
- [x] 7.14 Create `app/api/admin/audit-log/route.ts` - GET with pagination and filters
|
||||||
|
- [x] 7.15 Create `components/admin/audit/audit-log-table.tsx` - table with filters (date range, user, action)
|
||||||
|
- [x] 7.16 Create `app/admin/audit-log/page.tsx` - audit log viewer page
|
||||||
|
- [x] 7.17 Implement audit log retention (delete records older than AUDIT_LOG_RETENTION_DAYS)
|
||||||
|
- [x] 7.18 Create scheduled job or API endpoint to purge old audit logs
|
||||||
|
- [x] 7.19 Implement rate limiting on auth endpoints (sign-in, magic-link)
|
||||||
|
|
||||||
|
- [x] 8.0 Implement Bootstrap & Initial Setup Flow
|
||||||
|
- [x] 8.1 Create bootstrap check function to detect if any users exist
|
||||||
|
- [x] 8.2 Create seed script to create default super-admin from DEFAULT_ADMIN_EMAIL and DEFAULT_ADMIN_NAME env vars
|
||||||
|
- [x] 8.3 Add `requires_setup` flag to user table for accounts needing reconfiguration
|
||||||
|
- [x] 8.4 Create setup detection in middleware - redirect setup accounts to `/auth/setup`
|
||||||
|
- [x] 8.5 Create `app/auth/setup/page.tsx` - forced setup page for default account
|
||||||
|
- [x] 8.6 Create `components/auth/setup-form.tsx` - form to link Microsoft account or set up magic link
|
||||||
|
- [x] 8.7 Clear `requires_setup` flag after successful setup completion
|
||||||
|
- [x] 8.8 Display warning banner for setup accounts until reconfigured
|
||||||
|
- [x] 8.9 Run bootstrap/seed on application startup if no users exist
|
||||||
|
- [x] 8.10 Add documentation for initial setup process in README
|
||||||
Loading…
Add table
Add a link
Reference in a new issue