Backup: 2025-06-07 19:48 - Script test
[Restore from backup: vip-coordinator-backup-2025-06-07-19-48-script-test]
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Multi-stage build for development and production
|
||||
FROM node:22-alpine AS base
|
||||
FROM node:22-slim AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -13,9 +13,61 @@ COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
# Production stage
|
||||
FROM base AS production
|
||||
RUN npm install
|
||||
# Build stage
|
||||
FROM base AS build
|
||||
|
||||
# Accept build argument for API URL
|
||||
ARG VITE_API_URL
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
|
||||
# Install build dependencies for native modules (Debian-based)
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
RUN npm install typescript @vitejs/plugin-react vite
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
# Build the application with environment variable available
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Copy built application from build stage
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S appuser && \
|
||||
adduser -S appuser -u 1001 -G appuser
|
||||
|
||||
# Set proper permissions and create necessary directories
|
||||
RUN chown -R appuser:appuser /usr/share/nginx/html && \
|
||||
chown -R appuser:appuser /var/cache/nginx && \
|
||||
chown -R appuser:appuser /var/log/nginx && \
|
||||
chown -R appuser:appuser /etc/nginx/conf.d && \
|
||||
mkdir -p /tmp/nginx && \
|
||||
chown -R appuser:appuser /tmp/nginx && \
|
||||
touch /tmp/nginx/nginx.pid && \
|
||||
chown appuser:appuser /tmp/nginx/nginx.pid
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Custom PID file location for non-root user
|
||||
pid /tmp/nginx/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
5549
frontend/package-lock.json
generated
5549
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import { apiCall } from './config/api';
|
||||
import VipList from './pages/VipList';
|
||||
|
||||
109
frontend/src/api/client.ts
Normal file
109
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// Simplified API client that handles all the complexity in one place
|
||||
// Use empty string for relative URLs when no API URL is specified
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
class ApiClient {
|
||||
private baseURL: string;
|
||||
|
||||
constructor(baseURL: string) {
|
||||
this.baseURL = baseURL;
|
||||
}
|
||||
|
||||
private getAuthHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('authToken');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { Authorization: `Bearer ${token}` })
|
||||
};
|
||||
}
|
||||
|
||||
private async handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error?.message || error.error || `Request failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Generic request method
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `${this.baseURL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...this.getAuthHeaders(),
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
return this.handleResponse<T>(response);
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
async get<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint);
|
||||
}
|
||||
|
||||
async post<T>(endpoint: string, data?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async put<T>(endpoint: string, data?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T>(endpoint: string): Promise<T> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async patch<T>(endpoint: string, data?: any): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PATCH',
|
||||
body: data ? JSON.stringify(data) : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
|
||||
// Export specific API methods for better type safety and convenience
|
||||
export const vipApi = {
|
||||
list: () => api.get<any[]>('/api/vips'),
|
||||
get: (id: string) => api.get<any>(`/api/vips/${id}`),
|
||||
create: (data: any) => api.post<any>('/api/vips', data),
|
||||
update: (id: string, data: any) => api.put<any>(`/api/vips/${id}`, data),
|
||||
delete: (id: string) => api.delete<any>(`/api/vips/${id}`),
|
||||
getSchedule: (id: string) => api.get<any[]>(`/api/vips/${id}/schedule`)
|
||||
};
|
||||
|
||||
export const driverApi = {
|
||||
list: () => api.get<any[]>('/api/drivers'),
|
||||
get: (id: string) => api.get<any>(`/api/drivers/${id}`),
|
||||
create: (data: any) => api.post<any>('/api/drivers', data),
|
||||
update: (id: string, data: any) => api.put<any>(`/api/drivers/${id}`, data),
|
||||
delete: (id: string) => api.delete<any>(`/api/drivers/${id}`),
|
||||
getSchedule: (id: string) => api.get<any[]>(`/api/drivers/${id}/schedule`)
|
||||
};
|
||||
|
||||
export const scheduleApi = {
|
||||
create: (vipId: string, data: any) => api.post<any>(`/api/vips/${vipId}/schedule`, data),
|
||||
update: (vipId: string, eventId: string, data: any) =>
|
||||
api.put<any>(`/api/vips/${vipId}/schedule/${eventId}`, data),
|
||||
delete: (vipId: string, eventId: string) =>
|
||||
api.delete<any>(`/api/vips/${vipId}/schedule/${eventId}`),
|
||||
updateStatus: (vipId: string, eventId: string, status: string) =>
|
||||
api.patch<any>(`/api/vips/${vipId}/schedule/${eventId}/status`, { status })
|
||||
};
|
||||
|
||||
export const authApi = {
|
||||
me: () => api.get<any>('/auth/me'),
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
setup: () => api.get<any>('/auth/setup'),
|
||||
googleCallback: (code: string) => api.post<any>('/auth/google/callback', { code })
|
||||
};
|
||||
72
frontend/src/components/AsyncErrorBoundary.tsx
Normal file
72
frontend/src/components/AsyncErrorBoundary.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { Component, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class AsyncErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
console.error('AsyncErrorBoundary caught an error:', error);
|
||||
this.props.onError?.(error);
|
||||
}
|
||||
|
||||
retry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-400 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
Failed to load data
|
||||
</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-red-700">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={this.retry}
|
||||
className="mt-3 text-sm text-red-600 hover:text-red-500 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Driver {
|
||||
id: string;
|
||||
@@ -35,7 +35,7 @@ const EditDriverForm: React.FC<EditDriverFormProps> = ({ driver, onSubmit, onCan
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'lat' || name === 'lng') {
|
||||
setFormData(prev => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Flight {
|
||||
flightNumber: string;
|
||||
@@ -191,15 +191,6 @@ const EditVipForm: React.FC<EditVipFormProps> = ({ vip, onSubmit, onCancel }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const formatFlightTime = (timeString: string) => {
|
||||
if (!timeString) return '';
|
||||
return new Date(timeString).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
|
||||
114
frontend/src/components/ErrorBoundary.tsx
Normal file
114
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
this.setState({
|
||||
errorInfo
|
||||
});
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return <>{this.props.fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md max-w-2xl w-full">
|
||||
<div className="flex items-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-red-500 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Something went wrong</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
We're sorry, but something unexpected happened. Please try refreshing the page or contact support if the problem persists.
|
||||
</p>
|
||||
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mb-6">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
Error details (development mode only)
|
||||
</summary>
|
||||
<div className="mt-2 p-4 bg-gray-100 rounded text-xs">
|
||||
<p className="font-mono text-red-600 mb-2">{this.state.error.toString()}</p>
|
||||
{this.state.errorInfo && (
|
||||
<pre className="text-gray-700 overflow-auto">
|
||||
{this.state.errorInfo.componentStack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
53
frontend/src/components/ErrorMessage.tsx
Normal file
53
frontend/src/components/ErrorMessage.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorMessageProps {
|
||||
message: string;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ErrorMessage: React.FC<ErrorMessageProps> = ({
|
||||
message,
|
||||
onDismiss,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`bg-red-50 border border-red-200 rounded-lg p-4 ${className}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg
|
||||
className="h-5 w-5 text-red-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<p className="text-sm text-red-700">{message}</p>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<div className="ml-auto pl-3">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="inline-flex rounded-md bg-red-50 p-1.5 text-red-500 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-red-600 focus:ring-offset-2 focus:ring-offset-red-50"
|
||||
>
|
||||
<span className="sr-only">Dismiss</span>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
|
||||
interface GanttEvent {
|
||||
id: string;
|
||||
@@ -163,7 +162,7 @@ const GanttChart: React.FC<GanttChartProps> = ({ events, driverName }) => {
|
||||
|
||||
{/* Events */}
|
||||
<div style={{ padding: '1rem 0' }}>
|
||||
{events.map((event, index) => {
|
||||
{events.map((event) => {
|
||||
const position = calculateEventPosition(event, timeRange);
|
||||
return (
|
||||
<div
|
||||
|
||||
104
frontend/src/components/GoogleLogin.tsx
Normal file
104
frontend/src/components/GoogleLogin.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface GoogleLoginProps {
|
||||
onSuccess: (user: any, token: string) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
// Helper to decode JWT token
|
||||
function parseJwt(token: string) {
|
||||
try {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
return JSON.parse(jsonPayload);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google: any;
|
||||
}
|
||||
}
|
||||
|
||||
const GoogleLogin: React.FC<GoogleLoginProps> = ({ onSuccess, onError }) => {
|
||||
const buttonRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize Google Sign-In
|
||||
const initializeGoogleSignIn = () => {
|
||||
if (!window.google) {
|
||||
setTimeout(initializeGoogleSignIn, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: '308004695553-6k34bbq22frc4e76kejnkgq8mncepbbg.apps.googleusercontent.com',
|
||||
callback: handleCredentialResponse,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
});
|
||||
|
||||
// Render the button
|
||||
if (buttonRef.current) {
|
||||
window.google.accounts.id.renderButton(
|
||||
buttonRef.current,
|
||||
{
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'signin_with',
|
||||
shape: 'rectangular',
|
||||
logo_alignment: 'center',
|
||||
width: 300,
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCredentialResponse = async (response: any) => {
|
||||
try {
|
||||
// Send the Google credential to our backend
|
||||
const res = await fetch('/auth/google/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ credential: response.credential }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.error === 'pending_approval') {
|
||||
// User created but needs approval - still successful login
|
||||
onSuccess(data.user, data.token);
|
||||
} else {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
} else {
|
||||
onSuccess(data.user, data.token);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during authentication:', error);
|
||||
onError(error instanceof Error ? error.message : 'Failed to process authentication');
|
||||
}
|
||||
};
|
||||
|
||||
initializeGoogleSignIn();
|
||||
}, [onSuccess, onError]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div ref={buttonRef} className="google-signin-button"></div>
|
||||
<p className="mt-4 text-sm text-gray-600">
|
||||
Sign in with your Google account to continue
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleLogin;
|
||||
45
frontend/src/components/GoogleOAuthButton.tsx
Normal file
45
frontend/src/components/GoogleOAuthButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { apiCall } from '../utils/api';
|
||||
|
||||
interface GoogleOAuthButtonProps {
|
||||
onSuccess: (user: any, token: string) => void;
|
||||
onError: (error: string) => void;
|
||||
}
|
||||
|
||||
const GoogleOAuthButton: React.FC<GoogleOAuthButtonProps> = ({ onSuccess, onError }) => {
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
// Get the OAuth URL from backend
|
||||
const { data } = await apiCall('/auth/google/url');
|
||||
|
||||
if (data && data.url) {
|
||||
// Redirect to Google OAuth (no popup to avoid CORS issues)
|
||||
window.location.href = data.url;
|
||||
} else {
|
||||
onError('Failed to get authentication URL');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initiating Google login:', error);
|
||||
onError('Failed to start authentication');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleGoogleLogin}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-gray-300 rounded-lg shadow-sm bg-white hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
||||
<path fill="#4285F4" d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"/>
|
||||
<path fill="#34A853" d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"/>
|
||||
<path fill="#FBBC05" d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"/>
|
||||
<path fill="#EA4335" d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">Sign in with Google</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default GoogleOAuthButton;
|
||||
45
frontend/src/components/LoadingSpinner.tsx
Normal file
45
frontend/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'md',
|
||||
message
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-12 w-12'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-4">
|
||||
<svg
|
||||
className={`animate-spin ${sizeClasses[size]} text-blue-500`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{message && (
|
||||
<p className="mt-2 text-sm text-gray-600">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -65,7 +65,12 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get user info: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(user => {
|
||||
onLogin(user);
|
||||
// Clean up URL and redirect to dashboard
|
||||
@@ -73,6 +78,7 @@ const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error getting user info:', error);
|
||||
alert('Login failed. Please try again.');
|
||||
localStorage.removeItem('authToken');
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
|
||||
109
frontend/src/components/OAuthCallback.tsx
Normal file
109
frontend/src/components/OAuthCallback.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { apiCall } from '../utils/api';
|
||||
|
||||
const OAuthCallback: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [processing, setProcessing] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
// Check for errors from OAuth provider
|
||||
const errorParam = searchParams.get('error');
|
||||
if (errorParam) {
|
||||
setError(`Authentication failed: ${errorParam}`);
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the authorization code
|
||||
const code = searchParams.get('code');
|
||||
if (!code) {
|
||||
setError('No authorization code received');
|
||||
setProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange the code for a token
|
||||
const response = await apiCall('/auth/google/exchange', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
|
||||
if (response.error === 'pending_approval') {
|
||||
// User needs approval
|
||||
localStorage.setItem('authToken', response.data.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
navigate('/pending-approval');
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data && response.data.token) {
|
||||
// Success! Store the token and user data
|
||||
localStorage.setItem('authToken', response.data.token);
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user));
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
setError('Failed to authenticate');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('OAuth callback error:', err);
|
||||
if (err.message?.includes('pending_approval')) {
|
||||
// This means the user was created but needs approval
|
||||
navigate('/');
|
||||
} else {
|
||||
setError(err.message || 'Authentication failed');
|
||||
}
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate, searchParams]);
|
||||
|
||||
if (processing) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent mx-auto mb-4"></div>
|
||||
<p className="text-lg font-medium text-slate-700">Completing sign in...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 max-w-md text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-800 mb-2">Authentication Failed</h2>
|
||||
<p className="text-slate-600 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default OAuthCallback;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiCall } from '../config/api';
|
||||
import DriverSelector from './DriverSelector';
|
||||
|
||||
@@ -381,7 +381,7 @@ interface ScheduleEventFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ScheduleEventForm: React.FC<ScheduleEventFormProps> = ({ vipId, event, onSubmit, onCancel }) => {
|
||||
const ScheduleEventForm: React.FC<ScheduleEventFormProps> = ({ event, onSubmit, onCancel }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
title: event?.title || '',
|
||||
location: event?.location || '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_BASE_URL } from '../config/api';
|
||||
|
||||
interface User {
|
||||
@@ -131,7 +131,7 @@ const UserManagement: React.FC<UserManagementProps> = ({ currentUser }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const approveUser = async (userEmail: string, userName: string) => {
|
||||
const approveUser = async (userEmail: string) => {
|
||||
setUpdatingUser(userEmail);
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
@@ -424,7 +424,7 @@ const UserManagement: React.FC<UserManagementProps> = ({ currentUser }) => {
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={() => approveUser(user.email, user.name)}
|
||||
onClick={() => approveUser(user.email)}
|
||||
disabled={updatingUser === user.email}
|
||||
className={`px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md transition-colors ${
|
||||
updatingUser === user.email ? 'opacity-50 cursor-not-allowed' : ''
|
||||
|
||||
257
frontend/src/components/UserOnboarding.tsx
Normal file
257
frontend/src/components/UserOnboarding.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiCall } from '../utils/api';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { LoadingSpinner } from './LoadingSpinner';
|
||||
|
||||
interface OnboardingData {
|
||||
requestedRole: 'coordinator' | 'driver' | 'viewer';
|
||||
phone: string;
|
||||
organization: string;
|
||||
reason: string;
|
||||
// Driver-specific fields
|
||||
vehicleType?: string;
|
||||
vehicleCapacity?: number;
|
||||
licensePlate?: string;
|
||||
}
|
||||
|
||||
const UserOnboarding: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<OnboardingData>({
|
||||
requestedRole: 'viewer',
|
||||
phone: '',
|
||||
organization: '',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await apiCall('/api/users/complete-onboarding', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
onboardingData: formData,
|
||||
phone: formData.phone,
|
||||
organization: formData.organization,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast('Onboarding completed! Your account is pending approval.', 'success');
|
||||
navigate('/pending-approval');
|
||||
} else {
|
||||
showToast('Failed to complete onboarding. Please try again.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('An error occurred. Please try again.', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = (role: 'coordinator' | 'driver' | 'viewer') => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
requestedRole: role,
|
||||
// Clear driver fields if not driver
|
||||
vehicleType: role === 'driver' ? prev.vehicleType : undefined,
|
||||
vehicleCapacity: role === 'driver' ? prev.vehicleCapacity : undefined,
|
||||
licensePlate: role === 'driver' ? prev.licensePlate : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl max-w-2xl w-full p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-slate-800 mb-2">Welcome to VIP Coordinator</h1>
|
||||
<p className="text-slate-600">Please complete your profile to request access</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Role Selection */}
|
||||
<div className="form-section">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
What type of access do you need?
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleChange('coordinator')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
formData.requestedRole === 'coordinator'
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">📋</div>
|
||||
<div className="font-semibold text-slate-800">Coordinator</div>
|
||||
<div className="text-xs text-slate-600 mt-1">Manage VIPs & schedules</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleChange('driver')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
formData.requestedRole === 'driver'
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">🚗</div>
|
||||
<div className="font-semibold text-slate-800">Driver</div>
|
||||
<div className="text-xs text-slate-600 mt-1">Transport VIPs</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRoleChange('viewer')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
formData.requestedRole === 'viewer'
|
||||
? 'border-amber-500 bg-amber-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">👁️</div>
|
||||
<div className="font-semibold text-slate-800">Viewer</div>
|
||||
<div className="text-xs text-slate-600 mt-1">View-only access</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Common Fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Phone Number *
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
|
||||
className="form-input w-full"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Organization *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.organization}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, organization: e.target.value }))}
|
||||
className="form-input w-full"
|
||||
placeholder="Your company or department"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Driver-specific Fields */}
|
||||
{formData.requestedRole === 'driver' && (
|
||||
<div className="space-y-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h3 className="font-semibold text-slate-800 mb-3">Driver Information</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Vehicle Type *
|
||||
</label>
|
||||
<select
|
||||
required
|
||||
value={formData.vehicleType || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, vehicleType: e.target.value }))}
|
||||
className="form-select w-full"
|
||||
>
|
||||
<option value="">Select vehicle type</option>
|
||||
<option value="sedan">Sedan</option>
|
||||
<option value="suv">SUV</option>
|
||||
<option value="van">Van</option>
|
||||
<option value="minibus">Minibus</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Passenger Capacity *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.vehicleCapacity || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, vehicleCapacity: parseInt(e.target.value) }))}
|
||||
className="form-input w-full"
|
||||
placeholder="4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
License Plate *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.licensePlate || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, licensePlate: e.target.value }))}
|
||||
className="form-input w-full"
|
||||
placeholder="ABC-1234"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason for Access */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Why do you need access? *
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
value={formData.reason}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, reason: e.target.value }))}
|
||||
className="form-textarea w-full"
|
||||
placeholder="Please explain your role and why you need access to the VIP Coordinator system..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/')}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? <LoadingSpinner size="sm" /> : 'Submit Request'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserOnboarding;
|
||||
168
frontend/src/components/__tests__/GoogleLogin.test.tsx
Normal file
168
frontend/src/components/__tests__/GoogleLogin.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '../../tests/test-utils';
|
||||
import GoogleLogin from '../GoogleLogin';
|
||||
|
||||
describe('GoogleLogin', () => {
|
||||
const mockOnSuccess = vi.fn();
|
||||
const mockOnError = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders login button', () => {
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Check if Google button container is rendered
|
||||
const buttonContainer = screen.getByTestId('google-signin-button');
|
||||
expect(buttonContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('initializes Google Identity Services on mount', () => {
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
expect(google.accounts.id.initialize).toHaveBeenCalledWith({
|
||||
client_id: expect.any(String),
|
||||
callback: expect.any(Function),
|
||||
auto_select: true,
|
||||
cancel_on_tap_outside: false,
|
||||
});
|
||||
|
||||
expect(google.accounts.id.renderButton).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles successful login', async () => {
|
||||
// Get the callback function passed to initialize
|
||||
let googleCallback: any;
|
||||
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
|
||||
googleCallback = config.callback;
|
||||
});
|
||||
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Mock successful server response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
token: 'test-jwt-token',
|
||||
user: {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'coordinator',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Simulate Google credential response
|
||||
const mockCredential = { credential: 'mock-google-credential' };
|
||||
await googleCallback(mockCredential);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/auth/google/verify'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ credential: 'mock-google-credential' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSuccess).toHaveBeenCalledWith({
|
||||
token: 'test-jwt-token',
|
||||
user: {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'coordinator',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles login error', async () => {
|
||||
let googleCallback: any;
|
||||
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
|
||||
googleCallback = config.callback;
|
||||
});
|
||||
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Mock error response
|
||||
(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ error: 'Invalid credential' }),
|
||||
});
|
||||
|
||||
const mockCredential = { credential: 'invalid-credential' };
|
||||
await googleCallback(mockCredential);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnError).toHaveBeenCalledWith('Authentication failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles network error', async () => {
|
||||
let googleCallback: any;
|
||||
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
|
||||
googleCallback = config.callback;
|
||||
});
|
||||
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Mock network error
|
||||
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const mockCredential = { credential: 'mock-credential' };
|
||||
await googleCallback(mockCredential);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnError).toHaveBeenCalledWith('Network error. Please try again.');
|
||||
});
|
||||
});
|
||||
|
||||
it('displays loading state during authentication', async () => {
|
||||
let googleCallback: any;
|
||||
(google.accounts.id.initialize as any).mockImplementation((config: any) => {
|
||||
googleCallback = config.callback;
|
||||
});
|
||||
|
||||
render(
|
||||
<GoogleLogin onSuccess={mockOnSuccess} onError={mockOnError} />
|
||||
);
|
||||
|
||||
// Mock a delayed response
|
||||
(global.fetch as any).mockImplementation(() =>
|
||||
new Promise(resolve => setTimeout(() => resolve({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'test-token', user: {} }),
|
||||
}), 100))
|
||||
);
|
||||
|
||||
const mockCredential = { credential: 'mock-credential' };
|
||||
googleCallback(mockCredential);
|
||||
|
||||
// Check for loading state
|
||||
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||
|
||||
// Wait for authentication to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Authenticating...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
196
frontend/src/components/__tests__/VipForm.test.tsx
Normal file
196
frontend/src/components/__tests__/VipForm.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '../../tests/test-utils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import VipForm from '../VipForm';
|
||||
|
||||
describe('VipForm', () => {
|
||||
const mockOnSubmit = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all form fields', () => {
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/full name/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/title/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/organization/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/contact information/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/arrival date/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/departure date/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/transportation mode/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/hotel/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/room number/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/additional notes/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows flight-specific fields when flight mode is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const transportSelect = screen.getByLabelText(/transportation mode/i);
|
||||
await user.selectOptions(transportSelect, 'flight');
|
||||
|
||||
expect(screen.getByLabelText(/airport/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/flight number/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides flight fields when self-driving mode is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// First select flight to show fields
|
||||
const transportSelect = screen.getByLabelText(/transportation mode/i);
|
||||
await user.selectOptions(transportSelect, 'flight');
|
||||
|
||||
expect(screen.getByLabelText(/airport/i)).toBeInTheDocument();
|
||||
|
||||
// Then switch to self-driving
|
||||
await user.selectOptions(transportSelect, 'self_driving');
|
||||
|
||||
expect(screen.queryByLabelText(/airport/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/flight number/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits form with valid data', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// Fill out the form
|
||||
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
|
||||
await user.type(screen.getByLabelText(/title/i), 'CEO');
|
||||
await user.type(screen.getByLabelText(/organization/i), 'Test Corp');
|
||||
await user.type(screen.getByLabelText(/contact information/i), '+1234567890');
|
||||
await user.type(screen.getByLabelText(/arrival date/i), '2025-01-15T10:00');
|
||||
await user.type(screen.getByLabelText(/departure date/i), '2025-01-16T14:00');
|
||||
await user.selectOptions(screen.getByLabelText(/transportation mode/i), 'flight');
|
||||
await user.type(screen.getByLabelText(/airport/i), 'LAX');
|
||||
await user.type(screen.getByLabelText(/flight number/i), 'AA123');
|
||||
await user.type(screen.getByLabelText(/hotel/i), 'Hilton');
|
||||
await user.type(screen.getByLabelText(/room number/i), '1234');
|
||||
await user.type(screen.getByLabelText(/additional notes/i), 'VIP guest');
|
||||
|
||||
// Submit the form
|
||||
const submitButton = screen.getByRole('button', { name: /add vip/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnSubmit).toHaveBeenCalledWith({
|
||||
name: 'John Doe',
|
||||
title: 'CEO',
|
||||
organization: 'Test Corp',
|
||||
contact_info: '+1234567890',
|
||||
arrival_datetime: '2025-01-15T10:00',
|
||||
departure_datetime: '2025-01-16T14:00',
|
||||
transportation_mode: 'flight',
|
||||
airport: 'LAX',
|
||||
flight_number: 'AA123',
|
||||
hotel: 'Hilton',
|
||||
room_number: '1234',
|
||||
notes: 'VIP guest',
|
||||
status: 'scheduled',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// Try to submit empty form
|
||||
const submitButton = screen.getByRole('button', { name: /add vip/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Check that onSubmit was not called
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
|
||||
// Check for HTML5 validation (browser will show validation messages)
|
||||
const nameInput = screen.getByLabelText(/full name/i) as HTMLInputElement;
|
||||
expect(nameInput.validity.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pre-fills form when editing existing VIP', () => {
|
||||
const existingVip = {
|
||||
id: '123',
|
||||
name: 'Jane Smith',
|
||||
title: 'VP Sales',
|
||||
organization: 'Another Corp',
|
||||
contact_info: '+0987654321',
|
||||
arrival_datetime: '2025-01-15T14:00',
|
||||
departure_datetime: '2025-01-16T10:00',
|
||||
transportation_mode: 'self_driving' as const,
|
||||
hotel: 'Marriott',
|
||||
room_number: '567',
|
||||
status: 'scheduled' as const,
|
||||
notes: 'Arrives by car',
|
||||
};
|
||||
|
||||
render(
|
||||
<VipForm
|
||||
vip={existingVip}
|
||||
onSubmit={mockOnSubmit}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('Jane Smith')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('VP Sales')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Another Corp')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('+0987654321')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2025-01-15T14:00')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2025-01-16T10:00')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('self_driving')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Marriott')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('567')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Arrives by car')).toBeInTheDocument();
|
||||
|
||||
// Should show "Update VIP" instead of "Add VIP"
|
||||
expect(screen.getByRole('button', { name: /update vip/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates departure date is after arrival date', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<VipForm onSubmit={mockOnSubmit} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
// Set departure before arrival
|
||||
await user.type(screen.getByLabelText(/arrival date/i), '2025-01-16T14:00');
|
||||
await user.type(screen.getByLabelText(/departure date/i), '2025-01-15T10:00');
|
||||
|
||||
// Fill other required fields
|
||||
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
|
||||
await user.type(screen.getByLabelText(/title/i), 'CEO');
|
||||
await user.type(screen.getByLabelText(/organization/i), 'Test Corp');
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /add vip/i });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Form should not submit
|
||||
expect(mockOnSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
// API Configuration
|
||||
// Use environment variable with fallback to localhost for development
|
||||
export const API_BASE_URL = (import.meta as any).env.VITE_API_URL || 'http://localhost:3000';
|
||||
// VITE_API_URL must be set at build time - no fallback to prevent production issues
|
||||
export const API_BASE_URL = (import.meta as any).env.VITE_API_URL;
|
||||
|
||||
if (!API_BASE_URL) {
|
||||
throw new Error('VITE_API_URL environment variable is required');
|
||||
}
|
||||
|
||||
// Helper function for API calls
|
||||
export const apiCall = (endpoint: string, options?: RequestInit) => {
|
||||
|
||||
95
frontend/src/contexts/ToastContext.tsx
Normal file
95
frontend/src/contexts/ToastContext.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'info' | 'warning';
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (message: string, type: Toast['type'], duration?: number) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback((message: string, type: Toast['type'], duration = 5000) => {
|
||||
const id = Date.now().toString();
|
||||
const toast: Toast = { id, message, type, duration };
|
||||
|
||||
setToasts(prev => [...prev, toast]);
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
}, [removeToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
max-w-sm p-4 rounded-lg shadow-lg transform transition-all duration-300 ease-in-out
|
||||
${toast.type === 'success' ? 'bg-green-500 text-white' : ''}
|
||||
${toast.type === 'error' ? 'bg-red-500 text-white' : ''}
|
||||
${toast.type === 'info' ? 'bg-blue-500 text-white' : ''}
|
||||
${toast.type === 'warning' ? 'bg-amber-500 text-white' : ''}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{toast.type === 'success' && (
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{toast.type === 'error' && (
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{toast.type === 'info' && (
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
{toast.type === 'warning' && (
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-sm font-medium">{toast.message}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="ml-4 text-white hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
56
frontend/src/hooks/useApi.ts
Normal file
56
frontend/src/hooks/useApi.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// Simple hook for API calls that handles loading, error, and data states
|
||||
export function useApi<T>(
|
||||
apiCall: () => Promise<T>,
|
||||
dependencies: any[] = []
|
||||
) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await apiCall();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, dependencies);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
|
||||
// Hook for mutations (POST, PUT, DELETE)
|
||||
export function useMutation<TData = any, TVariables = any>(
|
||||
mutationFn: (variables: TVariables) => Promise<TData>
|
||||
) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const mutate = useCallback(async (variables: TVariables) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await mutationFn(variables);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'An error occurred';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mutationFn]);
|
||||
|
||||
return { mutate, loading, error };
|
||||
}
|
||||
74
frontend/src/hooks/useError.ts
Normal file
74
frontend/src/hooks/useError.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
export const useError = () => {
|
||||
const [error, setError] = useState<ApiError | null>(null);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
setIsError(false);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((error: unknown) => {
|
||||
console.error('API Error:', error);
|
||||
|
||||
let apiError: ApiError;
|
||||
|
||||
if (error instanceof Error) {
|
||||
// Check if it's our custom ApiError
|
||||
if ('status' in error && 'code' in error) {
|
||||
apiError = {
|
||||
message: error.message,
|
||||
code: (error as any).code,
|
||||
details: (error as any).details
|
||||
};
|
||||
} else {
|
||||
// Regular Error
|
||||
apiError = {
|
||||
message: error.message,
|
||||
code: 'ERROR'
|
||||
};
|
||||
}
|
||||
} else if (typeof error === 'object' && error !== null) {
|
||||
// Check for axios-like error structure
|
||||
const err = error as any;
|
||||
if (err.response?.data?.error) {
|
||||
apiError = {
|
||||
message: err.response.data.error.message || err.response.data.error,
|
||||
code: err.response.data.error.code,
|
||||
details: err.response.data.error.details
|
||||
};
|
||||
} else {
|
||||
apiError = {
|
||||
message: 'An unexpected error occurred',
|
||||
code: 'UNKNOWN_ERROR',
|
||||
details: error
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Unknown error type
|
||||
apiError = {
|
||||
message: 'An unexpected error occurred',
|
||||
code: 'UNKNOWN_ERROR'
|
||||
};
|
||||
}
|
||||
|
||||
setError(apiError);
|
||||
setIsError(true);
|
||||
|
||||
return apiError;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
error,
|
||||
isError,
|
||||
clearError,
|
||||
handleError
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiCall } from '../config/api';
|
||||
import { generateTestVips, getTestOrganizations, generateVipSchedule } from '../utils/testVipData';
|
||||
@@ -244,7 +244,7 @@ const AdminDashboard: React.FC = () => {
|
||||
const vipData = testVips[i];
|
||||
|
||||
try {
|
||||
const scheduleEvents = generateVipSchedule(vipData.name, vipData.department, vipData.transportMode);
|
||||
const scheduleEvents = generateVipSchedule(vipData.department, vipData.transportMode);
|
||||
|
||||
for (const event of scheduleEvents) {
|
||||
try {
|
||||
@@ -548,8 +548,8 @@ const AdminDashboard: React.FC = () => {
|
||||
<li>Create or select a project</li>
|
||||
<li>Enable the Google+ API</li>
|
||||
<li>Go to "Credentials" → "Create Credentials" → "OAuth 2.0 Client IDs"</li>
|
||||
<li>Set authorized redirect URI: http://bsa.madeamess.online:3000/auth/google/callback</li>
|
||||
<li>Set authorized JavaScript origins: http://bsa.madeamess.online:5173</li>
|
||||
<li>Set authorized redirect URI: https://your-domain.com/auth/google/callback</li>
|
||||
<li>Set authorized JavaScript origins: https://your-domain.com</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
800
frontend/src/pages/AdminDashboardOld.tsx
Normal file
800
frontend/src/pages/AdminDashboardOld.tsx
Normal file
@@ -0,0 +1,800 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiCall } from '../utils/api';
|
||||
import { generateTestVips, getTestOrganizations, generateVipSchedule } from '../utils/testVipData';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface ApiKeys {
|
||||
aviationStackKey?: string;
|
||||
googleMapsKey?: string;
|
||||
twilioKey?: string;
|
||||
googleClientId?: string;
|
||||
googleClientSecret?: string;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
defaultPickupLocation?: string;
|
||||
defaultDropoffLocation?: string;
|
||||
timeZone?: string;
|
||||
notificationsEnabled?: boolean;
|
||||
}
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeys>({});
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({});
|
||||
const [testResults, setTestResults] = useState<{ [key: string]: string }>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<string | null>(null);
|
||||
const [showKeys, setShowKeys] = useState<{ [key: string]: boolean }>({});
|
||||
const [savedKeys, setSavedKeys] = useState<{ [key: string]: boolean }>({});
|
||||
const [testDataLoading, setTestDataLoading] = useState(false);
|
||||
const [testDataStatus, setTestDataStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated and has admin role
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
const userData = localStorage.getItem('user');
|
||||
|
||||
if (!authToken || !userData) {
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedUser = JSON.parse(userData);
|
||||
if (parsedUser.role !== 'administrator' && parsedUser.role !== 'coordinator') {
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(parsedUser);
|
||||
loadSettings();
|
||||
}, [navigate]);
|
||||
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await apiCall('/api/admin/settings');
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// Track which keys are already saved (masked keys start with ***)
|
||||
const saved: { [key: string]: boolean } = {};
|
||||
if (data.apiKeys) {
|
||||
Object.entries(data.apiKeys).forEach(([key, value]) => {
|
||||
if (value && (value as string).startsWith('***')) {
|
||||
saved[key] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
setSavedKeys(saved);
|
||||
// Don't load masked keys as actual values - keep them empty
|
||||
const cleanedApiKeys: ApiKeys = {};
|
||||
if (data.apiKeys) {
|
||||
Object.entries(data.apiKeys).forEach(([key, value]) => {
|
||||
// Only set the value if it's not a masked key
|
||||
if (value && !(value as string).startsWith('***')) {
|
||||
cleanedApiKeys[key as keyof ApiKeys] = value as string;
|
||||
}
|
||||
});
|
||||
}
|
||||
setApiKeys(cleanedApiKeys);
|
||||
setSystemSettings(data.systemSettings || {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (key: keyof ApiKeys, value: string) => {
|
||||
setApiKeys(prev => ({ ...prev, [key]: value }));
|
||||
// If user is typing a new key, mark it as not saved anymore
|
||||
if (value && !value.startsWith('***')) {
|
||||
setSavedKeys(prev => ({ ...prev, [key]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingChange = (key: keyof SystemSettings, value: any) => {
|
||||
setSystemSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const testApiConnection = async (apiType: string) => {
|
||||
setTestResults(prev => ({ ...prev, [apiType]: 'Testing...' }));
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/test-api/${apiType}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: apiKeys[apiType as keyof ApiKeys]
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
[apiType]: `Success: ${result.message}`
|
||||
}));
|
||||
} else {
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
[apiType]: `Failed: ${result.error}`
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
[apiType]: 'Connection error'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setLoading(true);
|
||||
setSaveStatus(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKeys,
|
||||
systemSettings
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSaveStatus('Settings saved successfully!');
|
||||
// Mark keys as saved if they have values
|
||||
const newSavedKeys: { [key: string]: boolean } = {};
|
||||
Object.entries(apiKeys).forEach(([key, value]) => {
|
||||
if (value && !value.startsWith('***')) {
|
||||
newSavedKeys[key] = true;
|
||||
}
|
||||
});
|
||||
setSavedKeys(prev => ({ ...prev, ...newSavedKeys }));
|
||||
// Clear the input fields after successful save
|
||||
setApiKeys({});
|
||||
setTimeout(() => setSaveStatus(null), 3000);
|
||||
} else {
|
||||
setSaveStatus('Failed to save settings');
|
||||
}
|
||||
} catch (error) {
|
||||
setSaveStatus('Error saving settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
sessionStorage.removeItem('adminAuthenticated');
|
||||
setIsAuthenticated(false);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
// Test VIP functions
|
||||
const createTestVips = async () => {
|
||||
setTestDataLoading(true);
|
||||
setTestDataStatus('Creating test VIPs and schedules...');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const testVips = generateTestVips();
|
||||
|
||||
let vipSuccessCount = 0;
|
||||
let vipErrorCount = 0;
|
||||
let scheduleSuccessCount = 0;
|
||||
let scheduleErrorCount = 0;
|
||||
const createdVipIds: string[] = [];
|
||||
|
||||
// First, create all VIPs
|
||||
for (const vipData of testVips) {
|
||||
try {
|
||||
const response = await apiCall('/api/vips', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(vipData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const createdVip = await response.json();
|
||||
createdVipIds.push(createdVip.id);
|
||||
vipSuccessCount++;
|
||||
} else {
|
||||
vipErrorCount++;
|
||||
console.error(`Failed to create VIP: ${vipData.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
vipErrorCount++;
|
||||
console.error(`Error creating VIP ${vipData.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setTestDataStatus(`Created ${vipSuccessCount} VIPs, now creating schedules...`);
|
||||
|
||||
// Then, create schedules for each successfully created VIP
|
||||
for (let i = 0; i < createdVipIds.length; i++) {
|
||||
const vipId = createdVipIds[i];
|
||||
const vipData = testVips[i];
|
||||
|
||||
try {
|
||||
const scheduleEvents = generateVipSchedule(vipData.department, vipData.transportMode);
|
||||
|
||||
for (const event of scheduleEvents) {
|
||||
try {
|
||||
const eventWithId = {
|
||||
...event,
|
||||
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||||
};
|
||||
|
||||
const scheduleResponse = await apiCall(`/api/vips/${vipId}/schedule`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(eventWithId),
|
||||
});
|
||||
|
||||
if (scheduleResponse.ok) {
|
||||
scheduleSuccessCount++;
|
||||
} else {
|
||||
scheduleErrorCount++;
|
||||
console.error(`Failed to create schedule event for ${vipData.name}: ${event.title}`);
|
||||
}
|
||||
} catch (error) {
|
||||
scheduleErrorCount++;
|
||||
console.error(`Error creating schedule event for ${vipData.name}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error generating schedule for ${vipData.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setTestDataStatus(`✅ Created ${vipSuccessCount} VIPs with ${scheduleSuccessCount} schedule events! ${vipErrorCount > 0 || scheduleErrorCount > 0 ? `(${vipErrorCount + scheduleErrorCount} failed)` : ''}`);
|
||||
} catch (error) {
|
||||
setTestDataStatus('❌ Failed to create test VIPs and schedules');
|
||||
console.error('Error creating test data:', error);
|
||||
} finally {
|
||||
setTestDataLoading(false);
|
||||
setTimeout(() => setTestDataStatus(null), 8000);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTestVips = async () => {
|
||||
if (!confirm('Are you sure you want to remove all test VIPs? This will delete VIPs from the test organizations.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTestDataLoading(true);
|
||||
setTestDataStatus('Removing test VIPs...');
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
|
||||
// First, get all VIPs
|
||||
const response = await apiCall('/api/vips', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch VIPs');
|
||||
}
|
||||
|
||||
const allVips = await response.json();
|
||||
|
||||
// Filter test VIPs by organization names
|
||||
const testOrganizations = getTestOrganizations();
|
||||
const testVips = allVips.filter((vip: any) => testOrganizations.includes(vip.organization));
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const vip of testVips) {
|
||||
try {
|
||||
const deleteResponse = await apiCall(`/api/vips/${vip.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (deleteResponse.ok) {
|
||||
successCount++;
|
||||
} else {
|
||||
errorCount++;
|
||||
console.error(`Failed to delete VIP: ${vip.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
console.error(`Error deleting VIP ${vip.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
setTestDataStatus(`🗑️ Removed ${successCount} test VIPs successfully! ${errorCount > 0 ? `(${errorCount} failed)` : ''}`);
|
||||
} catch (error) {
|
||||
setTestDataStatus('❌ Failed to remove test VIPs');
|
||||
console.error('Error removing test VIPs:', error);
|
||||
} finally {
|
||||
setTestDataLoading(false);
|
||||
setTimeout(() => setTestDataStatus(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-amber-500 border-t-transparent mx-auto"></div>
|
||||
<p className="mt-4 text-slate-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-2xl shadow-lg p-8 border border-slate-200/60">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-800 to-slate-600 bg-clip-text text-transparent">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className="text-slate-600 mt-2">System configuration and API management</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys Section */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60">
|
||||
<h2 className="text-xl font-bold text-slate-800">API Key Management</h2>
|
||||
<p className="text-slate-600 mt-1">Configure external service integrations</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{/* AviationStack API */}
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">AviationStack API</h3>
|
||||
{savedKeys.aviationStackKey && (
|
||||
<span className="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-end">
|
||||
<div className="lg:col-span-2">
|
||||
<label className="form-label">API Key</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKeys.aviationStackKey ? 'text' : 'password'}
|
||||
placeholder={savedKeys.aviationStackKey ? 'Key saved (enter new key to update)' : 'Enter AviationStack API key'}
|
||||
value={apiKeys.aviationStackKey || ''}
|
||||
onChange={(e) => handleApiKeyChange('aviationStackKey', e.target.value)}
|
||||
className="form-input pr-12"
|
||||
/>
|
||||
{savedKeys.aviationStackKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKeys(prev => ({ ...prev, aviationStackKey: !prev.aviationStackKey }))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.aviationStackKey ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Get your key from: https://aviationstack.com/dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
className="btn btn-secondary w-full"
|
||||
onClick={() => testApiConnection('aviationStackKey')}
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{testResults.aviationStackKey && (
|
||||
<div className={`p-3 rounded-lg text-sm ${
|
||||
testResults.aviationStackKey.includes('Success')
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}>
|
||||
{testResults.aviationStackKey}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google OAuth Credentials */}
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">Google OAuth Credentials</h3>
|
||||
{(savedKeys.googleClientId && savedKeys.googleClientSecret) && (
|
||||
<span className="bg-green-100 text-green-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
Configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label className="form-label">Client ID</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKeys.googleClientId ? 'text' : 'password'}
|
||||
placeholder={savedKeys.googleClientId ? 'Client ID saved' : 'Enter Google OAuth Client ID'}
|
||||
value={apiKeys.googleClientId || ''}
|
||||
onChange={(e) => handleApiKeyChange('googleClientId', e.target.value)}
|
||||
className="form-input pr-12"
|
||||
/>
|
||||
{savedKeys.googleClientId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKeys(prev => ({ ...prev, googleClientId: !prev.googleClientId }))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.googleClientId ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Client Secret</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showKeys.googleClientSecret ? 'text' : 'password'}
|
||||
placeholder={savedKeys.googleClientSecret ? 'Client Secret saved' : 'Enter Google OAuth Client Secret'}
|
||||
value={apiKeys.googleClientSecret || ''}
|
||||
onChange={(e) => handleApiKeyChange('googleClientSecret', e.target.value)}
|
||||
className="form-input pr-12"
|
||||
/>
|
||||
{savedKeys.googleClientSecret && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKeys(prev => ({ ...prev, googleClientSecret: !prev.googleClientSecret }))}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showKeys.googleClientSecret ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-4">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">Setup Instructions</h4>
|
||||
<ol className="text-sm text-blue-800 space-y-1 list-decimal list-inside">
|
||||
<li>Go to Google Cloud Console</li>
|
||||
<li>Create or select a project</li>
|
||||
<li>Enable the Google+ API</li>
|
||||
<li>Go to "Credentials" → "Create Credentials" → "OAuth 2.0 Client IDs"</li>
|
||||
<li>Set authorized redirect URI: https://your-domain.com/auth/google/callback</li>
|
||||
<li>Set authorized JavaScript origins: https://your-domain.com</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Future APIs */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 opacity-50">
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">Google Maps API</h3>
|
||||
<span className="bg-gray-100 text-gray-600 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Google Maps API key (not yet implemented)"
|
||||
disabled
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">Twilio API</h3>
|
||||
<span className="bg-gray-100 text-gray-600 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
Coming Soon
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Twilio API key (not yet implemented)"
|
||||
disabled
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Settings Section */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 px-8 py-6 border-b border-slate-200/60">
|
||||
<h2 className="text-xl font-bold text-slate-800">System Settings</h2>
|
||||
<p className="text-slate-600 mt-1">Configure default system behavior</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultPickup" className="form-label">Default Pickup Location</label>
|
||||
<input
|
||||
type="text"
|
||||
id="defaultPickup"
|
||||
value={systemSettings.defaultPickupLocation || ''}
|
||||
onChange={(e) => handleSettingChange('defaultPickupLocation', e.target.value)}
|
||||
placeholder="e.g., JFK Airport Terminal 4"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultDropoff" className="form-label">Default Dropoff Location</label>
|
||||
<input
|
||||
type="text"
|
||||
id="defaultDropoff"
|
||||
value={systemSettings.defaultDropoffLocation || ''}
|
||||
onChange={(e) => handleSettingChange('defaultDropoffLocation', e.target.value)}
|
||||
placeholder="e.g., Hilton Downtown"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="timezone" className="form-label">Time Zone</label>
|
||||
<select
|
||||
id="timezone"
|
||||
value={systemSettings.timeZone || 'America/New_York'}
|
||||
onChange={(e) => handleSettingChange('timeZone', e.target.value)}
|
||||
className="form-select"
|
||||
>
|
||||
<option value="America/New_York">Eastern Time</option>
|
||||
<option value="America/Chicago">Central Time</option>
|
||||
<option value="America/Denver">Mountain Time</option>
|
||||
<option value="America/Los_Angeles">Pacific Time</option>
|
||||
<option value="UTC">UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="checkbox-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={systemSettings.notificationsEnabled || false}
|
||||
onChange={(e) => handleSettingChange('notificationsEnabled', e.target.checked)}
|
||||
className="form-checkbox mr-3"
|
||||
/>
|
||||
<span className="font-medium">Enable Email/SMS Notifications</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test VIP Data Section */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-orange-50 to-red-50 px-8 py-6 border-b border-slate-200/60">
|
||||
<h2 className="text-xl font-bold text-slate-800">Test VIP Data Management</h2>
|
||||
<p className="text-slate-600 mt-1">Create and manage test VIP data for application testing</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-3">Create Test VIPs</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Generate 20 diverse test VIPs (10 Admin department, 10 Office of Development) with realistic data including flights, transport modes, and special requirements.
|
||||
</p>
|
||||
<ul className="text-sm text-slate-600 mb-4 space-y-1">
|
||||
<li>• Mixed flight and self-driving transport modes</li>
|
||||
<li>• Single flights, connecting flights, and multi-segment journeys</li>
|
||||
<li>• Diverse organizations and special requirements</li>
|
||||
<li>• Realistic arrival dates (tomorrow and day after)</li>
|
||||
</ul>
|
||||
<button
|
||||
className="btn btn-success w-full"
|
||||
onClick={createTestVips}
|
||||
disabled={testDataLoading}
|
||||
>
|
||||
{testDataLoading ? (
|
||||
<>
|
||||
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
|
||||
Creating Test VIPs...
|
||||
</>
|
||||
) : (
|
||||
'🎭 Create 20 Test VIPs'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-3">Remove Test VIPs</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Remove all test VIPs from the system. This will delete VIPs from the following test organizations:
|
||||
</p>
|
||||
<div className="text-xs text-slate-500 mb-4 max-h-20 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{getTestOrganizations().slice(0, 8).map(org => (
|
||||
<div key={org}>• {org}</div>
|
||||
))}
|
||||
<div className="text-slate-400">... and 12 more organizations</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-danger w-full"
|
||||
onClick={removeTestVips}
|
||||
disabled={testDataLoading}
|
||||
>
|
||||
{testDataLoading ? (
|
||||
<>
|
||||
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2"></span>
|
||||
Removing Test VIPs...
|
||||
</>
|
||||
) : (
|
||||
'🗑️ Remove All Test VIPs'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testDataStatus && (
|
||||
<div className={`mt-6 p-4 rounded-lg text-center font-medium ${
|
||||
testDataStatus.includes('✅') || testDataStatus.includes('🗑️')
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}>
|
||||
{testDataStatus}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mt-6">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">💡 Test Data Details</h4>
|
||||
<div className="text-sm text-blue-800 space-y-1">
|
||||
<p><strong>Admin Department (10 VIPs):</strong> University officials, ambassadors, ministers, and executives</p>
|
||||
<p><strong>Office of Development (10 VIPs):</strong> Donors, foundation leaders, and philanthropists</p>
|
||||
<p><strong>Transport Modes:</strong> Mix of flights (single, connecting, multi-segment) and self-driving</p>
|
||||
<p><strong>Special Requirements:</strong> Dietary restrictions, accessibility needs, security details, interpreters</p>
|
||||
<p><strong>Full Day Schedules:</strong> Each VIP gets 5-7 realistic events including meetings, meals, tours, and presentations</p>
|
||||
<p><strong>Schedule Types:</strong> Airport pickup, welcome breakfast, department meetings, working lunches, campus tours, receptions</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Documentation Section */}
|
||||
<div className="bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden">
|
||||
<div className="bg-gradient-to-r from-purple-50 to-pink-50 px-8 py-6 border-b border-slate-200/60">
|
||||
<h2 className="text-xl font-bold text-slate-800">API Documentation</h2>
|
||||
<p className="text-slate-600 mt-1">Developer resources and API testing</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-3">Interactive API Documentation</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Explore and test all API endpoints with the interactive Swagger UI documentation.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary w-full mb-2"
|
||||
onClick={() => window.open('http://localhost:3000/api-docs.html', '_blank')}
|
||||
>
|
||||
Open API Documentation
|
||||
</button>
|
||||
<p className="text-xs text-slate-500">
|
||||
Opens in a new tab with full endpoint documentation and testing capabilities
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
|
||||
<h3 className="text-lg font-bold text-slate-800 mb-3">Quick API Examples</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Health Check:</span>
|
||||
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/health</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Get VIPs:</span>
|
||||
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/vips</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Get Drivers:</span>
|
||||
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/drivers</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Flight Info:</span>
|
||||
<code className="ml-2 bg-white px-2 py-1 rounded text-xs">GET /api/flights/UA1234</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary w-full mt-4"
|
||||
onClick={() => window.open('/README-API.md', '_blank')}
|
||||
>
|
||||
View API Guide
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mt-6">
|
||||
<p className="text-amber-800">
|
||||
<strong>Pro Tip:</strong> The interactive documentation allows you to test API endpoints directly in your browser.
|
||||
Perfect for developers integrating with the VIP Coordinator system!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
className="btn btn-success text-lg px-8 py-4"
|
||||
onClick={saveSettings}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save All Settings'}
|
||||
</button>
|
||||
|
||||
{saveStatus && (
|
||||
<div className={`mt-4 p-4 rounded-lg ${
|
||||
saveStatus.includes('successfully')
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}>
|
||||
{saveStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { apiCall } from '../config/api';
|
||||
import GanttChart from '../components/GanttChart';
|
||||
@@ -90,13 +90,6 @@ const DriverDashboard: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (timeString: string) => {
|
||||
return new Date(timeString).toLocaleDateString([], {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getNextEvent = () => {
|
||||
if (!scheduleData?.schedule) return null;
|
||||
|
||||
112
frontend/src/pages/PendingApproval.tsx
Normal file
112
frontend/src/pages/PendingApproval.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiCall } from '../utils/api';
|
||||
import { User } from '../types';
|
||||
|
||||
const PendingApproval: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Get user data from localStorage
|
||||
const savedUser = localStorage.getItem('user');
|
||||
if (savedUser) {
|
||||
setUser(JSON.parse(savedUser));
|
||||
}
|
||||
setLoading(false);
|
||||
|
||||
// Check status every 30 seconds
|
||||
const interval = setInterval(checkUserStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const checkUserStatus = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const { data: userData } = await apiCall('/auth/users/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (userData) {
|
||||
setUser(userData);
|
||||
|
||||
// If user is approved, redirect to dashboard
|
||||
if (userData.status === 'active') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking user status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-4 border-amber-500 border-t-transparent mx-auto"></div>
|
||||
<p className="mt-4 text-slate-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-amber-50 via-orange-50 to-yellow-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-3xl shadow-2xl max-w-md w-full p-8 text-center relative overflow-hidden">
|
||||
{/* Decorative element */}
|
||||
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-br from-amber-200 to-orange-200 rounded-full blur-3xl opacity-30 -translate-y-20 translate-x-20"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
{/* Icon */}
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-amber-400 to-orange-500 rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<svg className="w-12 h-12 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Welcome message */}
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-amber-600 to-orange-600 bg-clip-text text-transparent mb-2">
|
||||
Welcome, {user?.name?.split(' ')[0]}!
|
||||
</h1>
|
||||
|
||||
<p className="text-lg text-slate-600 mb-8">
|
||||
Your account is being reviewed
|
||||
</p>
|
||||
|
||||
{/* Status message */}
|
||||
<div className="bg-gradient-to-r from-amber-50 to-orange-50 border border-amber-200 rounded-2xl p-6 mb-8">
|
||||
<p className="text-amber-800 leading-relaxed">
|
||||
Thank you for signing up! An administrator will review your account request shortly.
|
||||
We'll notify you once your access has been approved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-refresh notice */}
|
||||
<p className="text-sm text-slate-500 mb-8">
|
||||
This page checks for updates automatically
|
||||
</p>
|
||||
|
||||
{/* Logout button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full bg-gradient-to-r from-slate-600 to-slate-700 hover:from-slate-700 hover:to-slate-800 text-white font-medium py-3 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingApproval;
|
||||
111
frontend/src/pages/SimplifiedVipList.tsx
Normal file
111
frontend/src/pages/SimplifiedVipList.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useApi, useMutation } from '../hooks/useApi';
|
||||
import { vipApi } from '../api/client';
|
||||
import VipForm from '../components/VipForm';
|
||||
import { LoadingSpinner } from '../components/LoadingSpinner';
|
||||
import { ErrorMessage } from '../components/ErrorMessage';
|
||||
|
||||
// Simplified VIP List - no more manual loading states, error handling, or token management
|
||||
const SimplifiedVipList: React.FC = () => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const { data: vips, loading, error, refetch } = useApi(() => vipApi.list());
|
||||
|
||||
const createVip = useMutation(vipApi.create);
|
||||
const deleteVip = useMutation(vipApi.delete);
|
||||
|
||||
const handleAddVip = async (vipData: any) => {
|
||||
try {
|
||||
await createVip.mutate(vipData);
|
||||
setShowForm(false);
|
||||
refetch(); // Refresh the list
|
||||
} catch (error) {
|
||||
// Error is already handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteVip = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this VIP?')) return;
|
||||
|
||||
try {
|
||||
await deleteVip.mutate(id);
|
||||
refetch(); // Refresh the list
|
||||
} catch (error) {
|
||||
// Error is already handled by the hook
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner message="Loading VIPs..." />;
|
||||
if (error) return <ErrorMessage message={error} onDismiss={() => refetch()} />;
|
||||
if (!vips) return null;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">VIP Management</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
Add VIP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{createVip.error && (
|
||||
<ErrorMessage message={createVip.error} className="mb-4" />
|
||||
)}
|
||||
|
||||
{deleteVip.error && (
|
||||
<ErrorMessage message={deleteVip.error} className="mb-4" />
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{vips.map((vip) => (
|
||||
<div key={vip.id} className="bg-white p-4 rounded-lg shadow">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{vip.name}</h3>
|
||||
<p className="text-gray-600">{vip.organization}</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{vip.transportMode === 'flight'
|
||||
? `Flight: ${vip.flights?.[0]?.flightNumber || 'TBD'}`
|
||||
: `Driving - Arrival: ${new Date(vip.expectedArrival).toLocaleString()}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/vips/${vip.id}`}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteVip(vip.id)}
|
||||
disabled={deleteVip.loading}
|
||||
className="text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-xl font-bold mb-4">Add New VIP</h2>
|
||||
<VipForm
|
||||
onSubmit={handleAddVip}
|
||||
onCancel={() => setShowForm(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimplifiedVipList;
|
||||
84
frontend/src/tests/setup.ts
Normal file
84
frontend/src/tests/setup.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach, vi } from 'vitest';
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // deprecated
|
||||
removeListener: vi.fn(), // deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Reset mocks before each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default fetch mock
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
text: async () => '',
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
});
|
||||
});
|
||||
|
||||
// Mock Google Identity Services
|
||||
(global as any).google = {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: vi.fn(),
|
||||
renderButton: vi.fn(),
|
||||
prompt: vi.fn(),
|
||||
disableAutoSelect: vi.fn(),
|
||||
storeCredential: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
onGoogleLibraryLoad: vi.fn(),
|
||||
revoke: vi.fn(),
|
||||
},
|
||||
oauth2: {
|
||||
initTokenClient: vi.fn(),
|
||||
initCodeClient: vi.fn(),
|
||||
hasGrantedAnyScope: vi.fn(),
|
||||
hasGrantedAllScopes: vi.fn(),
|
||||
revoke: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Mock console methods to reduce test noise
|
||||
const originalError = console.error;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
beforeAll(() => {
|
||||
console.error = vi.fn();
|
||||
console.warn = vi.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
console.error = originalError;
|
||||
console.warn = originalWarn;
|
||||
});
|
||||
195
frontend/src/tests/test-utils.tsx
Normal file
195
frontend/src/tests/test-utils.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { render, RenderOptions } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../contexts/ToastContext';
|
||||
|
||||
// Mock user data
|
||||
export const mockUsers = {
|
||||
admin: {
|
||||
id: '123',
|
||||
email: 'admin@test.com',
|
||||
name: 'Test Admin',
|
||||
role: 'administrator',
|
||||
status: 'active',
|
||||
approval_status: 'approved',
|
||||
profile_picture_url: 'https://example.com/admin.jpg',
|
||||
},
|
||||
coordinator: {
|
||||
id: '456',
|
||||
email: 'coordinator@test.com',
|
||||
name: 'Test Coordinator',
|
||||
role: 'coordinator',
|
||||
status: 'active',
|
||||
approval_status: 'approved',
|
||||
profile_picture_url: 'https://example.com/coord.jpg',
|
||||
},
|
||||
pendingUser: {
|
||||
id: '789',
|
||||
email: 'pending@test.com',
|
||||
name: 'Pending User',
|
||||
role: 'coordinator',
|
||||
status: 'pending',
|
||||
approval_status: 'pending',
|
||||
profile_picture_url: 'https://example.com/pending.jpg',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock VIP data
|
||||
export const mockVips = {
|
||||
flightVip: {
|
||||
id: '001',
|
||||
name: 'John Doe',
|
||||
title: 'CEO',
|
||||
organization: 'Test Corp',
|
||||
contact_info: '+1234567890',
|
||||
arrival_datetime: '2025-01-15T10:00:00Z',
|
||||
departure_datetime: '2025-01-16T14:00:00Z',
|
||||
airport: 'LAX',
|
||||
flight_number: 'AA123',
|
||||
hotel: 'Hilton Downtown',
|
||||
room_number: '1234',
|
||||
status: 'scheduled',
|
||||
transportation_mode: 'flight',
|
||||
notes: 'Requires luxury vehicle',
|
||||
},
|
||||
drivingVip: {
|
||||
id: '002',
|
||||
name: 'Jane Smith',
|
||||
title: 'VP Sales',
|
||||
organization: 'Another Corp',
|
||||
contact_info: '+0987654321',
|
||||
arrival_datetime: '2025-01-15T14:00:00Z',
|
||||
departure_datetime: '2025-01-16T10:00:00Z',
|
||||
hotel: 'Marriott',
|
||||
room_number: '567',
|
||||
status: 'scheduled',
|
||||
transportation_mode: 'self_driving',
|
||||
notes: 'Arrives by personal vehicle',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock driver data
|
||||
export const mockDrivers = {
|
||||
available: {
|
||||
id: 'd001',
|
||||
name: 'Mike Johnson',
|
||||
phone: '+1234567890',
|
||||
email: 'mike@drivers.com',
|
||||
license_number: 'DL123456',
|
||||
vehicle_info: '2023 Tesla Model S - Black',
|
||||
availability_status: 'available',
|
||||
current_location: 'Downtown Station',
|
||||
notes: 'Experienced with VIP transport',
|
||||
},
|
||||
busy: {
|
||||
id: 'd002',
|
||||
name: 'Sarah Williams',
|
||||
phone: '+0987654321',
|
||||
email: 'sarah@drivers.com',
|
||||
license_number: 'DL789012',
|
||||
vehicle_info: '2023 Mercedes S-Class - Silver',
|
||||
availability_status: 'busy',
|
||||
current_location: 'Airport',
|
||||
notes: 'Currently on assignment',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock schedule events
|
||||
export const mockScheduleEvents = {
|
||||
pickup: {
|
||||
id: 'e001',
|
||||
vip_id: '001',
|
||||
driver_id: 'd001',
|
||||
event_type: 'pickup',
|
||||
scheduled_time: '2025-01-15T10:30:00Z',
|
||||
location: 'LAX Terminal 4',
|
||||
status: 'scheduled',
|
||||
notes: 'Meet at baggage claim',
|
||||
},
|
||||
dropoff: {
|
||||
id: 'e002',
|
||||
vip_id: '001',
|
||||
driver_id: 'd001',
|
||||
event_type: 'dropoff',
|
||||
scheduled_time: '2025-01-16T12:00:00Z',
|
||||
location: 'LAX Terminal 4',
|
||||
status: 'scheduled',
|
||||
notes: 'Departure gate B23',
|
||||
},
|
||||
};
|
||||
|
||||
// Custom render function that includes providers
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
initialRoute?: string;
|
||||
user?: typeof mockUsers.admin | null;
|
||||
}
|
||||
|
||||
const AllTheProviders = ({
|
||||
children,
|
||||
initialRoute = '/'
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialRoute?: string;
|
||||
}) => {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[initialRoute]}>
|
||||
<ToastProvider>
|
||||
{children}
|
||||
</ToastProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
export const customRender = (
|
||||
ui: ReactElement,
|
||||
{ initialRoute = '/', ...options }: CustomRenderOptions = {}
|
||||
) => {
|
||||
return render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<AllTheProviders initialRoute={initialRoute}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
// Mock API responses
|
||||
export const mockApiResponses = {
|
||||
getVips: () => ({
|
||||
ok: true,
|
||||
json: async () => [mockVips.flightVip, mockVips.drivingVip],
|
||||
}),
|
||||
getVip: (id: string) => ({
|
||||
ok: true,
|
||||
json: async () =>
|
||||
id === '001' ? mockVips.flightVip : mockVips.drivingVip,
|
||||
}),
|
||||
getDrivers: () => ({
|
||||
ok: true,
|
||||
json: async () => [mockDrivers.available, mockDrivers.busy],
|
||||
}),
|
||||
getSchedule: () => ({
|
||||
ok: true,
|
||||
json: async () => [mockScheduleEvents.pickup, mockScheduleEvents.dropoff],
|
||||
}),
|
||||
getCurrentUser: (user = mockUsers.admin) => ({
|
||||
ok: true,
|
||||
json: async () => user,
|
||||
}),
|
||||
error: (message = 'Server error') => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ error: message }),
|
||||
}),
|
||||
};
|
||||
|
||||
// Helper to wait for async operations
|
||||
export const waitForLoadingToFinish = () =>
|
||||
new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
// Re-export everything from React Testing Library
|
||||
export * from '@testing-library/react';
|
||||
|
||||
// Use custom render by default
|
||||
export { customRender as render };
|
||||
116
frontend/src/types/index.ts
Normal file
116
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
// User types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'administrator' | 'coordinator' | 'driver' | 'viewer';
|
||||
status?: 'pending' | 'active' | 'deactivated';
|
||||
organization?: string;
|
||||
phone?: string;
|
||||
department?: string;
|
||||
profilePhoto?: string;
|
||||
onboardingData?: {
|
||||
vehicleType?: string;
|
||||
vehicleCapacity?: number;
|
||||
licensePlate?: string;
|
||||
homeLocation?: { lat: number; lng: number };
|
||||
requestedRole: string;
|
||||
reason: string;
|
||||
};
|
||||
approvedBy?: string;
|
||||
approvedAt?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
lastLogin?: string;
|
||||
}
|
||||
|
||||
// VIP types
|
||||
export interface Flight {
|
||||
flightNumber: string;
|
||||
airline?: string;
|
||||
scheduledArrival: string;
|
||||
scheduledDeparture?: string;
|
||||
status?: 'scheduled' | 'delayed' | 'cancelled' | 'arrived';
|
||||
}
|
||||
|
||||
export interface VIP {
|
||||
id: string;
|
||||
name: string;
|
||||
organization?: string;
|
||||
department?: 'Office of Development' | 'Admin';
|
||||
transportMode: 'flight' | 'self-driving';
|
||||
flights?: Flight[];
|
||||
expectedArrival?: string;
|
||||
needsAirportPickup?: boolean;
|
||||
needsVenueTransport?: boolean;
|
||||
notes?: string;
|
||||
assignedDriverIds?: string[];
|
||||
schedule?: ScheduleEvent[];
|
||||
}
|
||||
|
||||
// Driver types
|
||||
export interface Driver {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone: string;
|
||||
vehicleInfo?: string;
|
||||
status?: 'available' | 'assigned' | 'unavailable';
|
||||
department?: string;
|
||||
currentLocation?: { lat: number; lng: number };
|
||||
assignedVipIds?: string[];
|
||||
}
|
||||
|
||||
// Schedule Event types
|
||||
export interface ScheduleEvent {
|
||||
id: string;
|
||||
vipId?: string;
|
||||
vipName?: string;
|
||||
assignedDriverId?: string;
|
||||
eventTime: string;
|
||||
eventType: 'pickup' | 'dropoff' | 'custom';
|
||||
location: string;
|
||||
notes?: string;
|
||||
status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled';
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
// Form data types
|
||||
export interface VipFormData {
|
||||
name: string;
|
||||
organization?: string;
|
||||
department?: 'Office of Development' | 'Admin';
|
||||
transportMode: 'flight' | 'self-driving';
|
||||
flights?: Flight[];
|
||||
expectedArrival?: string;
|
||||
needsAirportPickup?: boolean;
|
||||
needsVenueTransport?: boolean;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface DriverFormData {
|
||||
name: string;
|
||||
email?: string;
|
||||
phone: string;
|
||||
vehicleInfo?: string;
|
||||
status?: 'available' | 'assigned' | 'unavailable';
|
||||
}
|
||||
|
||||
export interface ScheduleEventFormData {
|
||||
assignedDriverId?: string;
|
||||
eventTime: string;
|
||||
eventType: 'pickup' | 'dropoff' | 'custom';
|
||||
location: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// Admin settings
|
||||
export interface SystemSettings {
|
||||
aviationStackKey?: string;
|
||||
googleMapsKey?: string;
|
||||
twilioKey?: string;
|
||||
enableFlightTracking?: boolean;
|
||||
enableSMSNotifications?: boolean;
|
||||
defaultPickupLocation?: string;
|
||||
defaultDropoffLocation?: string;
|
||||
}
|
||||
21
frontend/src/utils/api.ts
Normal file
21
frontend/src/utils/api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { apiCall as baseApiCall, API_BASE_URL } from '../config/api';
|
||||
|
||||
// Re-export API_BASE_URL for components that need it
|
||||
export { API_BASE_URL };
|
||||
|
||||
// Legacy API call wrapper that returns the response directly
|
||||
// This maintains backward compatibility with existing code
|
||||
export const apiCall = async (endpoint: string, options?: RequestInit) => {
|
||||
const result = await baseApiCall(endpoint, options);
|
||||
|
||||
// Return the response object with data attached
|
||||
const response = result.response;
|
||||
(response as any).data = result.data;
|
||||
|
||||
// Make the response look like it can be used with .json()
|
||||
if (!response.json) {
|
||||
(response as any).json = async () => result.data;
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
@@ -240,7 +240,7 @@ export const getTestOrganizations = () => [
|
||||
];
|
||||
|
||||
// Generate realistic daily schedules for VIPs
|
||||
export const generateVipSchedule = (vipName: string, department: string, transportMode: string) => {
|
||||
export const generateVipSchedule = (department: string, transportMode: string) => {
|
||||
const today = new Date();
|
||||
const eventDate = new Date(today);
|
||||
eventDate.setDate(eventDate.getDate() + 1); // Tomorrow
|
||||
|
||||
@@ -14,8 +14,7 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
allowedHosts: [
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'bsa.madeamess.online'
|
||||
'127.0.0.1'
|
||||
],
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
28
frontend/vitest.config.ts
Normal file
28
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/tests/setup.ts',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'src/tests/',
|
||||
'*.config.*',
|
||||
'src/main.tsx',
|
||||
'src/vite-env.d.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user