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