From a1611e17828ae815297befd3f636632463f3b3da Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Tue, 25 Nov 2025 21:25:53 +0000 Subject: [PATCH] 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. --- app/(admin)/admin/settings/page.tsx | 140 +++++++++--- app/(auth)/login/page.tsx | 17 +- app/(user)/user/dashboard/page.tsx | 325 +++++++++++++++------------- components/LoginDialog.tsx | 25 ++- components/Navbar.tsx | 9 +- hooks/useAppointments.ts | 10 + hooks/useAuth.ts | 1 + lib/actions/appointments.ts | 27 +++ lib/actions/auth.ts | 70 ++++++ lib/api_urls.ts | 3 + lib/models/appointments.ts | 9 + lib/schema/auth.ts | 9 + lib/utils/encryption.ts | 240 ++++++++++++++++++++ middleware.ts | 1 - 14 files changed, 695 insertions(+), 191 deletions(-) create mode 100644 lib/utils/encryption.ts diff --git a/app/(admin)/admin/settings/page.tsx b/app/(admin)/admin/settings/page.tsx index d6668ba..90a17c5 100644 --- a/app/(admin)/admin/settings/page.tsx +++ b/app/(admin)/admin/settings/page.tsx @@ -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)); - setLoading(false); - // In a real app, you would show a success message here + 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); + } }; const handlePasswordSave = async () => { @@ -113,15 +157,20 @@ export default function AdminSettingsPage() { @@ -139,21 +188,49 @@ export default function AdminSettingsPage() { -
- -
- - handleInputChange("fullName", e.target.value)} - className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`} - placeholder="Enter your full name" - /> + {fetching ? ( +
+
-
+ ) : ( + <> +
+
+ +
+ + handleInputChange("firstName", e.target.value)} + className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`} + placeholder="Enter your first name" + required + /> +
+
+ +
+ +
+ + 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 + /> +
+
+
+ + )}
+

+ Email address cannot be changed +

diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index ebd6e09..935a9a6 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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(() => { diff --git a/app/(user)/user/dashboard/page.tsx b/app/(user)/user/dashboard/page.tsx index 6fc605f..d2e231c 100644 --- a/app/(user)/user/dashboard/page.tsx +++ b/app/(user)/user/dashboard/page.tsx @@ -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([]); - 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 completedBookings = bookings.filter( - (booking) => booking.status === "completed" - ); - const cancelledBookings = bookings.filter( - (booking) => booking.status === "cancelled" - ); + 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" + ); + }, [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 (
@@ -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" > - Book Appointment + Request Appointment
@@ -166,7 +160,7 @@ export default function UserDashboard() { {loading ? (
-
+
) : ( <> @@ -214,53 +208,62 @@ export default function UserDashboard() {
{/* Upcoming Appointments Section */} - {upcomingBookings.length > 0 && ( + {upcomingAppointments.length > 0 ? (

Upcoming Appointments

- {upcomingBookings.map((booking) => ( + {upcomingAppointments.map((appointment) => (
-
- - - {formatDate(booking.scheduled_at)} - -
-
- - - {formatTime(booking.scheduled_at)} - - - ({booking.duration} minutes) - -
- {booking.notes && ( + {appointment.scheduled_datetime && ( + <> +
+ + + {formatDate(appointment.scheduled_datetime)} + +
+
+ + + {formatTime(appointment.scheduled_datetime)} + + {appointment.scheduled_duration && ( + + ({appointment.scheduled_duration} minutes) + + )} +
+ + )} + {appointment.reason && (

- {booking.notes} + {appointment.reason}

)}
- - {booking.status.charAt(0).toUpperCase() + - booking.status.slice(1)} - - - ${booking.amount} + + {appointment.status.charAt(0).toUpperCase() + + appointment.status.slice(1).replace('_', ' ')}
- {booking.jitsi_room_url && ( + {appointment.jitsi_meet_url && appointment.can_join_meeting && (
+ ) : !loading && ( +
+
+ +

+ Request Appointment +

+

+ No upcoming appointments. Book an appointment to get started. +

+ + + +
+
)} {/* Account Information */} -
-

- Account Information -

-
-
-
- + {user && ( +
+

+ Account Information +

+
+
+
+ +
+
+

+ Full Name +

+

+ {user.first_name} {user.last_name} +

+
-
-

- Full Name -

-

- John Doe -

-
-
-
-
- -
-
-

- Email -

-

- john.doe@example.com -

-
-
-
-
- -
-
-

- Phone -

-

- +1 (555) 123-4567 -

-
-
-
-
- -
-
-

- Member Since -

-

- January 2025 -

+
+
+ +
+
+

+ Email +

+

+ {user.email} +

+
+ {user.phone_number && ( +
+
+ +
+
+

+ Phone +

+

+ {user.phone_number} +

+
+
+ )} + {user.date_joined && ( +
+
+ +
+
+

+ Member Since +

+

+ {formatMemberSince(user.date_joined)} +

+
+
+ )}
-
+ )} )} diff --git a/components/LoginDialog.tsx b/components/LoginDialog.tsx index a4ee3a9..fa5fc3c 100644 --- a/components/LoginDialog.tsx +++ b/components/LoginDialog.tsx @@ -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."; diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 04a8a73..ef2ccf0 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -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); }; diff --git a/hooks/useAppointments.ts b/hooks/useAppointments.ts index 71b66fb..3d65fe4 100644 --- a/hooks/useAppointments.ts +++ b/hooks/useAppointments.ts @@ -75,6 +75,13 @@ export function useAppointments() { staleTime: 1 * 60 * 1000, // 1 minute }); + // Get user appointment stats query + const userAppointmentStatsQuery = useQuery({ + queryKey: ["appointments", "user", "stats"], + queryFn: () => getUserAppointmentStats(), + staleTime: 1 * 60 * 1000, // 1 minute + }); + // Get Jitsi meeting info query const useJitsiMeetingInfo = (id: string | null) => { return useQuery({ @@ -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, diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index ab59df6..9b549c9 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -14,6 +14,7 @@ import { refreshToken, getStoredTokens, getStoredUser, + getStoredUserSync, storeTokens, storeUser, clearAuthData, diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts index f4a1c29..a8ad586 100644 --- a/lib/actions/appointments.ts +++ b/lib/actions/appointments.ts @@ -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 { return data; } +// Get user appointment stats +export async function getUserAppointmentStats(): Promise { + 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 { const tokens = getStoredTokens(); diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts index 4056bf1..229e722 100644 --- a/lib/actions/auth.ts +++ b/lib/actions/auth.ts @@ -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 { return []; } +// Get user profile +export async function getProfile(): Promise { + 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 { + 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"); +} + diff --git a/lib/api_urls.ts b/lib/api_urls.ts index 0dab8aa..a4ffa32 100644 --- a/lib/api_urls.ts +++ b/lib/api_urls.ts @@ -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; diff --git a/lib/models/appointments.ts b/lib/models/appointments.ts index 7fef918..4810fe1 100644 --- a/lib/models/appointments.ts +++ b/lib/models/appointments.ts @@ -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; diff --git a/lib/schema/auth.ts b/lib/schema/auth.ts index f0d9b3f..f77813f 100644 --- a/lib/schema/auth.ts +++ b/lib/schema/auth.ts @@ -78,3 +78,12 @@ export const tokenRefreshSchema = z.object({ export type TokenRefreshInput = z.infer; +// 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; + diff --git a/lib/utils/encryption.ts b/lib/utils/encryption.ts new file mode 100644 index 0000000..5dd9986 --- /dev/null +++ b/lib/utils/encryption.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} + diff --git a/middleware.ts b/middleware.ts index 630a3ac..884c367 100644 --- a/middleware.ts +++ b/middleware.ts @@ -72,4 +72,3 @@ export const config = { "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; -