Enhance AdminSettingsPage to fetch and update user profile. Implement profile data retrieval on component mount, update form structure to include first and last name, and add validation for required fields. Improve loading indicators and error handling for profile updates. Update API integration for fetching and updating user profile data.

This commit is contained in:
iamkiddy 2025-11-25 21:25:53 +00:00
parent f6bd813c07
commit a1611e1782
14 changed files with 695 additions and 191 deletions

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
@ -13,16 +13,21 @@ import {
Lock, Lock,
Eye, Eye,
EyeOff, EyeOff,
Loader2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { getProfile, updateProfile } from "@/lib/actions/auth";
import { toast } from "sonner";
export default function AdminSettingsPage() { export default function AdminSettingsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
fullName: "Hammond", firstName: "",
email: "admin@attuneheart.com", lastName: "",
phone: "+1 (555) 123-4567", email: "",
phone: "",
}); });
const [passwordData, setPasswordData] = useState({ const [passwordData, setPasswordData] = useState({
currentPassword: "", currentPassword: "",
@ -37,6 +42,30 @@ export default function AdminSettingsPage() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
// Fetch profile data on mount
useEffect(() => {
const fetchProfile = async () => {
setFetching(true);
try {
const profile = await getProfile();
setFormData({
firstName: profile.first_name || "",
lastName: profile.last_name || "",
email: profile.email || "",
phone: profile.phone_number || "",
});
} catch (error) {
console.error("Failed to fetch profile:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
toast.error(errorMessage);
} finally {
setFetching(false);
}
};
fetchProfile();
}, []);
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
@ -59,11 +88,26 @@ export default function AdminSettingsPage() {
}; };
const handleSave = async () => { const handleSave = async () => {
if (!formData.firstName || !formData.lastName) {
toast.error("First name and last name are required");
return;
}
setLoading(true); setLoading(true);
// Simulate API call try {
await new Promise((resolve) => setTimeout(resolve, 1000)); await updateProfile({
setLoading(false); first_name: formData.firstName,
// In a real app, you would show a success message here last_name: formData.lastName,
phone_number: formData.phone || undefined,
});
toast.success("Profile updated successfully!");
} catch (error) {
console.error("Failed to update profile:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
toast.error(errorMessage);
} finally {
setLoading(false);
}
}; };
const handlePasswordSave = async () => { const handlePasswordSave = async () => {
@ -113,15 +157,20 @@ export default function AdminSettingsPage() {
</div> </div>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={loading} disabled={loading || fetching}
className="w-full sm:w-auto bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white" className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
> >
{loading ? ( {loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : ( ) : (
<Save className="w-4 h-4 mr-2" /> <>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)} )}
Save Changes
</Button> </Button>
</div> </div>
@ -139,21 +188,49 @@ export default function AdminSettingsPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> {fetching ? (
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}> <div className="flex items-center justify-center py-8">
Full Name <Loader2 className="w-6 h-6 animate-spin text-rose-600" />
</label>
<div className="relative">
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<Input
type="text"
value={formData.fullName}
onChange={(e) => handleInputChange("fullName", e.target.value)}
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
placeholder="Enter your full name"
/>
</div> </div>
</div> ) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
First Name *
</label>
<div className="relative">
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<Input
type="text"
value={formData.firstName}
onChange={(e) => handleInputChange("firstName", e.target.value)}
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
placeholder="Enter your first name"
required
/>
</div>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Last Name *
</label>
<div className="relative">
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<Input
type="text"
value={formData.lastName}
onChange={(e) => handleInputChange("lastName", e.target.value)}
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
placeholder="Enter your last name"
required
/>
</div>
</div>
</div>
</>
)}
<div className="space-y-2"> <div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}> <label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
@ -164,11 +241,14 @@ export default function AdminSettingsPage() {
<Input <Input
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)} disabled
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`} className={`pl-10 ${isDark ? "bg-gray-700/50 border-gray-600 text-gray-400 cursor-not-allowed" : "bg-gray-50 border-gray-300 text-gray-500 cursor-not-allowed"}`}
placeholder="Enter your email" placeholder="Email address"
/> />
</div> </div>
<p className={`text-xs ${isDark ? "text-gray-500" : "text-gray-400"}`}>
Email address cannot be changed
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -139,13 +139,18 @@ function LoginContent() {
// Wait a moment for cookies to be set, then redirect // Wait a moment for cookies to be set, then redirect
// Check if user is admin/staff/superuser - check all possible field names // Check if user is admin/staff/superuser - check all possible field names
const user = result.user as any; const user = result.user as any;
const isTruthy = (value: any): boolean => {
if (value === true || value === "true" || value === 1 || value === "1") return true;
return false;
};
const userIsAdmin = const userIsAdmin =
user.is_admin === true || isTruthy(user.is_admin) ||
user.isAdmin === true || isTruthy(user.isAdmin) ||
user.is_staff === true || isTruthy(user.is_staff) ||
user.isStaff === true || isTruthy(user.isStaff) ||
user.is_superuser === true || isTruthy(user.is_superuser) ||
user.isSuperuser === true; isTruthy(user.isSuperuser);
// Wait longer for cookies to be set and middleware to process // Wait longer for cookies to be set and middleware to process
setTimeout(() => { setTimeout(() => {

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Calendar, Calendar,
@ -16,57 +16,28 @@ import {
CalendarCheck, CalendarCheck,
ArrowUpRight, ArrowUpRight,
Settings, Settings,
Loader2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { useAppointments } from "@/hooks/useAppointments";
interface Booking { import { useAuth } from "@/hooks/useAuth";
ID: number; import type { Appointment } from "@/lib/models/appointments";
scheduled_at: string; import { toast } from "sonner";
duration: number;
status: string;
amount: number;
notes: string;
jitsi_room_url?: string;
}
export default function UserDashboard() { export default function UserDashboard() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const [bookings, setBookings] = useState<Booking[]>([]); const { user } = useAuth();
const [loading, setLoading] = useState(true); const {
userAppointments,
useEffect(() => { userAppointmentStats,
// Simulate API call to fetch user bookings isLoadingUserAppointments,
const fetchBookings = async () => { isLoadingUserStats,
setLoading(true); refetchUserAppointments,
try { refetchUserStats,
// Simulate network delay } = useAppointments();
await new Promise((resolve) => setTimeout(resolve, 500));
// Mock data - in real app, this would fetch from API
const mockBookings: Booking[] = [
{
ID: 1,
scheduled_at: "2025-01-15T10:00:00Z",
duration: 60,
status: "scheduled",
amount: 150,
notes: "Initial consultation",
jitsi_room_url: "https://meet.jit.si/sample-room",
},
];
setBookings(mockBookings);
} catch (error) {
console.error("Failed to fetch bookings:", error);
} finally {
setLoading(false);
}
};
fetchBookings();
}, []);
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
@ -86,47 +57,70 @@ export default function UserDashboard() {
}); });
}; };
const upcomingBookings = bookings.filter( const formatMemberSince = (dateString?: string) => {
(booking) => booking.status === "scheduled" if (!dateString) return "N/A";
); const date = new Date(dateString);
const completedBookings = bookings.filter( return date.toLocaleDateString("en-US", {
(booking) => booking.status === "completed" month: "long",
); year: "numeric",
const cancelledBookings = bookings.filter( });
(booking) => booking.status === "cancelled" };
);
// Filter appointments by status
const upcomingAppointments = useMemo(() => {
return userAppointments.filter(
(appointment) => appointment.status === "scheduled"
);
}, [userAppointments]);
const completedAppointments = useMemo(() => {
return userAppointments.filter(
(appointment) => appointment.status === "completed"
);
}, [userAppointments]);
const stats = userAppointmentStats || {
total_requests: 0,
pending_review: 0,
scheduled: 0,
rejected: 0,
completed: 0,
completion_rate: 0,
};
const statCards = [ const statCards = [
{ {
title: "Upcoming Appointments", title: "Upcoming Appointments",
value: upcomingBookings.length, value: stats.scheduled,
icon: CalendarCheck, icon: CalendarCheck,
trend: "+2", trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0",
trendUp: true, trendUp: true,
}, },
{ {
title: "Completed Sessions", title: "Completed Sessions",
value: completedBookings.length, value: stats.completed || 0,
icon: CheckCircle2, icon: CheckCircle2,
trend: "+5", trend: stats.completed > 0 ? `+${stats.completed}` : "0",
trendUp: true, trendUp: true,
}, },
{ {
title: "Total Appointments", title: "Total Appointments",
value: bookings.length, value: stats.total_requests,
icon: Calendar, icon: Calendar,
trend: "+12%", trend: `${Math.round(stats.completion_rate || 0)}%`,
trendUp: true, trendUp: true,
}, },
{ {
title: "Total Spent", title: "Pending Review",
value: `$${bookings.reduce((sum, b) => sum + b.amount, 0)}`, value: stats.pending_review,
icon: Heart, icon: Calendar,
trend: "+18%", trend: stats.pending_review > 0 ? `${stats.pending_review}` : "0",
trendUp: true, trendUp: false,
}, },
]; ];
const loading = isLoadingUserAppointments || isLoadingUserStats;
return ( return (
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}> <div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
<Navbar /> <Navbar />
@ -158,7 +152,7 @@ export default function UserDashboard() {
className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white" className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
> >
<CalendarPlus className="w-4 h-4 mr-2" /> <CalendarPlus className="w-4 h-4 mr-2" />
Book Appointment Request Appointment
</Button> </Button>
</Link> </Link>
</div> </div>
@ -166,7 +160,7 @@ export default function UserDashboard() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? 'border-gray-600' : 'border-gray-400'}`}></div> <Loader2 className={`w-8 h-8 animate-spin ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div> </div>
) : ( ) : (
<> <>
@ -214,53 +208,62 @@ export default function UserDashboard() {
</div> </div>
{/* Upcoming Appointments Section */} {/* Upcoming Appointments Section */}
{upcomingBookings.length > 0 && ( {upcomingAppointments.length > 0 ? (
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}> <div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}> <h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
Upcoming Appointments Upcoming Appointments
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
{upcomingBookings.map((booking) => ( {upcomingAppointments.map((appointment) => (
<div <div
key={booking.ID} key={appointment.id}
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`} className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`}
> >
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-2"> {appointment.scheduled_datetime && (
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> <>
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}> <div className="flex items-center gap-2 mb-2">
{formatDate(booking.scheduled_at)} <Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</span> <span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
</div> {formatDate(appointment.scheduled_datetime)}
<div className="flex items-center gap-2 mb-2"> </span>
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> </div>
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}> <div className="flex items-center gap-2 mb-2">
{formatTime(booking.scheduled_at)} <Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</span> <span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> {formatTime(appointment.scheduled_datetime)}
({booking.duration} minutes) </span>
</span> {appointment.scheduled_duration && (
</div> <span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
{booking.notes && ( ({appointment.scheduled_duration} minutes)
</span>
)}
</div>
</>
)}
{appointment.reason && (
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> <p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{booking.notes} {appointment.reason}
</p> </p>
)} )}
</div> </div>
<div className="flex flex-col sm:items-end gap-3"> <div className="flex flex-col sm:items-end gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'}`}> <span className={`px-3 py-1 rounded-full text-sm font-medium ${
{booking.status.charAt(0).toUpperCase() + appointment.status === "scheduled"
booking.status.slice(1)} ? isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'
</span> : appointment.status === "pending_review"
<span className={`text-lg font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}> ? isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700'
${booking.amount} : isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700'
}`}>
{appointment.status.charAt(0).toUpperCase() +
appointment.status.slice(1).replace('_', ' ')}
</span> </span>
</div> </div>
{booking.jitsi_room_url && ( {appointment.jitsi_meet_url && appointment.can_join_meeting && (
<a <a
href={booking.jitsi_room_url} href={appointment.jitsi_meet_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
@ -275,68 +278,92 @@ export default function UserDashboard() {
))} ))}
</div> </div>
</div> </div>
) : !loading && (
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<div className="flex flex-col items-center justify-center py-8 text-center">
<CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Request Appointment
</p>
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
No upcoming appointments. Book an appointment to get started.
</p>
<Link href="/book-now">
<Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white">
<CalendarPlus className="w-4 h-4 mr-2" />
Request Appointment
</Button>
</Link>
</div>
</div>
)} )}
{/* Account Information */} {/* Account Information */}
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}> {user && (
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}> <div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
Account Information <h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
</h2> Account Information
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> </h2>
<div className="flex items-center gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}> <div className="flex items-center gap-3">
<User className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> <div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<User className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Full Name
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{user.first_name} {user.last_name}
</p>
</div>
</div> </div>
<div> <div className="flex items-center gap-3">
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> <div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
Full Name <Mail className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</p> </div>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}> <div>
John Doe <p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
</p> Email
</div> </p>
</div> <p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
<div className="flex items-center gap-3"> {user.email}
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}> </p>
<Mail className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> </div>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Email
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
john.doe@example.com
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Phone className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Phone
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
+1 (555) 123-4567
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Member Since
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
January 2025
</p>
</div> </div>
{user.phone_number && (
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Phone className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Phone
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{user.phone_number}
</p>
</div>
</div>
)}
{user.date_joined && (
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Member Since
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{formatMemberSince(user.date_joined)}
</p>
</div>
</div>
)}
</div> </div>
</div> </div>
</div> )}
</> </>
)} )}
</main> </main>

View File

@ -70,9 +70,30 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail,
onOpenChange(false); onOpenChange(false);
// Reset form // Reset form
setLoginData({ email: "", password: "" }); setLoginData({ email: "", password: "" });
// Redirect to user dashboard
router.push("/user/dashboard"); // Check if user is admin/staff/superuser
const user = result.user as any;
const isTruthy = (value: any): boolean => {
if (value === true || value === "true" || value === 1 || value === "1") return true;
return false;
};
const userIsAdmin =
isTruthy(user.is_admin) ||
isTruthy(user.isAdmin) ||
isTruthy(user.is_staff) ||
isTruthy(user.isStaff) ||
isTruthy(user.is_superuser) ||
isTruthy(user.isSuperuser);
// Call onLoginSuccess callback first
onLoginSuccess(); onLoginSuccess();
// Redirect based on user role
const redirectPath = userIsAdmin ? "/admin/dashboard" : "/user/dashboard";
setTimeout(() => {
window.location.href = redirectPath;
}, 200);
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again."; const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";

View File

@ -25,7 +25,7 @@ export function Navbar() {
const isUserDashboard = pathname?.startsWith("/user/dashboard"); const isUserDashboard = pathname?.startsWith("/user/dashboard");
const isUserSettings = pathname?.startsWith("/user/settings"); const isUserSettings = pathname?.startsWith("/user/settings");
const isUserRoute = pathname?.startsWith("/user/"); const isUserRoute = pathname?.startsWith("/user/");
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, logout, user, isAdmin } = useAuth();
const scrollToSection = (id: string) => { const scrollToSection = (id: string) => {
const element = document.getElementById(id); const element = document.getElementById(id);
@ -36,8 +36,11 @@ export function Navbar() {
}; };
const handleLoginSuccess = () => { const handleLoginSuccess = () => {
// Redirect to admin dashboard after successful login // Check if user is admin/staff/superuser and redirect accordingly
router.push("/admin/dashboard"); // Note: user might not be immediately available, so we check isAdmin from hook
// which is computed from the user data
const redirectPath = isAdmin ? "/admin/dashboard" : "/user/dashboard";
router.push(redirectPath);
setMobileMenuOpen(false); setMobileMenuOpen(false);
}; };

View File

@ -75,6 +75,13 @@ export function useAppointments() {
staleTime: 1 * 60 * 1000, // 1 minute staleTime: 1 * 60 * 1000, // 1 minute
}); });
// Get user appointment stats query
const userAppointmentStatsQuery = useQuery<UserAppointmentStats>({
queryKey: ["appointments", "user", "stats"],
queryFn: () => getUserAppointmentStats(),
staleTime: 1 * 60 * 1000, // 1 minute
});
// Get Jitsi meeting info query // Get Jitsi meeting info query
const useJitsiMeetingInfo = (id: string | null) => { const useJitsiMeetingInfo = (id: string | null) => {
return useQuery<JitsiMeetingInfo>({ return useQuery<JitsiMeetingInfo>({
@ -166,6 +173,7 @@ export function useAppointments() {
userAppointments: userAppointmentsQuery.data || [], userAppointments: userAppointmentsQuery.data || [],
adminAvailability: adminAvailabilityQuery.data, adminAvailability: adminAvailabilityQuery.data,
appointmentStats: appointmentStatsQuery.data, appointmentStats: appointmentStatsQuery.data,
userAppointmentStats: userAppointmentStatsQuery.data,
// Query states // Query states
isLoadingAvailableDates: availableDatesQuery.isLoading, isLoadingAvailableDates: availableDatesQuery.isLoading,
@ -173,6 +181,7 @@ export function useAppointments() {
isLoadingUserAppointments: userAppointmentsQuery.isLoading, isLoadingUserAppointments: userAppointmentsQuery.isLoading,
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading, isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
isLoadingStats: appointmentStatsQuery.isLoading, isLoadingStats: appointmentStatsQuery.isLoading,
isLoadingUserStats: userAppointmentStatsQuery.isLoading,
// Query refetch functions // Query refetch functions
refetchAvailableDates: availableDatesQuery.refetch, refetchAvailableDates: availableDatesQuery.refetch,
@ -180,6 +189,7 @@ export function useAppointments() {
refetchUserAppointments: userAppointmentsQuery.refetch, refetchUserAppointments: userAppointmentsQuery.refetch,
refetchAdminAvailability: adminAvailabilityQuery.refetch, refetchAdminAvailability: adminAvailabilityQuery.refetch,
refetchStats: appointmentStatsQuery.refetch, refetchStats: appointmentStatsQuery.refetch,
refetchUserStats: userAppointmentStatsQuery.refetch,
// Hooks for specific queries // Hooks for specific queries
useAppointmentDetail, useAppointmentDetail,

View File

@ -14,6 +14,7 @@ import {
refreshToken, refreshToken,
getStoredTokens, getStoredTokens,
getStoredUser, getStoredUser,
getStoredUserSync,
storeTokens, storeTokens,
storeUser, storeUser,
clearAuthData, clearAuthData,

View File

@ -13,6 +13,7 @@ import type {
AvailableDatesResponse, AvailableDatesResponse,
AdminAvailability, AdminAvailability,
AppointmentStats, AppointmentStats,
UserAppointmentStats,
JitsiMeetingInfo, JitsiMeetingInfo,
ApiError, ApiError,
} from "@/lib/models/appointments"; } from "@/lib/models/appointments";
@ -451,6 +452,32 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
return data; return data;
} }
// Get user appointment stats
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data: UserAppointmentStats = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
}
return data;
}
// Get Jitsi meeting info // Get Jitsi meeting info
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> { export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
const tokens = getStoredTokens(); const tokens = getStoredTokens();

View File

@ -8,6 +8,7 @@ import type {
VerifyPasswordResetOtpInput, VerifyPasswordResetOtpInput,
ResetPasswordInput, ResetPasswordInput,
TokenRefreshInput, TokenRefreshInput,
UpdateProfileInput,
} from "@/lib/schema/auth"; } from "@/lib/schema/auth";
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth"; import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
@ -369,3 +370,72 @@ export async function getAllUsers(): Promise<User[]> {
return []; return [];
} }
// Get user profile
export async function getProfile(): Promise<User> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.auth.getProfile, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data);
throw new Error(errorMessage);
}
// Handle different response formats
if (data.user) {
return data.user;
}
if (data.id) {
return data;
}
throw new Error("Invalid profile response format");
}
// Update user profile
export async function updateProfile(input: UpdateProfileInput): Promise<User> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.auth.updateProfile, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(input),
});
const data = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data);
throw new Error(errorMessage);
}
// Handle different response formats
if (data.user) {
return data.user;
}
if (data.id) {
return data;
}
throw new Error("Invalid profile response format");
}

View File

@ -13,6 +13,8 @@ export const API_ENDPOINTS = {
resetPassword: `${API_BASE_URL}/auth/reset-password/`, resetPassword: `${API_BASE_URL}/auth/reset-password/`,
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`, tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
allUsers: `${API_BASE_URL}/auth/all-users/`, allUsers: `${API_BASE_URL}/auth/all-users/`,
getProfile: `${API_BASE_URL}/auth/profile/`,
updateProfile: `${API_BASE_URL}/auth/profile/update/`,
}, },
meetings: { meetings: {
base: `${API_BASE_URL}/meetings/`, base: `${API_BASE_URL}/meetings/`,
@ -20,6 +22,7 @@ export const API_ENDPOINTS = {
createAppointment: `${API_BASE_URL}/meetings/appointments/create/`, createAppointment: `${API_BASE_URL}/meetings/appointments/create/`,
listAppointments: `${API_BASE_URL}/meetings/appointments/`, listAppointments: `${API_BASE_URL}/meetings/appointments/`,
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`, userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`, adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
}, },
} as const; } as const;

