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";
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">

View File

@ -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(() => {

View File

@ -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>

View File

@ -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.";

View File

@ -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);
};

View File

@ -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,

View File

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

View File

@ -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();

View File

@ -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");
}

View File

@ -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;

View File

@ -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;

View File

@ -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
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)$).*)",
],
};