Added 4 Demo Users, with Coast emails, Hierarchy, roles etc.

This commit is contained in:
2026-06-18 13:13:29 +02:00
parent 3f839bc0fc
commit 5238b59140
22 changed files with 973 additions and 286 deletions
+1
View File
@@ -47,6 +47,7 @@ export default function ChatsPage() {
const resizeStartRef = useRef({ x: 0, width: 0 })
const [conversations, setConversations] = useState(conversationsData)
if (!user) return null
const conversation = conversations.find((c) => c.id === activeChat)
const otherParticipant = (conv: typeof conversationsData[0]) =>
conv.participants.find((p) => p.id !== "user1") ?? conv.participants[0]
+19 -2
View File
@@ -1,7 +1,24 @@
"use client"
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider } from "@/providers/user-provider"
import { UserProvider, useUser } from "@/providers/user-provider"
import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser()
if (loading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
if (!user) return null
return <AppShell>{children}</AppShell>
}
export default function DashboardLayout({
children,
@@ -10,7 +27,7 @@ export default function DashboardLayout({
}) {
return (
<UserProvider>
<AppShell>{children}</AppShell>
<DashboardContent>{children}</DashboardContent>
</UserProvider>
)
}
+4 -2
View File
@@ -2,6 +2,7 @@
import Link from "next/link"
import { motion } from "framer-motion"
import { use } from "react"
import { PageHeader } from "@/components/shared/page-header"
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
@@ -19,8 +20,9 @@ import { getLeadById } from "@/data/leads"
import { getNotesByLeadId } from "@/data/notes"
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
export default function LeadDetailsPage({ params }: { params: { id: string } }) {
const lead = getLeadById(params.id)
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const lead = getLeadById(id)
const leadNotes = lead ? getNotesByLeadId(lead.id) : []
if (!lead) {
+1
View File
@@ -11,6 +11,7 @@ import { toast } from "sonner"
export default function ProfilePage() {
const { user, updateAvatar } = useUser()
if (!user) return null
const fileInputRef = useRef<HTMLInputElement>(null)
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+120
View File
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server"
import {
comparePassword,
getUserByEmail,
getUserByUsername,
mapDbUserToSessionUser,
recordLoginAttempt,
incrementFailedAttempts,
resetFailedAttempts,
isAccountLocked,
createSession,
} from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const { email, username, password } = await request.json()
const credential = email || username
if (!credential || !password) {
return NextResponse.json(
{ error: "Email/Username and password are required." },
{ status: 400 }
)
}
if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json(
{ error: "Credentials cannot be empty." },
{ status: 400 }
)
}
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
"127.0.0.1"
const userAgent = request.headers.get("user-agent") || null
// Try to find user by email first, then by username
let dbUser =
email || credential.includes("@")
? await getUserByEmail(credential)
: null
if (!dbUser) {
dbUser = await getUserByUsername(credential)
}
if (!dbUser) {
await recordLoginAttempt(
null,
credential,
ipAddress,
userAgent,
false,
"User not found"
)
return NextResponse.json(
{ error: "Invalid email/username or password." },
{ status: 401 }
)
}
const lockStatus = await isAccountLocked(dbUser)
if (lockStatus.locked) {
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
false,
lockStatus.reason
)
return NextResponse.json(
{ error: lockStatus.reason },
{ status: 423 }
)
}
const valid = await comparePassword(password, dbUser.password_hash)
if (!valid) {
await incrementFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
false,
"Invalid password"
)
return NextResponse.json(
{ error: "Invalid email/username or password." },
{ status: 401 }
)
}
await resetFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
credential,
ipAddress,
userAgent,
true
)
await createSession(dbUser.id, dbUser.role_name)
const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 })
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable. Please ensure PostgreSQL is running and configured." },
{ status: 503 }
)
}
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server"
import { destroySession } from "@/lib/auth"
export async function POST() {
try {
await destroySession()
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
console.error("Logout error:", error)
return NextResponse.json(
{ error: "Logout failed." },
{ status: 500 }
)
}
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
export async function GET() {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
return NextResponse.json({ user }, { status: 200 })
} catch (error) {
console.error("Auth me error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ status: 503 }
)
}
}
+87 -72
View File
@@ -1,32 +1,107 @@
"use client"
import { useState } from "react"
import { useRouter } from "next/navigation"
import { Suspense, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { motion } from "framer-motion"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import { COMPANY_NAME } from "@/lib/constants"
import { Eye, EyeOff, Loader2 } from "lucide-react"
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react"
export default function LoginPage() {
function LoginForm() {
const router = useRouter()
const [email, setEmail] = useState("admin@coastalit.com")
const searchParams = useSearchParams()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [remember, setRemember] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
setLoading(true)
await new Promise((resolve) => setTimeout(resolve, 1200))
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
})
router.push("/dashboard")
const data = await res.json()
if (!res.ok) {
setError(data.error || "Authentication failed.")
setLoading(false)
return
}
const redirect = searchParams.get("redirect") || "/dashboard"
router.push(redirect)
} catch {
setError("Unable to connect to authentication service. Please ensure the database is running.")
setLoading(false)
}
}
return (
<>
{error && (
<div className="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
<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>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
</>
)
}
export default function LoginPage() {
return (
<div className="flex min-h-screen">
{/* Left - Brand panel */}
@@ -110,69 +185,9 @@ export default function LoginPage() {
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<button
type="button"
className="text-xs text-primary hover:underline"
>
Forgot password?
</button>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
<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 className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={remember}
onCheckedChange={(checked) => setRemember(checked as boolean)}
/>
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Remember me
</label>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</Button>
</form>
<Suspense fallback={<div className="text-center text-muted-foreground py-8">Loading...</div>}>
<LoginForm />
</Suspense>
<p className="text-center text-xs text-muted-foreground">
By signing in, you agree to our{" "}
+1
View File
@@ -42,6 +42,7 @@ interface SidebarProps {
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const pathname = usePathname()
const { user } = useUser()
if (!user) return null
const initials = user.name.split(" ").map((n) => n[0]).join("")
const sidebarContent = (
+3 -2
View File
@@ -35,8 +35,9 @@ interface TopbarProps {
export function Topbar({ onMenuClick }: TopbarProps) {
const router = useRouter()
const { theme, setTheme } = useTheme()
const { user } = useUser()
const { user, logout } = useUser()
const [searchOpen, setSearchOpen] = useState(false)
if (!user) return null
const initials = user.name.split(" ").map((n) => n[0]).join("")
return (
@@ -136,7 +137,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive">
<DropdownMenuItem className="text-destructive" onClick={logout}>
<LogOut className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
-2
View File
@@ -75,8 +75,6 @@ export const users: User[] = [
},
]
export const currentUser: User = users[0]
export function getUserById(id: string): User | undefined {
return users.find((u) => u.id === id)
}
+218
View File
@@ -0,0 +1,218 @@
import { SignJWT, jwtVerify } from "jose"
import bcrypt from "bcryptjs"
import { query } from "@/lib/db"
import { cookies } from "next/headers"
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
)
const SESSION_COOKIE = "session"
const MAX_FAILED_ATTEMPTS = 5
const LOCKOUT_DURATION_MINUTES = 15
export interface SessionUser {
id: string
username: string
email: string
firstName: string
lastName: string
role: string
avatar: string
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12)
}
export async function comparePassword(
password: string,
hash: string
): Promise<boolean> {
return bcrypt.compare(password, hash)
}
export async function signToken(payload: { userId: string; role: string }) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.setIssuedAt()
.sign(JWT_SECRET)
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET)
return payload as { userId: string; role: string }
} catch {
return null
}
}
export async function getUserByEmail(email: string) {
const result = await query(
`SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
u.password_change_required,
r.name AS role_name
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.email = $1 AND u.deleted_at IS NULL
LIMIT 1`,
[email.toLowerCase().trim()]
)
return result.rows[0] || null
}
export async function getUserByUsername(username: string) {
const result = await query(
`SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
u.is_active, u.is_locked, u.failed_login_attempts, u.locked_until,
u.password_change_required,
r.name AS role_name
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.username = $1 AND u.deleted_at IS NULL
LIMIT 1`,
[username.toLowerCase().trim()]
)
return result.rows[0] || null
}
export async function getUserById(id: string) {
const result = await query(
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active,
r.name AS role_name
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id
WHERE u.id = $1 AND u.deleted_at IS NULL
LIMIT 1`,
[id]
)
return result.rows[0] || null
}
export async function recordLoginAttempt(
userId: string | null,
usernameAttempted: string,
ipAddress: string,
userAgent: string | null,
wasSuccessful: boolean,
failureReason?: string
) {
await query(
`INSERT INTO login_attempts (user_id, username_attempted, ip_address, user_agent, was_successful, failure_reason)
VALUES ($1, $2, $3, $4, $5, $6)`,
[userId, usernameAttempted, ipAddress, userAgent, wasSuccessful, failureReason || null]
)
}
export async function incrementFailedAttempts(userId: string) {
await query(
`UPDATE users
SET failed_login_attempts = failed_login_attempts + 1,
locked_until = CASE
WHEN failed_login_attempts + 1 >= $2
THEN NOW() + (INTERVAL '1 minute' * $3)
ELSE locked_until
END
WHERE id = $1`,
[userId, MAX_FAILED_ATTEMPTS, LOCKOUT_DURATION_MINUTES]
)
}
export async function resetFailedAttempts(userId: string) {
await query(
`UPDATE users
SET failed_login_attempts = 0,
locked_until = NULL,
last_login_at = NOW()
WHERE id = $1`,
[userId]
)
}
export async function isAccountLocked(user: {
is_locked: boolean
locked_until: Date | null
}): Promise<{ locked: boolean; reason?: string }> {
if (user.is_locked) {
return { locked: true, reason: "Account has been locked by an administrator." }
}
if (user.locked_until && new Date(user.locked_until) > new Date()) {
const minutes = Math.ceil(
(new Date(user.locked_until).getTime() - Date.now()) / 60000
)
return {
locked: true,
reason: `Account is temporarily locked due to too many failed attempts. Try again in ${minutes} minute(s).`,
}
}
return { locked: false }
}
export function mapDbUserToSessionUser(dbUser: Record<string, unknown>): SessionUser {
const roleName = dbUser.role_name as string
const mappedRole =
roleName === "SUPER_ADMIN" || roleName === "ADMIN" ? "admin" : "sales"
return {
id: dbUser.id as string,
username: dbUser.username as string,
email: dbUser.email as string,
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: mappedRole,
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(
`${dbUser.first_name}+${dbUser.last_name}`
)}&background=1d4ed8&color=fff&size=128`,
}
}
export async function createSession(userId: string, role: string) {
const token = await signToken({ userId, role })
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
})
return token
}
export async function destroySession() {
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 0,
})
}
export async function getSessionUser(): Promise<SessionUser | null> {
try {
const cookieStore = await cookies()
const token = cookieStore.get(SESSION_COOKIE)?.value
if (!token) return null
const payload = await verifyToken(token)
if (!payload) return null
const dbUser = await getUserById(payload.userId)
if (!dbUser) return null
return mapDbUserToSessionUser(dbUser)
} catch {
return null
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Pool } from "pg"
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
})
pool.on("error", (err) => {
console.error("Unexpected database pool error:", err)
})
export async function query(text: string, params?: unknown[]) {
const client = await pool.connect()
try {
const result = await client.query(text, params)
return result
} finally {
client.release()
}
}
export default pool
+55
View File
@@ -0,0 +1,55 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { jwtVerify } from "jose"
const JWT_SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || "fallback-dev-secret-do-not-use-in-production"
)
const publicRoutes = [
"/login",
"/api/auth/login",
"/api/auth/logout",
"/_next/static",
"/_next/image",
"/favicon.ico",
"/logo",
"/fonts",
]
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const isPublic = publicRoutes.some(
(route) => pathname === route || pathname.startsWith(route + "/") || pathname.startsWith(route)
)
if (pathname === "/api/auth/me") {
return NextResponse.next()
}
if (isPublic) {
return NextResponse.next()
}
const sessionCookie = request.cookies.get("session")?.value
if (!sessionCookie) {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
}
try {
await jwtVerify(sessionCookie, JWT_SECRET)
return NextResponse.next()
} catch {
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
}
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|logo|fonts).*)"],
}
+53 -6
View File
@@ -1,23 +1,70 @@
"use client"
import { createContext, useContext, useState, ReactNode } from "react"
import { currentUser } from "@/data/users"
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
import { useRouter } from "next/navigation"
import type { User } from "@/types"
interface UserContextValue {
user: User
user: User | null
loading: boolean
error: string | null
logout: () => Promise<void>
updateAvatar: (url: string) => void
}
const UserContext = createContext<UserContextValue | null>(null)
export function UserProvider({ children }: { children: ReactNode }) {
const [avatar, setAvatar] = useState(currentUser.avatar)
const router = useRouter()
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const user: User = { ...currentUser, avatar }
const fetchUser = useCallback(async () => {
try {
const res = await fetch("/api/auth/me")
if (res.ok) {
const data = await res.json()
const u = data.user
setUser({
id: u.id,
name: `${u.firstName} ${u.lastName}`,
email: u.email,
role: u.role,
active: true,
avatar: u.avatar,
createdAt: new Date().toISOString(),
})
} else {
setUser(null)
}
} catch {
setUser(null)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchUser()
}, [fetchUser])
const logout = useCallback(async () => {
try {
await fetch("/api/auth/logout", { method: "POST" })
} catch {
// Proceed with client-side logout even if API fails
}
setUser(null)
router.push("/login")
}, [router])
const updateAvatar = useCallback((url: string) => {
setUser((prev) => (prev ? { ...prev, avatar: url } : prev))
}, [])
return (
<UserContext.Provider value={{ user, updateAvatar: setAvatar }}>
<UserContext.Provider value={{ user, loading, error, logout, updateAvatar }}>
{children}
</UserContext.Provider>
)