From 0750430d3b3c7da4f5d122d3473cb2ffa1dc0196 Mon Sep 17 00:00:00 2001 From: v0 Date: Tue, 10 Feb 2026 19:20:30 +0000 Subject: [PATCH 1/2] feat: build full Next.js 16 frontend for Secure App Create core infrastructure, auth pages, dashboard layout, and admin pages Co-authored-by: SX1109 <109422266+SX110903@users.noreply.github.com> --- app/(auth)/forgot-password/page.tsx | 133 + app/(auth)/layout.tsx | 71 + app/(auth)/login/page.tsx | 154 + app/(auth)/register/page.tsx | 187 + app/(auth)/reset-password/page.tsx | 193 + app/(dashboard)/admin/page.tsx | 211 ++ app/(dashboard)/admin/users/[id]/page.tsx | 239 ++ app/(dashboard)/dashboard/page.tsx | 169 + app/(dashboard)/layout.tsx | 47 + app/(dashboard)/profile/page.tsx | 340 ++ app/globals.css | 77 + app/layout.tsx | 46 + app/page.tsx | 5 + components/layout/header.tsx | 48 + components/layout/sidebar.tsx | 171 + components/ui/avatar.tsx | 49 + components/ui/badge.tsx | 35 + components/ui/button.tsx | 55 + components/ui/card.tsx | 75 + components/ui/input.tsx | 21 + components/ui/label.tsx | 25 + components/ui/separator.tsx | 30 + components/ui/table.tsx | 116 + lib/api.ts | 202 ++ lib/auth.tsx | 190 + lib/utils.ts | 26 + next.config.mjs | 11 + node_modules | 1 + package.json | 72 + pnpm-lock.yaml | 3957 +++++++++++++++++++++ postcss.config.mjs | 9 + tailwind.config.ts | 74 + tsconfig.json | 27 + types/auth.ts | 117 + 34 files changed, 7183 insertions(+) create mode 100644 app/(auth)/forgot-password/page.tsx create mode 100644 app/(auth)/layout.tsx create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/register/page.tsx create mode 100644 app/(auth)/reset-password/page.tsx create mode 100644 app/(dashboard)/admin/page.tsx create mode 100644 app/(dashboard)/admin/users/[id]/page.tsx create mode 100644 app/(dashboard)/dashboard/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/(dashboard)/profile/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/layout/header.tsx create mode 100644 components/layout/sidebar.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/table.tsx create mode 100644 lib/api.ts create mode 100644 lib/auth.tsx create mode 100644 lib/utils.ts create mode 100644 next.config.mjs create mode 120000 node_modules create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 postcss.config.mjs create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json create mode 100644 types/auth.ts diff --git a/app/(auth)/forgot-password/page.tsx b/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..dc3153a --- /dev/null +++ b/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,133 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Shield, Loader2, ArrowLeft, Mail } from "lucide-react" + +const forgotSchema = z.object({ + email: z.string().email("Enter a valid email address"), +}) + +type ForgotForm = z.infer + +export default function ForgotPasswordPage() { + const [submitted, setSubmitted] = useState(false) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(forgotSchema), + }) + + const onSubmit = async (_data: ForgotForm) => { + // Simulate API call - backend would handle sending email + await new Promise((resolve) => setTimeout(resolve, 1000)) + setSubmitted(true) + } + + return ( + <> +
+ + Secure App +
+ + + {submitted ? ( + <> + +
+ +
+ + Check your email + + + If an account exists with that email, we sent password reset + instructions. + +
+ + + + Back to sign in + + + + ) : ( + <> + + + Forgot password + + + {"Enter your email and we'll send you a reset link"} + + + +
+
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ + +
+
+ + + + Back to sign in + + + + )} +
+ + ) +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..ec7b599 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,71 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { useAuth } from "@/lib/auth" +import { Shield } from "lucide-react" + +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + const { isAuthenticated, isLoading } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!isLoading && isAuthenticated) { + router.replace("/dashboard") + } + }, [isAuthenticated, isLoading, router]) + + if (isLoading) { + return ( +
+
Loading...
+
+ ) + } + + if (isAuthenticated) return null + + return ( +
+ {/* Left panel - branding */} +
+
+
+ +
+

+ Secure App +

+

+ Enterprise-grade security management platform with role-based access + control, comprehensive user administration, and granular permission + management. +

+
+
+
+ JWT-based authentication +
+
+
+ Role-based access control (RBAC) +
+
+
+ Granular permission system +
+
+
+
+ + {/* Right panel - form */} +
+
{children}
+
+
+ ) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..e5bb934 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,154 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { useAuth } from "@/lib/auth" +import { ApiRequestError } from "@/lib/api" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Shield, Loader2, Eye, EyeOff } from "lucide-react" + +const loginSchema = z.object({ + email: z.string().email("Enter a valid email address"), + password: z.string().min(1, "Password is required"), +}) + +type LoginForm = z.infer + +export default function LoginPage() { + const { login } = useAuth() + const router = useRouter() + const [showPassword, setShowPassword] = useState(false) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + }) + + const onSubmit = async (data: LoginForm) => { + try { + await login(data.email, data.password) + toast.success("Login successful") + router.push("/dashboard") + } catch (error) { + if (error instanceof ApiRequestError) { + toast.error(error.message) + } else { + toast.error("An unexpected error occurred") + } + } + } + + return ( + <> +
+ + Secure App +
+ + + + Welcome back + + Sign in to your account to continue + + + +
+
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+
+ + + Forgot password? + +
+
+ + +
+ {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ + +
+
+ +

+ {"Don't have an account? "} + + Create account + +

+
+
+ + ) +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..49b47af --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,187 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { useAuth } from "@/lib/auth" +import { ApiRequestError } from "@/lib/api" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Shield, Loader2, Eye, EyeOff } from "lucide-react" + +const registerSchema = z + .object({ + name: z.string().min(3, "Name must be at least 3 characters"), + email: z.string().email("Enter a valid email address"), + password: z + .string() + .min(8, "Password must be at least 8 characters"), + confirm_password: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.password === data.confirm_password, { + message: "Passwords do not match", + path: ["confirm_password"], + }) + +type RegisterForm = z.infer + +export default function RegisterPage() { + const { register: registerUser } = useAuth() + const router = useRouter() + const [showPassword, setShowPassword] = useState(false) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(registerSchema), + }) + + const onSubmit = async (data: RegisterForm) => { + try { + await registerUser(data.name, data.email, data.password) + toast.success("Account created successfully! Please sign in.") + router.push("/login") + } catch (error) { + if (error instanceof ApiRequestError) { + toast.error(error.message) + } else if (error instanceof Error) { + toast.error(error.message) + } else { + toast.error("An unexpected error occurred") + } + } + } + + return ( + <> +
+ + Secure App +
+ + + + Create account + + Enter your details to get started + + + +
+
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+ + +
+ {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+ + + {errors.confirm_password && ( +

+ {errors.confirm_password.message} +

+ )} +
+ + +
+
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+ + ) +} diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..a49f850 --- /dev/null +++ b/app/(auth)/reset-password/page.tsx @@ -0,0 +1,193 @@ +"use client" + +import { useState, Suspense } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import Link from "next/link" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Shield, Loader2, ArrowLeft, CheckCircle } from "lucide-react" + +const resetSchema = z + .object({ + password: z.string().min(8, "Password must be at least 8 characters"), + confirm_password: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.password === data.confirm_password, { + message: "Passwords do not match", + path: ["confirm_password"], + }) + +type ResetForm = z.infer + +function ResetPasswordForm() { + const router = useRouter() + const searchParams = useSearchParams() + const token = searchParams.get("token") + const [submitted, setSubmitted] = useState(false) + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(resetSchema), + }) + + const onSubmit = async (_data: ResetForm) => { + if (!token) { + toast.error("Reset token is missing") + return + } + // Simulate API call + await new Promise((resolve) => setTimeout(resolve, 1000)) + setSubmitted(true) + } + + if (!token) { + return ( + + + Invalid link + + This password reset link is invalid or has expired. + + + + + Request a new reset link + + + + ) + } + + return ( + + {submitted ? ( + <> + +
+ +
+ + Password updated + + + Your password has been successfully reset. + +
+ + + + + ) : ( + <> + + + Reset password + + Enter your new password below + + +
+
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ +
+ + + {errors.confirm_password && ( +

+ {errors.confirm_password.message} +

+ )} +
+ + +
+
+ + + + Back to sign in + + + + )} +
+ ) +} + +export default function ResetPasswordPage() { + return ( + <> +
+ + Secure App +
+ + + + + + } + > + + + + ) +} diff --git a/app/(dashboard)/admin/page.tsx b/app/(dashboard)/admin/page.tsx new file mode 100644 index 0000000..9d18906 --- /dev/null +++ b/app/(dashboard)/admin/page.tsx @@ -0,0 +1,211 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import useSWR from "swr" +import { useAuth } from "@/lib/auth" +import { getUsers } from "@/lib/api" +import { formatDate } from "@/lib/utils" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Users, + Eye, + ChevronLeft, + ChevronRight, + Loader2, + ShieldAlert, +} from "lucide-react" +import { useState } from "react" + +const PAGE_SIZE = 10 + +export default function AdminPage() { + const { hasRole, isLoading: authLoading } = useAuth() + const router = useRouter() + const [page, setPage] = useState(0) + + const isAdmin = hasRole("admin") + + const { data, isLoading, error } = useSWR( + isAdmin ? `users-${page}` : null, + () => getUsers(PAGE_SIZE, page * PAGE_SIZE), + { revalidateOnFocus: false } + ) + + useEffect(() => { + if (!authLoading && !isAdmin) { + router.replace("/dashboard") + } + }, [authLoading, isAdmin, router]) + + if (!isAdmin) { + return ( +
+ +

+ You do not have permission to view this page. +

+
+ ) + } + + const users = data?.data?.users || [] + const pagination = data?.data?.pagination + const totalPages = pagination + ? Math.ceil(pagination.total / PAGE_SIZE) + : 0 + + return ( +
+ {/* Page header */} +
+

User Management

+

+ View and manage all registered users +

+
+ + {/* Users table */} + + +
+ + All Users +
+ {pagination && ( + + {pagination.total} total users + + )} +
+ + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ Failed to load users. Please try again. +
+ ) : ( + <> +
+ + + + ID + Name + Email + Status + Last Login + Created + Actions + + + + {users.length === 0 ? ( + + + No users found + + + ) : ( + users.map((user) => ( + + + {user.id} + + + {user.name} + + + {user.email} + + + + {user.is_active ? "Active" : "Inactive"} + + + + {formatDate(user.last_login)} + + + {formatDate(user.created_at)} + + + + + + + + )) + )} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page + 1} of {totalPages} +

+
+ + +
+
+ )} + + )} +
+
+
+ ) +} diff --git a/app/(dashboard)/admin/users/[id]/page.tsx b/app/(dashboard)/admin/users/[id]/page.tsx new file mode 100644 index 0000000..1b27389 --- /dev/null +++ b/app/(dashboard)/admin/users/[id]/page.tsx @@ -0,0 +1,239 @@ +"use client" + +import { useEffect, use } from "react" +import { useRouter } from "next/navigation" +import Link from "next/link" +import useSWR from "swr" +import { useAuth } from "@/lib/auth" +import { getUserById } from "@/lib/api" +import { formatDate, getInitials } from "@/lib/utils" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { + ArrowLeft, + Mail, + Calendar, + Clock, + Activity, + Shield, + Lock, + Loader2, + ShieldAlert, +} from "lucide-react" + +export default function UserDetailPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = use(params) + const { hasRole, isLoading: authLoading } = useAuth() + const router = useRouter() + const isAdmin = hasRole("admin") + const userId = parseInt(id, 10) + + const { data, isLoading, error } = useSWR( + isAdmin && userId ? `user-${userId}` : null, + () => getUserById(userId), + { revalidateOnFocus: false } + ) + + useEffect(() => { + if (!authLoading && !isAdmin) { + router.replace("/dashboard") + } + }, [authLoading, isAdmin, router]) + + if (!isAdmin) { + return ( +
+ +

+ You do not have permission to view this page. +

+
+ ) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error || !data?.data) { + return ( +
+

Failed to load user details.

+ + + +
+ ) + } + + const user = data.data.user + const permissions = data.data.permissions + const userRoles = user.roles || [] + + const details = [ + { label: "Email", value: user.email, icon: Mail }, + { + label: "Status", + value: user.is_active ? "Active" : "Inactive", + icon: Activity, + }, + { + label: "Member since", + value: formatDate(user.created_at), + icon: Calendar, + }, + { + label: "Last login", + value: formatDate(user.last_login), + icon: Clock, + }, + ] + + return ( +
+ {/* Back button */} + + + + + {/* User header card */} + + +
+ + + {getInitials(user.name)} + + +
+

{user.name}

+

{user.email}

+
+ {userRoles.map((role: string) => ( + + {role} + + ))} + + {user.is_active ? "Active" : "Inactive"} + +
+
+
+
+
+ + {/* Details */} + + +
+ + Account Details +
+ User account information +
+ +
+ {details.map((detail) => ( +
+
+ +
+
+

+ {detail.label} +

+

+ {detail.value || "N/A"} +

+
+
+ ))} +
+
+
+ + {/* Permissions */} + + +
+ + Permissions +
+ + Granular permissions granted through roles + +
+ + {permissions.length > 0 ? ( +
+ {permissions.map((perm) => { + const permName = + typeof perm === "string" ? perm : perm.name + const permDescription = + typeof perm === "string" ? null : perm.description + const [category, action] = permName.split(".") + return ( +
+ + {category} + +
+ + {action} + + {permDescription && ( + + {permDescription} + + )} +
+
+ ) + })} +
+ ) : ( +

+ No permissions assigned +

+ )} +
+
+
+ ) +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..24e14fd --- /dev/null +++ b/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,169 @@ +"use client" + +import { useAuth } from "@/lib/auth" +import { formatDate } from "@/lib/utils" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { + ShieldCheck, + Clock, + KeyRound, + UserCheck, + Activity, + Lock, +} from "lucide-react" + +export default function DashboardPage() { + const { user, roles, permissions } = useAuth() + + const stats = [ + { + label: "Account Status", + value: user?.is_active ? "Active" : "Inactive", + icon: Activity, + color: user?.is_active + ? "text-primary" + : "text-destructive", + bgColor: user?.is_active + ? "bg-primary/10" + : "bg-destructive/10", + }, + { + label: "Last Access", + value: user?.last_login ? formatDate(user.last_login) : "Never", + icon: Clock, + color: "text-accent", + bgColor: "bg-accent/10", + }, + { + label: "Roles", + value: String(roles.length), + icon: ShieldCheck, + color: "text-primary", + bgColor: "bg-primary/10", + }, + { + label: "Permissions", + value: String(permissions.length), + icon: KeyRound, + color: "text-accent", + bgColor: "bg-accent/10", + }, + ] + + return ( +
+ {/* Page header */} +
+

Dashboard

+

+ Welcome back, {user?.name || "User"} +

+
+ + {/* Stats grid */} +
+ {stats.map((stat) => ( + + +
+
+

{stat.label}

+

{stat.value}

+
+
+ +
+
+
+
+ ))} +
+ + {/* Roles & Permissions */} +
+ {/* Roles */} + + +
+ + Your Roles +
+ + Assigned roles that define your access level + +
+ + {roles.length > 0 ? ( +
+ {roles.map((role) => ( + + {role} + + ))} +
+ ) : ( +

+ No roles assigned +

+ )} +
+
+ + {/* Permissions */} + + +
+ + Your Permissions +
+ + Granular permissions granted by your roles + +
+ + {permissions.length > 0 ? ( +
+ {permissions.map((perm) => { + const [category, action] = perm.split(".") + return ( +
+
+ + {category} + + + {action} + +
+
+ ) + })} +
+ ) : ( +

+ No permissions assigned +

+ )} +
+
+
+
+ ) +} diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..25da249 --- /dev/null +++ b/app/(dashboard)/layout.tsx @@ -0,0 +1,47 @@ +"use client" + +import { useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { useAuth } from "@/lib/auth" +import { Sidebar } from "@/components/layout/sidebar" +import { Header } from "@/components/layout/header" +import { Loader2 } from "lucide-react" + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + const { isAuthenticated, isLoading } = useAuth() + const router = useRouter() + const [sidebarOpen, setSidebarOpen] = useState(false) + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.replace("/login") + } + }, [isAuthenticated, isLoading, router]) + + if (isLoading) { + return ( +
+
+ +

Loading...

+
+
+ ) + } + + if (!isAuthenticated) return null + + return ( +
+ setSidebarOpen(false)} /> +
+
setSidebarOpen(true)} /> +
{children}
+
+
+ ) +} diff --git a/app/(dashboard)/profile/page.tsx b/app/(dashboard)/profile/page.tsx new file mode 100644 index 0000000..c719abf --- /dev/null +++ b/app/(dashboard)/profile/page.tsx @@ -0,0 +1,340 @@ +"use client" + +import { useState } from "react" +import { useAuth } from "@/lib/auth" +import { formatDate } from "@/lib/utils" +import { updateProfile, changePassword, ApiRequestError } from "@/lib/api" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" +import { Badge } from "@/components/ui/badge" +import { + User, + Mail, + Calendar, + Clock, + Activity, + Loader2, + KeyRound, + Save, + Eye, + EyeOff, +} from "lucide-react" + +const profileSchema = z.object({ + name: z.string().min(3, "Name must be at least 3 characters"), +}) + +const passwordSchema = z + .object({ + current_password: z.string().min(1, "Current password is required"), + new_password: z.string().min(8, "Password must be at least 8 characters"), + confirm_password: z.string().min(1, "Please confirm your password"), + }) + .refine((data) => data.new_password === data.confirm_password, { + message: "Passwords do not match", + path: ["confirm_password"], + }) + +type ProfileForm = z.infer +type PasswordForm = z.infer + +export default function ProfilePage() { + const { user, roles, refreshUser } = useAuth() + const [showCurrentPassword, setShowCurrentPassword] = useState(false) + const [showNewPassword, setShowNewPassword] = useState(false) + + const profileForm = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { + name: user?.name || "", + }, + }) + + const passwordForm = useForm({ + resolver: zodResolver(passwordSchema), + defaultValues: { + current_password: "", + new_password: "", + confirm_password: "", + }, + }) + + const onProfileSubmit = async (data: ProfileForm) => { + try { + await updateProfile(data.name) + toast.success("Profile updated successfully") + refreshUser() + } catch (error) { + if (error instanceof ApiRequestError) { + toast.error(error.message) + } else { + toast.error("Failed to update profile") + } + } + } + + const onPasswordSubmit = async (data: PasswordForm) => { + try { + await changePassword(data) + toast.success("Password updated successfully") + passwordForm.reset() + } catch (error) { + if (error instanceof ApiRequestError) { + toast.error(error.message) + } else { + toast.error("Failed to update password") + } + } + } + + const profileDetails = [ + { label: "Email", value: user?.email, icon: Mail }, + { + label: "Status", + value: user?.is_active ? "Active" : "Inactive", + icon: Activity, + }, + { + label: "Member since", + value: formatDate(user?.created_at || null), + icon: Calendar, + }, + { + label: "Last login", + value: formatDate(user?.last_login || null), + icon: Clock, + }, + ] + + return ( +
+ {/* Page header */} +
+

