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";
|
"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({
|
||||||
|
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);
|
setLoading(false);
|
||||||
// In a real app, you would show a success message here
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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,22 +188,50 @@ export default function AdminSettingsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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">
|
<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"}`}>
|
||||||
Full Name
|
First Name *
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<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"}`} />
|
<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
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.fullName}
|
value={formData.firstName}
|
||||||
onChange={(e) => handleInputChange("fullName", e.target.value)}
|
onChange={(e) => handleInputChange("firstName", e.target.value)}
|
||||||
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
|
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>
|
</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"}`}>
|
||||||
Email Address
|
Email Address
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
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(
|
}, [userAppointments]);
|
||||||
(booking) => booking.status === "completed"
|
|
||||||
);
|
const completedAppointments = useMemo(() => {
|
||||||
const cancelledBookings = bookings.filter(
|
return userAppointments.filter(
|
||||||
(booking) => booking.status === "cancelled"
|
(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">
|
||||||
|
{appointment.scheduled_datetime && (
|
||||||
|
<>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
{formatDate(booking.scheduled_at)}
|
{formatDate(appointment.scheduled_datetime)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
|
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
|
||||||
{formatTime(booking.scheduled_at)}
|
{formatTime(appointment.scheduled_datetime)}
|
||||||
</span>
|
</span>
|
||||||
|
{appointment.scheduled_duration && (
|
||||||
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
({booking.duration} minutes)
|
({appointment.scheduled_duration} minutes)
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{booking.notes && (
|
</>
|
||||||
|
)}
|
||||||
|
{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,9 +278,28 @@ 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 */}
|
||||||
|
{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'}`}>
|
<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'}`}>
|
||||||
Account Information
|
Account Information
|
||||||
@ -292,7 +314,7 @@ export default function UserDashboard() {
|
|||||||
Full Name
|
Full Name
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
John Doe
|
{user.first_name} {user.last_name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -305,10 +327,11 @@ export default function UserDashboard() {
|
|||||||
Email
|
Email
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
john.doe@example.com
|
{user.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{user.phone_number && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
<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'}`} />
|
<Phone className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
@ -318,10 +341,12 @@ export default function UserDashboard() {
|
|||||||
Phone
|
Phone
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
+1 (555) 123-4567
|
{user.phone_number}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
{user.date_joined && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
<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'}`} />
|
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
@ -331,12 +356,14 @@ export default function UserDashboard() {
|
|||||||
Member Since
|
Member Since
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
January 2025
|
{formatMemberSince(user.date_joined)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -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.";
|
||||||
|
|||||||
@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
refreshToken,
|
refreshToken,
|
||||||
getStoredTokens,
|
getStoredTokens,
|
||||||
getStoredUser,
|
getStoredUser,
|
||||||
|
getStoredUserSync,
|
||||||
storeTokens,
|
storeTokens,
|
||||||
storeUser,
|
storeUser,
|
||||||
clearAuthData,
|
clearAuthData,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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");
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
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)$).*)",
|
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user