feat/authentication #21

Merged
Hammond merged 12 commits from feat/authentication into master 2025-11-24 22:09:51 +00:00
7 changed files with 373 additions and 193 deletions
Showing only changes of commit 4f6e64bf99 - Show all commits

View File

@ -8,104 +8,36 @@ import {
Video,
FileText,
MoreVertical,
Search,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
interface User {
ID: number;
CreatedAt?: string;
UpdatedAt?: string;
DeletedAt?: string | null;
first_name: string;
last_name: string;
email: string;
phone: string;
location: string;
date_of_birth?: string;
is_admin?: boolean;
bookings?: null;
}
interface Booking {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt: string | null;
user_id: number;
user: User;
scheduled_at: string;
duration: number;
status: string;
jitsi_room_id: string;
jitsi_room_url: string;
payment_id: string;
payment_status: string;
amount: number;
notes: string;
}
interface BookingsResponse {
bookings: Booking[];
limit: number;
offset: number;
total: number;
}
import { listAppointments } from "@/lib/actions/appointments";
import { Input } from "@/components/ui/input";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
export default function Booking() {
const [bookings, setBookings] = useState<Booking[]>([]);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const { theme } = useAppTheme();
const isDark = theme === "dark";
useEffect(() => {
// Simulate API call
const fetchBookings = async () => {
setLoading(true);
await new Promise((resolve) => setTimeout(resolve, 500));
// Mock API response
const mockData: BookingsResponse = {
bookings: [
{
ID: 1,
CreatedAt: "2025-11-06T11:33:45.704633Z",
UpdatedAt: "2025-11-06T11:33:45.707543Z",
DeletedAt: null,
user_id: 3,
user: {
ID: 3,
CreatedAt: "2025-11-06T10:43:01.299311Z",
UpdatedAt: "2025-11-06T10:43:48.427284Z",
DeletedAt: null,
first_name: "John",
last_name: "Smith",
email: "john.doe@example.com",
phone: "+1234567891",
location: "Los Angeles, CA",
date_of_birth: "0001-01-01T00:00:00Z",
is_admin: true,
bookings: null,
},
scheduled_at: "2025-11-07T10:00:00Z",
duration: 60,
status: "scheduled",
jitsi_room_id: "booking-1-1762428825-22c92ced2870c17c",
jitsi_room_url:
"https://meet.jit.si/booking-1-1762428825-22c92ced2870c17c",
payment_id: "",
payment_status: "pending",
amount: 52,
notes: "Initial consultation session",
},
],
limit: 50,
offset: 0,
total: 1,
};
setBookings(mockData.bookings);
try {
const data = await listAppointments();
console.log("Fetched appointments:", data);
console.log("Appointments count:", data?.length);
setAppointments(data || []);
} catch (error) {
console.error("Failed to fetch appointments:", error);
toast.error("Failed to load appointments. Please try again.");
setAppointments([]);
} finally {
setLoading(false);
}
};
fetchBookings();
@ -137,8 +69,10 @@ export default function Booking() {
return "bg-blue-500/20 text-blue-200";
case "completed":
return "bg-green-500/20 text-green-200";
case "rejected":
case "cancelled":
return "bg-red-500/20 text-red-200";
case "pending_review":
case "pending":
return "bg-yellow-500/20 text-yellow-200";
default:
@ -150,8 +84,10 @@ export default function Booking() {
return "bg-blue-100 text-blue-700";
case "completed":
return "bg-green-100 text-green-700";
case "rejected":
case "cancelled":
return "bg-red-100 text-red-700";
case "pending_review":
case "pending":
return "bg-yellow-100 text-yellow-700";
default:
@ -159,41 +95,20 @@ export default function Booking() {
}
};
const getPaymentStatusColor = (status: string) => {
const normalized = status.toLowerCase();
if (isDark) {
switch (normalized) {
case "paid":
return "bg-green-500/20 text-green-200";
case "pending":
return "bg-yellow-500/20 text-yellow-200";
case "failed":
return "bg-red-500/20 text-red-200";
default:
return "bg-gray-700 text-gray-200";
}
}
switch (normalized) {
case "paid":
return "bg-green-100 text-green-700";
case "pending":
return "bg-yellow-100 text-yellow-700";
case "failed":
return "bg-red-100 text-red-700";
default:
return "bg-gray-100 text-gray-700";
}
const formatStatus = (status: string) => {
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
};
const filteredBookings = bookings.filter(
(booking) =>
booking.user.first_name
const filteredAppointments = appointments.filter(
(appointment) =>
appointment.first_name
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
booking.user.last_name
appointment.last_name
.toLowerCase()
.includes(searchTerm.toLowerCase()) ||
booking.user.email.toLowerCase().includes(searchTerm.toLowerCase())
appointment.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
(appointment.phone && appointment.phone.toLowerCase().includes(searchTerm.toLowerCase()))
);
return (
@ -202,7 +117,8 @@ export default function Booking() {
{/* Main Content */}
<main className="p-3 sm:p-4 md:p-6 lg:p-8">
{/* Page Header */}
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="mb-4 sm:mb-6 flex flex-col gap-3 sm:gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
Bookings
@ -211,25 +127,32 @@ export default function Booking() {
Manage and view all appointment bookings
</p>
</div>
<button className={`w-full sm:w-auto px-3 sm:px-4 py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
isDark ? "bg-rose-500 text-white hover:bg-rose-600" : "bg-gray-900 text-white hover:bg-gray-800"
}`}>
+ New Booking
</button>
</div>
{/* Search Bar */}
<div className="relative">
<Search className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
<Input
type="text"
placeholder="Search by name, email, or phone..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className={`pl-10 ${isDark ? "bg-gray-800 border-gray-700 text-white placeholder:text-gray-400" : "bg-white border-gray-200 text-gray-900 placeholder:text-gray-500"}`}
/>
</div>
</div>
{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>
</div>
) : filteredBookings.length === 0 ? (
) : filteredAppointments.length === 0 ? (
<div className={`rounded-lg border p-12 text-center ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<Calendar className={`w-12 h-12 mx-auto mb-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<p className={`font-medium mb-1 ${isDark ? "text-gray-200" : "text-gray-600"}`}>No bookings found</p>
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{searchTerm
? "Try adjusting your search terms"
: "Create a new booking to get started"}
: "No appointments have been created yet"}
</p>
</div>
) : (
@ -251,10 +174,10 @@ export default function Booking() {
Status
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Payment
Preferred Dates
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Amount
Created
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Actions
@ -262,9 +185,9 @@ export default function Booking() {
</tr>
</thead>
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
{filteredBookings.map((booking) => (
{filteredAppointments.map((appointment) => (
<tr
key={booking.ID}
key={appointment.id}
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
>
<td className="px-3 sm:px-4 md:px-6 py-4">
@ -274,55 +197,75 @@ export default function Booking() {
</div>
<div className="ml-2 sm:ml-4 min-w-0">
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
{booking.user.first_name} {booking.user.last_name}
{appointment.first_name} {appointment.last_name}
</div>
<div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{booking.user.email}
{appointment.email}
</div>
{appointment.phone && (
<div className={`text-xs truncate hidden sm:block ${isDark ? "text-gray-500" : "text-gray-400"}`}>
{appointment.phone}
</div>
)}
{appointment.scheduled_datetime && (
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(booking.scheduled_at)}
{formatDate(appointment.scheduled_datetime)}
</div>
)}
</div>
</div>
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
{appointment.scheduled_datetime ? (
<>
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(booking.scheduled_at)}
{formatDate(appointment.scheduled_datetime)}
</div>
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<Clock className="w-3 h-3" />
{formatTime(booking.scheduled_at)}
{formatTime(appointment.scheduled_datetime)}
</div>
</>
) : (
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Not scheduled
</div>
)}
</td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
{booking.duration} min
{appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
booking.status
appointment.status
)}`}
>
{booking.status}
{formatStatus(appointment.status)}
</span>
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden lg:table-cell">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPaymentStatusColor(
booking.payment_status
)}`}
>
{booking.payment_status}
</span>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
<div className="flex flex-col gap-1">
{appointment.preferred_dates.slice(0, 2).map((date, idx) => (
<span key={idx}>{formatDate(date)}</span>
))}
{appointment.preferred_dates.length > 2 && (
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
)}
</div>
) : (
"-"
)}
</td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm font-medium hidden xl:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
${booking.amount}
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(appointment.created_at)}
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-1 sm:gap-2">
{booking.jitsi_room_url && (
{appointment.jitsi_meet_url && (
<a
href={booking.jitsi_room_url}
href={appointment.jitsi_meet_url}
target="_blank"
rel="noopener noreferrer"
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
@ -331,10 +274,10 @@ export default function Booking() {
<Video className="w-4 h-4" />
</a>
)}
{booking.notes && (
{appointment.reason && (
<button
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
title="View Notes"
title={appointment.reason}
>
<FileText className="w-4 h-4" />
</button>

View File

@ -20,6 +20,11 @@ import {
ArrowDownRight,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAllUsers } from "@/lib/actions/auth";
import { getAppointmentStats, listAppointments } from "@/lib/actions/appointments";
import { toast } from "sonner";
import type { User } from "@/lib/models/auth";
import type { Appointment } from "@/lib/models/appointments";
interface DashboardStats {
total_users: number;
@ -30,6 +35,16 @@ interface DashboardStats {
cancelled_bookings: number;
total_revenue: number;
monthly_revenue: number;
trends: {
total_users: string;
active_users: string;
total_bookings: string;
upcoming_bookings: string;
completed_bookings: string;
cancelled_bookings: string;
total_revenue: string;
monthly_revenue: string;
};
}
export default function Dashboard() {
@ -40,86 +55,166 @@ export default function Dashboard() {
const isDark = theme === "dark";
useEffect(() => {
// Simulate API call
const fetchStats = async () => {
setLoading(true);
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
try {
// Fetch all data in parallel
const [users, appointmentStats, appointments] = await Promise.all([
getAllUsers().catch(() => [] as User[]),
getAppointmentStats().catch(() => null),
listAppointments().catch(() => [] as Appointment[]),
]);
// Mock API response
const mockData: DashboardStats = {
total_users: 3,
active_users: 3,
total_bookings: 6,
upcoming_bookings: 6,
// Calculate statistics
// Use users count from appointment stats if available, otherwise use getAllUsers result
const totalUsers = appointmentStats?.users ?? users.length;
const activeUsers = users.filter(
(user) => user.is_active === true || user.isActive === true
).length;
const totalBookings = appointmentStats?.total_requests || appointments.length;
const upcomingBookings = appointmentStats?.scheduled ||
appointments.filter((apt) => apt.status === "scheduled").length;
// Completed bookings - not in API status types, so set to 0
const completedBookings = 0;
const cancelledBookings = appointmentStats?.rejected ||
appointments.filter((apt) => apt.status === "rejected").length;
// Calculate revenue (assuming appointments have amount field, defaulting to 0)
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
const totalRevenue = appointments.reduce((sum, apt) => {
// If appointment has amount field, use it, otherwise default to 0
const amount = (apt as any).amount || 0;
return sum + amount;
}, 0);
const monthlyRevenue = appointments
.filter((apt) => {
if (!apt.scheduled_datetime) return false;
const aptDate = new Date(apt.scheduled_datetime);
return (
aptDate.getMonth() === currentMonth &&
aptDate.getFullYear() === currentYear
);
})
.reduce((sum, apt) => {
const amount = (apt as any).amount || 0;
return sum + amount;
}, 0);
// For now, use static trends (in a real app, you'd calculate these from historical data)
const trends = {
total_users: "+12%",
active_users: "+8%",
total_bookings: "+24%",
upcoming_bookings: "+6",
completed_bookings: "0%",
cancelled_bookings: "0%",
total_revenue: "+18%",
monthly_revenue: "+32%",
};
setStats({
total_users: totalUsers,
active_users: activeUsers,
total_bookings: totalBookings,
upcoming_bookings: upcomingBookings,
completed_bookings: completedBookings,
cancelled_bookings: cancelledBookings,
total_revenue: totalRevenue,
monthly_revenue: monthlyRevenue,
trends,
});
} catch (error) {
console.error("Failed to fetch dashboard stats:", error);
toast.error("Failed to load dashboard statistics");
// Set default values on error
setStats({
total_users: 0,
active_users: 0,
total_bookings: 0,
upcoming_bookings: 0,
completed_bookings: 0,
cancelled_bookings: 0,
total_revenue: 0,
monthly_revenue: 0,
};
setStats(mockData);
trends: {
total_users: "0%",
active_users: "0%",
total_bookings: "0%",
upcoming_bookings: "0",
completed_bookings: "0%",
cancelled_bookings: "0%",
total_revenue: "0%",
monthly_revenue: "0%",
},
});
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
}, [timePeriod]);
const statCards = [
{
title: "Total Users",
value: stats?.total_users ?? 0,
icon: Users,
trend: "+12%",
trend: stats?.trends.total_users ?? "0%",
trendUp: true,
},
{
title: "Active Users",
value: stats?.active_users ?? 0,
icon: UserCheck,
trend: "+8%",
trend: stats?.trends.active_users ?? "0%",
trendUp: true,
},
{
title: "Total Bookings",
value: stats?.total_bookings ?? 0,
icon: Calendar,
trend: "+24%",
trend: stats?.trends.total_bookings ?? "0%",
trendUp: true,
},
{
title: "Upcoming Bookings",
value: stats?.upcoming_bookings ?? 0,
icon: CalendarCheck,
trend: "+6",
trend: stats?.trends.upcoming_bookings ?? "0",
trendUp: true,
},
{
title: "Completed Bookings",
value: stats?.completed_bookings ?? 0,
icon: CalendarCheck,
trend: "0%",
trend: stats?.trends.completed_bookings ?? "0%",
trendUp: true,
},
{
title: "Cancelled Bookings",
value: stats?.cancelled_bookings ?? 0,
icon: CalendarX,
trend: "0%",
trend: stats?.trends.cancelled_bookings ?? "0%",
trendUp: false,
},
{
title: "Total Revenue",
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
icon: DollarSign,
trend: "+18%",
trend: stats?.trends.total_revenue ?? "0%",
trendUp: true,
},
{
title: "Monthly Revenue",
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
icon: TrendingUp,
trend: "+32%",
trend: stats?.trends.monthly_revenue ?? "0%",
trendUp: true,
},
];

View File

@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { useCallback, useEffect } from "react";
import {
loginUser,
registerUser,
@ -17,6 +17,8 @@ import {
storeTokens,
storeUser,
clearAuthData,
isTokenExpired,
hasValidAuth,
} from "@/lib/actions/auth";
import type {
LoginInput,
@ -28,6 +30,7 @@ import type {
ResetPasswordInput,
} from "@/lib/schema/auth";
import type { User } from "@/lib/models/auth";
import { toast } from "sonner";
export function useAuth() {
const router = useRouter();
@ -40,8 +43,8 @@ export function useAuth() {
staleTime: Infinity,
});
// Check if user is authenticated
const isAuthenticated = !!user && !!getStoredTokens().access;
// Check if user is authenticated with valid token
const isAuthenticated = !!user && hasValidAuth();
// Check if user is admin (check multiple possible field names)
const isAdmin =
@ -108,6 +111,12 @@ export function useAuth() {
mutationFn: (refresh: string) => refreshToken({ refresh }),
onSuccess: (tokens) => {
storeTokens(tokens);
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
onError: () => {
// If refresh fails, logout
clearAuthData();
queryClient.clear();
},
});
@ -118,6 +127,42 @@ export function useAuth() {
// Don't redirect here - let components handle redirect as needed
}, [queryClient]);
// Auto-logout if token is expired or missing
useEffect(() => {
const checkAuth = () => {
const tokens = getStoredTokens();
const storedUser = getStoredUser();
// If user exists but no token or token is expired, logout
if (storedUser && (!tokens.access || isTokenExpired(tokens.access))) {
// Try to refresh token first if refresh token exists
if (tokens.refresh && !isTokenExpired(tokens.refresh)) {
refreshTokenMutation.mutate(tokens.refresh, {
onError: () => {
// If refresh fails, logout
clearAuthData();
queryClient.clear();
toast.error("Your session has expired. Please log in again.");
},
});
} else {
// No valid refresh token, logout immediately
clearAuthData();
queryClient.clear();
toast.error("Your session has expired. Please log in again.");
}
}
};
// Check immediately
checkAuth();
// Check every 30 seconds
const interval = setInterval(checkAuth, 30000);
return () => clearInterval(interval);
}, [queryClient, refreshTokenMutation]);
// Login function
const login = useCallback(
async (input: LoginInput) => {

View File

@ -121,14 +121,26 @@ export async function listAppointments(email?: string): Promise<Appointment[]> {
},
});
const data: AppointmentsListResponse = await response.json();
const data = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as ApiError);
throw new Error(errorMessage);
}
return data.appointments || [];
// Handle different response formats
// API might return array directly or wrapped in an object
if (Array.isArray(data)) {
return data;
}
if (data.appointments && Array.isArray(data.appointments)) {
return data.appointments;
}
if (data.results && Array.isArray(data.results)) {
return data.results;
}
return [];
}
// Get user appointments
@ -147,14 +159,26 @@ export async function getUserAppointments(): Promise<Appointment[]> {
},
});
const data: AppointmentsListResponse = await response.json();
const data = await response.json();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as ApiError);
throw new Error(errorMessage);
}
return data.appointments || [];
// Handle different response formats
// API might return array directly or wrapped in an object
if (Array.isArray(data)) {
return data;
}
if (data.appointments && Array.isArray(data.appointments)) {
return data.appointments;
}
if (data.results && Array.isArray(data.results)) {
return data.results;
}
return [];
}
// Get appointment detail

View File

@ -229,6 +229,35 @@ export async function refreshToken(input: TokenRefreshInput): Promise<AuthTokens
return handleResponse<AuthTokens>(response);
}
// Decode JWT token to check expiration
function decodeJWT(token: string): { exp?: number; [key: string]: any } | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = parts[1];
const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
return decoded;
} catch (error) {
return null;
}
}
// Check if token is expired
export function isTokenExpired(token: string | null): boolean {
if (!token) return true;
const decoded = decodeJWT(token);
if (!decoded || !decoded.exp) return true;
// exp is in seconds, Date.now() is in milliseconds
const expirationTime = decoded.exp * 1000;
const currentTime = Date.now();
// Consider token expired if it expires within the next 5 seconds (buffer)
return currentTime >= (expirationTime - 5000);
}
// Get stored tokens
export function getStoredTokens(): { access: string | null; refresh: string | null } {
if (typeof window === "undefined") {
@ -241,6 +270,14 @@ export function getStoredTokens(): { access: string | null; refresh: string | nu
};
}
// Check if user has valid authentication
export function hasValidAuth(): boolean {
const tokens = getStoredTokens();
if (!tokens.access) return false;
return !isTokenExpired(tokens.access);
}
// Store tokens
export function storeTokens(tokens: AuthTokens): void {
if (typeof window === "undefined") return;
@ -292,9 +329,43 @@ export function clearAuthData(): void {
// Get auth header for API requests
export function getAuthHeader(): { Authorization: string } | {} {
const tokens = getStoredTokens();
if (tokens.access) {
if (tokens.access && !isTokenExpired(tokens.access)) {
return { Authorization: `Bearer ${tokens.access}` };
}
return {};
}
// Get all users (Admin only)
export async function getAllUsers(): Promise<User[]> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.auth.allUsers, {
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.users) {
return data.users;
}
if (Array.isArray(data)) {
return data;
}
return [];
}

View File

@ -20,6 +20,7 @@ export const API_ENDPOINTS = {
verifyPasswordResetOtp: `${API_BASE_URL}/auth/verify-password-reset-otp/`,
resetPassword: `${API_BASE_URL}/auth/reset-password/`,
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
allUsers: `${API_BASE_URL}/auth/all-users/`,
},
meetings: {
base: `${API_BASE_URL}/meetings/`,

View File

@ -52,6 +52,7 @@ export interface AppointmentStats {
scheduled: number;
rejected: number;
completion_rate: number;
users?: number; // Total users count from API
}
export interface JitsiMeetingInfo {