My Profile

+

+ Manage your account information +

+
+ + {/* Profile info */} + + +
+
+ + Personal Information +
+
+ {roles.map((role) => ( + + {role} + + ))} +
+
+ Your account details at a glance +
+ +
+ {profileDetails.map((detail) => ( +
+
+ +
+
+

+ {detail.label} +

+

+ {detail.value || "N/A"} +

+
+
+ ))} +
+
+
+ + {/* Edit name */} + + +
+ + Edit Profile +
+ Update your display name +
+ +
+
+ + + {profileForm.formState.errors.name && ( +

+ {profileForm.formState.errors.name.message} +

+ )} +
+
+ +
+
+
+
+ + {/* Change password */} + + +
+ + Change Password +
+ + Update your password to keep your account secure + +
+ +
+
+ +
+ + +
+ {passwordForm.formState.errors.current_password && ( +

+ {passwordForm.formState.errors.current_password.message} +

+ )} +
+ +
+ +
+ + +
+ {passwordForm.formState.errors.new_password && ( +

+ {passwordForm.formState.errors.new_password.message} +

+ )} +
+ +
+ + + {passwordForm.formState.errors.confirm_password && ( +

+ {passwordForm.formState.errors.confirm_password.message} +

+ )} +
+ +
+ +
+
+
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..3296d40 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,77 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 98%; + --foreground: 215 28% 10%; + --card: 0 0% 100%; + --card-foreground: 215 28% 10%; + --popover: 0 0% 100%; + --popover-foreground: 215 28% 10%; + --primary: 173 78% 26%; + --primary-foreground: 0 0% 100%; + --secondary: 210 15% 93%; + --secondary-foreground: 215 28% 10%; + --muted: 210 15% 95%; + --muted-foreground: 215 10% 45%; + --accent: 25 95% 53%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 100%; + --border: 214 15% 88%; + --input: 214 15% 88%; + --ring: 173 78% 26%; + --radius: 0.5rem; + --sidebar: 220 40% 8%; + --sidebar-foreground: 210 15% 80%; + --sidebar-accent: 220 35% 14%; + --sidebar-accent-foreground: 0 0% 100%; + --chart-1: 173 78% 26%; + --chart-2: 25 95% 53%; + --chart-3: 215 28% 15%; + --chart-4: 210 15% 60%; + --chart-5: 173 50% 40%; + } + + .dark { + --background: 220 40% 6%; + --foreground: 210 15% 90%; + --card: 220 35% 10%; + --card-foreground: 210 15% 90%; + --popover: 220 35% 10%; + --popover-foreground: 210 15% 90%; + --primary: 173 65% 38%; + --primary-foreground: 0 0% 100%; + --secondary: 220 30% 16%; + --secondary-foreground: 210 15% 90%; + --muted: 220 25% 14%; + --muted-foreground: 210 10% 55%; + --accent: 25 95% 53%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84% 60%; + --destructive-foreground: 0 0% 100%; + --border: 220 20% 18%; + --input: 220 20% 18%; + --ring: 173 65% 38%; + --sidebar: 220 45% 5%; + --sidebar-foreground: 210 15% 75%; + --sidebar-accent: 220 40% 10%; + --sidebar-accent-foreground: 0 0% 100%; + --chart-1: 173 65% 38%; + --chart-2: 25 95% 53%; + --chart-3: 215 25% 25%; + --chart-4: 210 15% 50%; + --chart-5: 173 45% 50%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..3b65f5e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata, Viewport } from "next" +import { Inter, JetBrains_Mono } from "next/font/google" +import { Toaster } from "sonner" +import { AuthProvider } from "@/lib/auth" +import "./globals.css" + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}) + +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + variable: "--font-jetbrains-mono", +}) + +export const metadata: Metadata = { + title: "Secure App - Security Management Dashboard", + description: + "Enterprise-grade security management with role-based access control, user administration, and comprehensive permission management.", +} + +export const viewport: Viewport = { + themeColor: "#0f766e", + width: "device-width", + initialScale: 1, +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + + {children} + + + + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..55b6bde --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation" + +export default function Home() { + redirect("/login") +} diff --git a/components/layout/header.tsx b/components/layout/header.tsx new file mode 100644 index 0000000..9d1f9c5 --- /dev/null +++ b/components/layout/header.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useAuth } from "@/lib/auth" +import { getInitials } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { Menu } from "lucide-react" + +interface HeaderProps { + onMenuClick: () => void + title?: string +} + +export function Header({ onMenuClick, title }: HeaderProps) { + const { user, roles } = useAuth() + + return ( +
+
+ + {title && ( +

{title}

+ )} +
+ +
+ {roles.length > 0 && ( + + {roles[0]} + + )} + + + {user ? getInitials(user.name) : "?"} + + +
+
+ ) +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx new file mode 100644 index 0000000..2a31bd1 --- /dev/null +++ b/components/layout/sidebar.tsx @@ -0,0 +1,171 @@ +"use client" + +import Link from "next/link" +import { usePathname } from "next/navigation" +import { useAuth } from "@/lib/auth" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Shield, + LayoutDashboard, + User, + Users, + LogOut, + X, + ChevronLeft, +} from "lucide-react" + +interface SidebarProps { + open: boolean + onClose: () => void +} + +const navItems = [ + { + label: "Dashboard", + href: "/dashboard", + icon: LayoutDashboard, + }, + { + label: "My Profile", + href: "/profile", + icon: User, + }, +] + +const adminItems = [ + { + label: "Users", + href: "/admin", + icon: Users, + }, +] + +export function Sidebar({ open, onClose }: SidebarProps) { + const pathname = usePathname() + const { user, hasRole, logout } = useAuth() + + const isAdmin = hasRole("admin") + + const handleLogout = () => { + logout() + window.location.href = "/login" + } + + return ( + <> + {/* Mobile overlay */} + {open && ( +