View File

@ -55,6 +55,15 @@ export interface AppointmentStats {
users?: number; // Total users count from API users?: number; // Total users count from API
} }
export interface UserAppointmentStats {
total_requests: number;
pending_review: number;
scheduled: number;
rejected: number;
completed: number;
completion_rate: number;
}
export interface JitsiMeetingInfo { export interface JitsiMeetingInfo {
meeting_url: string; meeting_url: string;
room_id: string; room_id: string;

View File

@ -78,3 +78,12 @@ export const tokenRefreshSchema = z.object({
export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>; export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>;
// Update Profile Schema
export const updateProfileSchema = z.object({
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
phone_number: z.string().optional(),
});
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;

240
lib/utils/encryption.ts Normal file
View File

@ -0,0 +1,240 @@
/**
* Encryption utilities for securing sensitive user data
* Uses Web Crypto API with AES-GCM for authenticated encryption
*/
// Generate a key from a password using PBKDF2
async function deriveKey(password: string, salt: BufferSource): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveBits", "deriveKey"]
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
// Get or create encryption key from localStorage
async function getEncryptionKey(): Promise<CryptoKey> {
const STORAGE_KEY = "encryption_salt";
const PASSWORD_KEY = "encryption_password";
// Generate a unique password based on user's browser fingerprint
// This creates a consistent key per browser/device
const getBrowserFingerprint = (): string => {
try {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.textBaseline = "top";
ctx.font = "14px 'Arial'";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = "#f60";
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = "#069";
ctx.fillText("Browser fingerprint", 2, 15);
ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
ctx.fillText("Browser fingerprint", 4, 17);
}
const fingerprint = (canvas.toDataURL() || "") +
(navigator.userAgent || "") +
(navigator.language || "") +
(screen.width || 0) +
(screen.height || 0) +
(new Date().getTimezoneOffset() || 0);
return fingerprint;
} catch (error) {
// Fallback if canvas fingerprinting fails
return (navigator.userAgent || "") +
(navigator.language || "") +
(screen.width || 0) +
(screen.height || 0);
}
};
let salt = localStorage.getItem(STORAGE_KEY);
let password = localStorage.getItem(PASSWORD_KEY);
if (!salt || !password) {
// Generate new salt and password
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
salt = Array.from(saltBytes)
.map(b => b.toString(16).padStart(2, "0"))
.join("");
password = getBrowserFingerprint();
localStorage.setItem(STORAGE_KEY, salt);
localStorage.setItem(PASSWORD_KEY, password);
}
// Convert hex string back to Uint8Array
const saltBytes = salt.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || [];
const saltArray = new Uint8Array(saltBytes);
return deriveKey(password, saltArray);
}
// Encrypt a string value
export async function encryptValue(value: string): Promise<string> {
if (!value || typeof window === "undefined") return value;
try {
const key = await getEncryptionKey();
const encoder = new TextEncoder();
const data = encoder.encode(value);
// Generate a random IV for each encryption
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
data
);
// Combine IV and encrypted data
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
// Convert to base64 for storage
const binaryString = String.fromCharCode(...combined);
return btoa(binaryString);
} catch (error) {
console.error("Encryption error:", error);
// If encryption fails, return original value (graceful degradation)
return value;
}
}
// Decrypt a string value
export async function decryptValue(encryptedValue: string): Promise<string> {
if (!encryptedValue || typeof window === "undefined") return encryptedValue;
try {
const key = await getEncryptionKey();
// Decode from base64
const binaryString = atob(encryptedValue);
const combined = Uint8Array.from(binaryString, c => c.charCodeAt(0));
// Extract IV and encrypted data
const iv = combined.slice(0, 12);
const encrypted = combined.slice(12);
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
encrypted
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
} catch (error) {
console.error("Decryption error:", error);
// If decryption fails, try to return as-is (might be unencrypted legacy data)
return encryptedValue;
}
}
// Encrypt sensitive fields in a user object
export async function encryptUserData(user: any): Promise<any> {
if (!user || typeof window === "undefined") return user;
const encrypted = { ...user };
// Encrypt sensitive fields
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
for (const field of sensitiveFields) {
if (encrypted[field]) {
encrypted[field] = await encryptValue(String(encrypted[field]));
}
}
return encrypted;
}
// Decrypt sensitive fields in a user object
export async function decryptUserData(user: any): Promise<any> {
if (!user || typeof window === "undefined") return user;
const decrypted = { ...user };
// Decrypt sensitive fields
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
for (const field of sensitiveFields) {
if (decrypted[field]) {
try {
decrypted[field] = await decryptValue(String(decrypted[field]));
} catch (error) {
// If decryption fails, keep original value (might be unencrypted)
console.warn(`Failed to decrypt field ${field}:`, error);
}
}
}
return decrypted;
}
// Check if a value is encrypted (heuristic check)
function isEncrypted(value: string): boolean {
// Encrypted values are base64 encoded and have a specific structure
// This is a simple heuristic - encrypted values will be longer and base64-like
if (!value || value.length < 20) return false;
try {
// Try to decode as base64
atob(value);
// If it decodes successfully and is long enough, it's likely encrypted
return value.length > 30;
} catch {
return false;
}
}
// Smart encrypt/decrypt that handles both encrypted and unencrypted data
export async function smartDecryptUserData(user: any): Promise<any> {
if (!user || typeof window === "undefined") return user;
const decrypted = { ...user };
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
for (const field of sensitiveFields) {
if (decrypted[field] && typeof decrypted[field] === "string") {
if (isEncrypted(decrypted[field])) {
try {
decrypted[field] = await decryptValue(decrypted[field]);
} catch (error) {
console.warn(`Failed to decrypt field ${field}:`, error);
}
}
// If not encrypted, keep as-is (backward compatibility)
}
}
return decrypted;
}

View File

@ -72,4 +72,3 @@ export const config = {
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
], ],
}; };