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:
parent
f6bd813c07
commit
a1611e1782
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
@ -13,16 +13,21 @@ import {
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { getProfile, updateProfile } from "@/lib/actions/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: "Hammond",
|
||||
email: "admin@attuneheart.com",
|
||||
phone: "+1 (555) 123-4567",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
});
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: "",
|
||||
@ -37,6 +42,30 @@ export default function AdminSettingsPage() {
|
||||
const { theme } = useAppTheme();
|
||||
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) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
@ -59,11 +88,26 @@ export default function AdminSettingsPage() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.firstName || !formData.lastName) {
|
||||
toast.error("First name and last name are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
try {
|
||||
await updateProfile({
|
||||
first_name: formData.firstName,
|
||||
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);
|
||||
// In a real app, you would show a success message here
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSave = async () => {
|
||||
@ -113,15 +157,20 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
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"
|
||||
disabled={loading || fetching}
|
||||
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 ? (
|
||||
<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 Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -139,22 +188,50 @@ export default function AdminSettingsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{fetching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-rose-600" />
|
||||
</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"}`}>
|
||||
Full Name
|
||||
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.fullName}
|
||||
onChange={(e) => handleInputChange("fullName", e.target.value)}
|
||||
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 full name"
|
||||
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">
|
||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Email Address
|
||||
@ -164,11 +241,14 @@ export default function AdminSettingsPage() {
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
|
||||
placeholder="Enter your email"
|
||||
disabled
|
||||
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="Email address"
|
||||
/>
|
||||
</div>
|
||||
<p className={`text-xs ${isDark ? "text-gray-500" : "text-gray-400"}`}>
|
||||
Email address cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -139,13 +139,18 @@ function LoginContent() {
|
||||
// Wait a moment for cookies to be set, then redirect
|
||||
// Check if user is admin/staff/superuser - check all possible field names
|
||||
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 =
|
||||
user.is_admin === true ||
|
||||
user.isAdmin === true ||
|
||||
user.is_staff === true ||
|
||||
user.isStaff === true ||
|
||||
user.is_superuser === true ||
|
||||
user.isSuperuser === true;
|
||||
isTruthy(user.is_admin) ||
|
||||
isTruthy(user.isAdmin) ||
|
||||
isTruthy(user.is_staff) ||
|
||||
isTruthy(user.isStaff) ||
|
||||
isTruthy(user.is_superuser) ||
|
||||
isTruthy(user.isSuperuser);
|
||||
|
||||
// Wait longer for cookies to be set and middleware to process
|
||||
setTimeout(() => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Calendar,
|
||||
@ -16,57 +16,28 @@ import {
|
||||
CalendarCheck,
|
||||
ArrowUpRight,
|
||||
Settings,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
|
||||
interface Booking {
|
||||
ID: number;
|
||||
scheduled_at: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
amount: number;
|
||||
notes: string;
|
||||
jitsi_room_url?: string;
|
||||
}
|
||||
import { useAppointments } from "@/hooks/useAppointments";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import type { Appointment } from "@/lib/models/appointments";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function UserDashboard() {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call to fetch user bookings
|
||||
const fetchBookings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Simulate network delay
|
||||
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 { user } = useAuth();
|
||||
const {
|
||||
userAppointments,
|
||||
userAppointmentStats,
|
||||
isLoadingUserAppointments,
|
||||
isLoadingUserStats,
|
||||
refetchUserAppointments,
|
||||
refetchUserStats,
|
||||
} = useAppointments();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
@ -86,47 +57,70 @@ export default function UserDashboard() {
|
||||
});
|
||||
};
|
||||
|
||||
const upcomingBookings = bookings.filter(
|
||||
(booking) => booking.status === "scheduled"
|
||||
const formatMemberSince = (dateString?: string) => {
|
||||
if (!dateString) return "N/A";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Filter appointments by status
|
||||
const upcomingAppointments = useMemo(() => {
|
||||
return userAppointments.filter(
|
||||
(appointment) => appointment.status === "scheduled"
|
||||
);
|
||||
const completedBookings = bookings.filter(
|
||||
(booking) => booking.status === "completed"
|
||||
);
|
||||
const cancelledBookings = bookings.filter(
|
||||
(booking) => booking.status === "cancelled"
|
||||
}, [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 = [
|
||||
{
|
||||
title: "Upcoming Appointments",
|
||||
value: upcomingBookings.length,
|
||||
value: stats.scheduled,
|
||||
icon: CalendarCheck,
|
||||
trend: "+2",
|
||||
trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Completed Sessions",
|
||||
value: completedBookings.length,
|
||||
value: stats.completed || 0,
|
||||
icon: CheckCircle2,
|
||||
trend: "+5",
|
||||
trend: stats.completed > 0 ? `+${stats.completed}` : "0",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Total Appointments",
|
||||
value: bookings.length,
|
||||
value: stats.total_requests,
|
||||
icon: Calendar,
|
||||
trend: "+12%",
|
||||
trend: `${Math.round(stats.completion_rate || 0)}%`,
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Total Spent",
|
||||
value: `$${bookings.reduce((sum, b) => sum + b.amount, 0)}`,
|
||||
icon: Heart,
|
||||
trend: "+18%",
|
||||
trendUp: true,
|
||||
title: "Pending Review",
|
||||
value: stats.pending_review,
|
||||
icon: Calendar,
|
||||
trend: stats.pending_review > 0 ? `${stats.pending_review}` : "0",
|
||||
trendUp: false,
|
||||
},
|
||||
];
|
||||
|
||||
const loading = isLoadingUserAppointments || isLoadingUserStats;
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
|
||||
<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"
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4 mr-2" />
|
||||
Book Appointment
|
||||
Request Appointment
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@ -166,7 +160,7 @@ export default function UserDashboard() {
|
||||
|
||||
{loading ? (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
@ -214,53 +208,62 @@ export default function UserDashboard() {
|
||||
</div>
|
||||
|
||||
{/* 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'}`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
Upcoming Appointments
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{upcomingBookings.map((booking) => (
|
||||
{upcomingAppointments.map((appointment) => (
|
||||
<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'}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{appointment.scheduled_datetime && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{formatDate(booking.scheduled_at)}
|
||||
{formatDate(appointment.scheduled_datetime)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
|
||||
{formatTime(booking.scheduled_at)}
|
||||
{formatTime(appointment.scheduled_datetime)}
|
||||
</span>
|
||||
{appointment.scheduled_duration && (
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
({booking.duration} minutes)
|
||||
({appointment.scheduled_duration} minutes)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{booking.notes && (
|
||||
</>
|
||||
)}
|
||||
{appointment.reason && (
|
||||
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{booking.notes}
|
||||
{appointment.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col sm:items-end gap-3">
|
||||
<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'}`}>
|
||||
{booking.status.charAt(0).toUpperCase() +
|
||||
booking.status.slice(1)}
|
||||
</span>
|
||||
<span className={`text-lg font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
${booking.amount}
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
appointment.status === "scheduled"
|
||||
? isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'
|
||||
: appointment.status === "pending_review"
|
||||
? isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700'
|
||||
: 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>
|
||||
</div>
|
||||
{booking.jitsi_room_url && (
|
||||
{appointment.jitsi_meet_url && appointment.can_join_meeting && (
|
||||
<a
|
||||
href={booking.jitsi_room_url}
|
||||
href={appointment.jitsi_meet_url}
|
||||
target="_blank"
|
||||
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"
|
||||
@ -275,9 +278,28 @@ export default function UserDashboard() {
|
||||
))}
|
||||
</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 */}
|
||||
{user && (
|
||||
<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'}`}>
|
||||
Account Information
|
||||
@ -292,7 +314,7 @@ export default function UserDashboard() {
|
||||
Full Name
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
John Doe
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -305,10 +327,11 @@ export default function UserDashboard() {
|
||||
Email
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
john.doe@example.com
|
||||
{user.email}
|
||||
</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'}`} />
|
||||
@ -318,10 +341,12 @@ export default function UserDashboard() {
|
||||
Phone
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
+1 (555) 123-4567
|
||||
{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'}`} />
|
||||
@ -331,12 +356,14 @@ export default function UserDashboard() {
|
||||
Member Since
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
January 2025
|
||||
{formatMemberSince(user.date_joined)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@ -70,9 +70,30 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail,
|
||||
onOpenChange(false);
|
||||
// Reset form
|
||||
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();
|
||||
|
||||
// Redirect based on user role
|
||||
const redirectPath = userIsAdmin ? "/admin/dashboard" : "/user/dashboard";
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectPath;
|
||||
}, 200);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
|
||||
|
||||
@ -25,7 +25,7 @@ export function Navbar() {
|
||||
const isUserDashboard = pathname?.startsWith("/user/dashboard");
|
||||
const isUserSettings = pathname?.startsWith("/user/settings");
|
||||
const isUserRoute = pathname?.startsWith("/user/");
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const { isAuthenticated, logout, user, isAdmin } = useAuth();
|
||||
|
||||
const scrollToSection = (id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
@ -36,8 +36,11 @@ export function Navbar() {
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
// Redirect to admin dashboard after successful login
|
||||
router.push("/admin/dashboard");
|
||||
// Check if user is admin/staff/superuser and redirect accordingly
|
||||
// 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);
|
||||
};
|
||||
|
||||
|
||||
@ -75,6 +75,13 @@ export function useAppointments() {
|
||||
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
|
||||
const useJitsiMeetingInfo = (id: string | null) => {
|
||||
return useQuery<JitsiMeetingInfo>({
|
||||
@ -166,6 +173,7 @@ export function useAppointments() {
|
||||
userAppointments: userAppointmentsQuery.data || [],
|
||||
adminAvailability: adminAvailabilityQuery.data,
|
||||
appointmentStats: appointmentStatsQuery.data,
|
||||
userAppointmentStats: userAppointmentStatsQuery.data,
|
||||
|
||||
// Query states
|
||||
isLoadingAvailableDates: availableDatesQuery.isLoading,
|
||||
@ -173,6 +181,7 @@ export function useAppointments() {
|
||||
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
|
||||
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
|
||||
isLoadingStats: appointmentStatsQuery.isLoading,
|
||||
isLoadingUserStats: userAppointmentStatsQuery.isLoading,
|
||||
|
||||
// Query refetch functions
|
||||
refetchAvailableDates: availableDatesQuery.refetch,
|
||||
@ -180,6 +189,7 @@ export function useAppointments() {
|
||||
refetchUserAppointments: userAppointmentsQuery.refetch,
|
||||
refetchAdminAvailability: adminAvailabilityQuery.refetch,
|
||||
refetchStats: appointmentStatsQuery.refetch,
|
||||
refetchUserStats: userAppointmentStatsQuery.refetch,
|
||||
|
||||
// Hooks for specific queries
|
||||
useAppointmentDetail,
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
refreshToken,
|
||||
getStoredTokens,
|
||||
getStoredUser,
|
||||
getStoredUserSync,
|
||||
storeTokens,
|
||||
storeUser,
|
||||
clearAuthData,
|
||||
|
||||
@ -13,6 +13,7 @@ import type {
|
||||
AvailableDatesResponse,
|
||||
AdminAvailability,
|
||||
AppointmentStats,
|
||||
UserAppointmentStats,
|
||||
JitsiMeetingInfo,
|
||||
ApiError,
|
||||
} from "@/lib/models/appointments";
|
||||
@ -451,6 +452,32 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
|
||||
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
|
||||
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
VerifyPasswordResetOtpInput,
|
||||
ResetPasswordInput,
|
||||
TokenRefreshInput,
|
||||
UpdateProfileInput,
|
||||
} from "@/lib/schema/auth";
|
||||
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
|
||||
|
||||
@ -369,3 +370,72 @@ export async function getAllUsers(): Promise<User[]> {
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,8 @@ export const API_ENDPOINTS = {
|
||||
resetPassword: `${API_BASE_URL}/auth/reset-password/`,
|
||||
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
|
||||
allUsers: `${API_BASE_URL}/auth/all-users/`,
|
||||
getProfile: `${API_BASE_URL}/auth/profile/`,
|
||||
updateProfile: `${API_BASE_URL}/auth/profile/update/`,
|
||||
},
|
||||
meetings: {
|
||||
base: `${API_BASE_URL}/meetings/`,
|
||||
@ -20,6 +22,7 @@ export const API_ENDPOINTS = {
|
||||
createAppointment: `${API_BASE_URL}/meetings/appointments/create/`,
|
||||
listAppointments: `${API_BASE_URL}/meetings/appointments/`,
|
||||
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
|
||||
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
|
||||
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@ -55,6 +55,15 @@ export interface AppointmentStats {
|
||||
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 {
|
||||
meeting_url: string;
|
||||
room_id: string;
|
||||
|
||||
@ -78,3 +78,12 @@ export const tokenRefreshSchema = z.object({
|
||||
|
||||
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
240
lib/utils/encryption.ts
Normal 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;
|
||||
}
|
||||
|
||||
@ -72,4 +72,3 @@ export const config = {
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user