Fixed AI. Optimized. Colour/Theme fixed. Added Forgot Password. Cuba theme — Added to client portal settings. Topbar search. Search, loading states
Build & Auto-Repair / build (push) Has been cancelled
Build & Auto-Repair / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import crypto from "crypto"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { getUserByEmail } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email } = await request.json()
|
||||||
|
if (!email || typeof email !== "string") {
|
||||||
|
return NextResponse.json({ error: "Email is required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByEmail(email)
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ message: "If that email exists, a reset link has been generated." })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = crypto.randomBytes(32).toString("hex")
|
||||||
|
const expiresAt = new Date(Date.now() + 60 * 60 * 1000) // 1 hour
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES ($1, $2, $3)`,
|
||||||
|
[user.id, token, expiresAt],
|
||||||
|
)
|
||||||
|
|
||||||
|
const resetUrl = `${request.nextUrl.origin}/reset-password/${token}`
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "If that email exists, a reset link has been generated.", resetUrl })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Forgot password error:", error)
|
||||||
|
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { hashPassword } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token, password } = await request.json()
|
||||||
|
|
||||||
|
if (!token || !password) {
|
||||||
|
return NextResponse.json({ error: "Token and password are required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
return NextResponse.json({ error: "Password must be at least 6 characters" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT id, user_id, expires_at FROM password_reset_tokens
|
||||||
|
WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()`,
|
||||||
|
[token],
|
||||||
|
)
|
||||||
|
|
||||||
|
const row = result.rows[0]
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password)
|
||||||
|
|
||||||
|
await query(`UPDATE users SET password_hash = $1, password_change_required = FALSE WHERE id = $2`, [passwordHash, row.user_id])
|
||||||
|
await query(`UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, [row.id])
|
||||||
|
|
||||||
|
return NextResponse.json({ message: "Password has been reset successfully." })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Reset password error:", error)
|
||||||
|
return NextResponse.json({ error: "Something went wrong" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const currentUser = await getSessionUser()
|
||||||
|
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const q = request.nextUrl.searchParams.get("q") || ""
|
||||||
|
if (!q.trim()) return NextResponse.json({ results: [] })
|
||||||
|
|
||||||
|
const like = `%${q}%`
|
||||||
|
|
||||||
|
const [leadsResult, projectsResult, proposalsResult, conversationsResult] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`SELECT id, company_name AS title, 'lead' AS type FROM leads WHERE deleted_at IS NULL AND company_name ILIKE $1 LIMIT 5`,
|
||||||
|
[like],
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`SELECT id, name AS title, 'project' AS type FROM projects WHERE deleted_at IS NULL AND name ILIKE $1 LIMIT 5`,
|
||||||
|
[like],
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`SELECT id, title, 'proposal' AS type FROM proposals WHERE deleted_at IS NULL AND title ILIKE $1 LIMIT 5`,
|
||||||
|
[like],
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`SELECT id, title, 'conversation' AS type FROM conversations WHERE title ILIKE $1 LIMIT 5`,
|
||||||
|
[like],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const results = [
|
||||||
|
...leadsResult.rows,
|
||||||
|
...projectsResult.rows,
|
||||||
|
...proposalsResult.rows,
|
||||||
|
...conversationsResult.rows,
|
||||||
|
]
|
||||||
|
|
||||||
|
return NextResponse.json({ results })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search error:", error)
|
||||||
|
return NextResponse.json({ error: "Search failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,12 +22,14 @@ interface Event {
|
|||||||
export default function ClientActivity() {
|
export default function ClientActivity() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/client-portal/api/activity", { credentials: "include" })
|
fetch("/client-portal/api/activity", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setEvents(d.events || []))
|
.then((d) => setEvents(d.events || []))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch activity:", err))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
const getIcon = (type: string) => {
|
||||||
@@ -57,6 +59,18 @@ export default function ClientActivity() {
|
|||||||
return date.toLocaleDateString([], { month: "short", day: "numeric" })
|
return date.toLocaleDateString([], { month: "short", day: "numeric" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">Recent project updates and changes</p>
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
|
<h1 className="text-xl font-bold text-foreground mb-1">Activity</h1>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface Deadline {
|
|||||||
|
|
||||||
export default function ClientDashboard() {
|
export default function ClientDashboard() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 })
|
const [counts, setCounts] = useState({ projects: 0, invoices: 0, tickets: 0 })
|
||||||
const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] })
|
const [stats, setStats] = useState({ totalBilled: 0, totalPaid: 0, totalOverdue: 0, upcomingDeadlines: [] as Deadline[] })
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
@@ -21,30 +22,32 @@ export default function ClientDashboard() {
|
|||||||
const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject()
|
const safeJson = (r: Response) => r.ok ? r.json() : Promise.reject()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
Promise.allSettled([
|
||||||
fetch("/client-portal/api/projects", { credentials: "include" })
|
fetch("/client-portal/api/projects", { credentials: "include" })
|
||||||
.then(safeJson)
|
.then(safeJson)
|
||||||
.then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 })))
|
.then((d) => setCounts((prev) => ({ ...prev, projects: d.projects?.length || 0 })))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch projects:", err)),
|
||||||
|
|
||||||
fetch("/client-portal/api/invoices", { credentials: "include" })
|
fetch("/client-portal/api/invoices", { credentials: "include" })
|
||||||
.then(safeJson)
|
.then(safeJson)
|
||||||
.then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 })))
|
.then((d) => setCounts((prev) => ({ ...prev, invoices: d.invoices?.length || 0 })))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch invoices:", err)),
|
||||||
|
|
||||||
fetch("/client-portal/api/support", { credentials: "include" })
|
fetch("/client-portal/api/support", { credentials: "include" })
|
||||||
.then(safeJson)
|
.then(safeJson)
|
||||||
.then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 })))
|
.then((d) => setCounts((prev) => ({ ...prev, tickets: d.tickets?.length || 0 })))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch tickets:", err)),
|
||||||
|
|
||||||
fetch("/client-portal/api/dashboard/stats", { credentials: "include" })
|
fetch("/client-portal/api/dashboard/stats", { credentials: "include" })
|
||||||
.then(safeJson)
|
.then(safeJson)
|
||||||
.then((d) => setStats(d))
|
.then((d) => setStats(d))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch stats:", err)),
|
||||||
|
|
||||||
fetch("/api/auth/me", { credentials: "include" })
|
fetch("/api/auth/me", { credentials: "include" })
|
||||||
.then(safeJson)
|
.then(safeJson)
|
||||||
.then((d) => setName(d.user?.firstName || d.user?.email || "Client"))
|
.then((d) => setName(d.user?.firstName || d.user?.email || "Client"))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch user:", err)),
|
||||||
|
]).finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const cards = [
|
const cards = [
|
||||||
@@ -58,6 +61,14 @@ export default function ClientDashboard() {
|
|||||||
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground mb-1">Welcome, {name}</h1>
|
<h1 className="text-xl font-bold text-foreground mb-1">Welcome, {name}</h1>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export default function ClientLoginPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [checking, setChecking] = useState(true)
|
const [checking, setChecking] = useState(true)
|
||||||
const [debugInfo, setDebugInfo] = useState("")
|
|
||||||
|
|
||||||
const [sessionUser, setSessionUser] = useState<{ email: string; role: string } | null>(null)
|
const [sessionUser, setSessionUser] = useState<{ email: string; role: string } | null>(null)
|
||||||
|
|
||||||
@@ -38,7 +37,6 @@ export default function ClientLoginPage() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError("")
|
setError("")
|
||||||
setDebugInfo("")
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/login", {
|
const res = await fetch("/api/auth/login", {
|
||||||
@@ -48,27 +46,21 @@ export default function ClientLoginPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
console.log("LOGIN RESPONSE:", res.status, data)
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || "Login failed")
|
setError(data.error || "Login failed")
|
||||||
setDebugInfo(`HTTP ${res.status}`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.user?.role !== "client") {
|
if (data.user?.role !== "client") {
|
||||||
setError("This portal is for clients only")
|
setError("This portal is for clients only")
|
||||||
setDebugInfo(`User role: ${data.user?.role}`)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setError("Login successful! Redirecting...")
|
setError("Login successful! Redirecting...")
|
||||||
setDebugInfo("")
|
|
||||||
setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300)
|
setTimeout(() => { window.location.href = "/client-portal/dashboard" }, 300)
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error("LOGIN FETCH ERROR:", err)
|
|
||||||
setError("Connection error")
|
setError("Connection error")
|
||||||
setDebugInfo("Check network tab for details")
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -147,7 +139,6 @@ export default function ClientLoginPage() {
|
|||||||
{error !== "Login successful! Redirecting..." && <AlertCircle className="h-4 w-4 shrink-0" />}
|
{error !== "Login successful! Redirecting..." && <AlertCircle className="h-4 w-4 shrink-0" />}
|
||||||
<span className="font-medium">{error}</span>
|
<span className="font-medium">{error}</span>
|
||||||
</div>
|
</div>
|
||||||
{debugInfo && <div className="mt-1 text-xs opacity-60">{debugInfo}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ClientProfile() {
|
|||||||
setProfile(d.profile || null)
|
setProfile(d.profile || null)
|
||||||
setContacts(d.contacts || [])
|
setContacts(d.contacts || [])
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch profile:", err))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
|
|||||||
@@ -18,14 +18,28 @@ interface Project {
|
|||||||
export default function ClientProjects() {
|
export default function ClientProjects() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/client-portal/api/projects", { credentials: "include" })
|
fetch("/client-portal/api/projects", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setProjects(d.projects || []))
|
.then((d) => setProjects(d.projects || []))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch projects:", err))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">View your project milestones and progress</p>
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
|
<h1 className="text-xl font-bold text-foreground mb-1">Projects</h1>
|
||||||
|
|||||||
@@ -15,14 +15,28 @@ interface Receipt {
|
|||||||
|
|
||||||
export default function ClientReceipts() {
|
export default function ClientReceipts() {
|
||||||
const [receipts, setReceipts] = useState<Receipt[]>([])
|
const [receipts, setReceipts] = useState<Receipt[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/client-portal/api/receipts", { credentials: "include" })
|
fetch("/client-portal/api/receipts", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setReceipts(d.receipts || []))
|
.then((d) => setReceipts(d.receipts || []))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch receipts:", err))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">View all your payment receipts in one place</p>
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
|
<h1 className="text-xl font-bold text-foreground mb-1">Receipts</h1>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const bgOptions = [
|
|||||||
{ value: "bw", label: "Black & White", color: "bg-gradient-to-r from-gray-900 to-gray-400", ring: "ring-gray-500", desc: "Clean monochrome" },
|
{ value: "bw", label: "Black & White", color: "bg-gradient-to-r from-gray-900 to-gray-400", ring: "ring-gray-500", desc: "Clean monochrome" },
|
||||||
{ value: "forest", label: "Forest", color: "bg-gradient-to-r from-green-700 to-green-400", ring: "ring-green-600", desc: "Earthy greens with tree silhouettes" },
|
{ value: "forest", label: "Forest", color: "bg-gradient-to-r from-green-700 to-green-400", ring: "ring-green-600", desc: "Earthy greens with tree silhouettes" },
|
||||||
{ value: "luminous", label: "Luminous", color: "bg-gradient-to-r from-blue-500 to-teal-400", ring: "ring-blue-500", desc: "Deep Glass 2.0 with specular highlights" },
|
{ value: "luminous", label: "Luminous", color: "bg-gradient-to-r from-blue-500 to-teal-400", ring: "ring-blue-500", desc: "Deep Glass 2.0 with specular highlights" },
|
||||||
|
{ value: "cuba", label: "Cuba", color: "bg-gradient-to-r from-orange-500 to-turquoise-400", ring: "ring-orange-500", desc: "Classic Cuban car on a playa at golden hour" },
|
||||||
]
|
]
|
||||||
|
|
||||||
function getStoredColorTheme(): string {
|
function getStoredColorTheme(): string {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function ClientSupport() {
|
|||||||
fetch("/client-portal/api/support", { credentials: "include" })
|
fetch("/client-portal/api/support", { credentials: "include" })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setTickets(d.tickets || []))
|
.then((d) => setTickets(d.tickets || []))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch tickets:", err))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Mail, ArrowLeft, CheckCircle, AlertCircle, Loader2 } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [sent, setSent] = useState(false)
|
||||||
|
const [resetUrl, setResetUrl] = useState("")
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/forgot-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Something went wrong")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSent(true)
|
||||||
|
if (data.resetUrl) setResetUrl(data.resetUrl)
|
||||||
|
} catch {
|
||||||
|
setError("Connection error")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm text-center">
|
||||||
|
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-2">Check your email</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
If an account with that email exists, we've sent a password reset link.
|
||||||
|
</p>
|
||||||
|
{resetUrl && (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4 mb-6 text-left">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">Development mode — reset link:</p>
|
||||||
|
<a href={resetUrl} className="text-sm text-primary break-all hover:underline">{resetUrl}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<Link href="/login" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-2">Reset your password</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Enter your email and we'll send you a reset link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-sm text-destructive bg-destructive/5 border border-destructive/10 rounded-lg px-3 py-2">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-muted-foreground mb-1.5">Email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full bg-muted border border-border rounded-lg pl-10 pr-3 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? "Sending..." : "Send reset link"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, Suspense } from "react"
|
import { useState, useEffect, useRef, Suspense } from "react"
|
||||||
import { useSearchParams, useRouter } from "next/navigation"
|
import { useSearchParams, useRouter } from "next/navigation"
|
||||||
|
import Link from "next/link"
|
||||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
const WEBSITE_THEME_KEY = "crm-website-theme"
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
@@ -384,9 +385,9 @@ function LoginForm() {
|
|||||||
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
|
<Link href="/forgot-password" className="text-[12px] text-[#1BB0CE] hover:underline">
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="input-sheen input-sheen-delayed">
|
<div className="input-sheen input-sheen-delayed">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useParams, useRouter } from "next/navigation"
|
||||||
|
import { Eye, EyeOff, AlertCircle, CheckCircle, Loader2 } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const { token } = useParams<{ token: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [done, setDone] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("Password must be at least 6 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/reset-password", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, password }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error || "Something went wrong")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setDone(true)
|
||||||
|
} catch {
|
||||||
|
setError("Connection error")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm text-center">
|
||||||
|
<CheckCircle className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-2">Password reset</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Your password has been reset successfully.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="inline-block bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-6 py-2.5 text-sm font-medium transition-all"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-background items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h1 className="text-xl font-bold text-foreground mb-2">Set new password</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">Enter your new password below.</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-sm text-destructive bg-destructive/5 border border-destructive/10 rounded-lg px-3 py-2">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-muted-foreground mb-1.5">New password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full bg-muted border border-border rounded-lg px-3 py-2.5 pr-10 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
|
||||||
|
placeholder="Min 6 characters"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-muted-foreground mb-1.5">Confirm password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="w-full bg-muted border border-border rounded-lg px-3 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
|
||||||
|
placeholder="Repeat password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-primary hover:bg-primary/80 text-primary-foreground rounded-lg px-4 py-2.5 text-sm font-medium transition-all disabled:opacity-40 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? "Resetting..." : "Reset password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center mt-6">
|
||||||
|
<Link href="/login" className="text-sm text-primary hover:underline">
|
||||||
|
Back to login
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -31,6 +31,11 @@ import {
|
|||||||
Bug,
|
Bug,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Building2,
|
||||||
|
FolderKanban,
|
||||||
|
FileText,
|
||||||
|
MessageSquare,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useWebsiteTheme } from "@/providers/website-theme-provider";
|
import { useWebsiteTheme } from "@/providers/website-theme-provider";
|
||||||
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
|
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
|
||||||
@@ -48,11 +53,60 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
useNotifications();
|
useNotifications();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<{ id: number; title: string; type: string }[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [showResults, setShowResults] = useState(false);
|
||||||
|
const searchRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const [bugModalOpen, setBugModalOpen] = useState(false);
|
const [bugModalOpen, setBugModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
||||||
|
setShowResults(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const doSearch = useCallback(async (q: string) => {
|
||||||
|
if (!q.trim()) { setResults([]); setSearching(false); return }
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`)
|
||||||
|
const data = await res.json()
|
||||||
|
setResults(data.results || [])
|
||||||
|
} catch { setResults([]) }
|
||||||
|
setSearching(false)
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearchInput = useCallback((value: string) => {
|
||||||
|
setQuery(value)
|
||||||
|
setShowResults(true)
|
||||||
|
if (searchTimer.current) clearTimeout(searchTimer.current)
|
||||||
|
if (!value.trim()) { setResults([]); return }
|
||||||
|
searchTimer.current = setTimeout(() => doSearch(value), 250)
|
||||||
|
}, [doSearch]);
|
||||||
|
|
||||||
|
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setShowResults(false)
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigateTo = useCallback((type: string, id: number) => {
|
||||||
|
setShowResults(false)
|
||||||
|
setQuery("")
|
||||||
|
setResults([])
|
||||||
|
const paths: Record<string, string> = { lead: `/leads/${id}`, project: `/projects/${id}`, proposal: `/proposals/${id}`, conversation: `/conversations/${id}` }
|
||||||
|
router.push(paths[type] || "/")
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
const typeIcons: Record<string, typeof Building2> = { lead: Building2, project: FolderKanban, proposal: FileText, conversation: MessageSquare }
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
function formatTimeAgo(ts: string): string {
|
function formatTimeAgo(ts: string): string {
|
||||||
@@ -93,12 +147,43 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative hidden flex-1 sm:block md:w-80">
|
<div className="relative hidden flex-1 sm:block md:w-80" ref={searchRef}>
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => handleSearchInput(e.target.value)}
|
||||||
|
onKeyDown={handleSearchKeyDown}
|
||||||
|
onFocus={() => query.trim() && setShowResults(true)}
|
||||||
placeholder="Search leads, companies..."
|
placeholder="Search leads, companies..."
|
||||||
className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground"
|
className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground"
|
||||||
/>
|
/>
|
||||||
|
{showResults && (results.length > 0 || searching) && (
|
||||||
|
<div className="absolute left-0 right-0 top-full mt-1 bg-card border border-border rounded-xl shadow-xl overflow-hidden z-50">
|
||||||
|
{searching ? (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
results.map((r) => {
|
||||||
|
const Icon = typeIcons[r.type] || Building2
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${r.type}-${r.id}`}
|
||||||
|
onClick={() => navigateTo(r.type, r.id)}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left hover:bg-muted/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-foreground truncate">{r.title}</span>
|
||||||
|
<span className="ml-auto text-[10px] uppercase tracking-wider text-muted-foreground/60">{r.type}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-end gap-3">
|
<div className="flex flex-1 items-center justify-end gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user