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