Backup: 2025-06-07 18:32 - Production setup complete
[Restore from backup: vip-coordinator-backup-2025-06-07-18-32-production-setup-complete]
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Multi-stage build for development and production
|
||||
FROM node:18-alpine AS base
|
||||
FROM node:22-alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -15,20 +15,7 @@ CMD ["npm", "run", "dev"]
|
||||
|
||||
# Production stage
|
||||
FROM base AS production
|
||||
ARG VITE_AUTH0_DOMAIN
|
||||
ARG VITE_AUTH0_CLIENT_ID
|
||||
ARG VITE_AUTH0_AUDIENCE
|
||||
ENV VITE_AUTH0_DOMAIN=${VITE_AUTH0_DOMAIN}
|
||||
ENV VITE_AUTH0_CLIENT_ID=${VITE_AUTH0_CLIENT_ID}
|
||||
ENV VITE_AUTH0_AUDIENCE=${VITE_AUTH0_AUDIENCE}
|
||||
RUN npm ci
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# Serve with nginx
|
||||
FROM nginx:alpine AS serve
|
||||
COPY --from=production /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -1,64 +1,51 @@
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name bsa.madeamess.online _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name bsa.madeamess.online _;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
|
||||
location /auth/callback {
|
||||
try_files $uri /index.html;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://backend:3000/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,34 +3,38 @@
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "node ./node_modules/vite/bin/vite.js",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth0/auth0-react": "^2.8.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"vite": "^4.5.14"
|
||||
"react-router-dom": "^6.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.4",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.47",
|
||||
"typescript": "^5.0.2"
|
||||
"@typescript-eslint/eslint-plugin": "^8.15.0",
|
||||
"@typescript-eslint/parser": "^8.15.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"postcss": "^8.5.4",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/postcss.config.mjs
Normal file
6
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
||||
@@ -1,105 +1,117 @@
|
||||
/* Modern App-specific styles using Tailwind utilities */
|
||||
|
||||
/* Enhanced button styles */
|
||||
.btn-modern {
|
||||
@apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
|
||||
}
|
||||
|
||||
.btn-gradient-blue {
|
||||
@apply bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-green {
|
||||
@apply bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-purple {
|
||||
@apply bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-amber {
|
||||
@apply bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white;
|
||||
@layer components {
|
||||
.btn-modern {
|
||||
@apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
|
||||
}
|
||||
|
||||
.btn-gradient-blue {
|
||||
@apply bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-green {
|
||||
@apply bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-purple {
|
||||
@apply bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white;
|
||||
}
|
||||
|
||||
.btn-gradient-amber {
|
||||
@apply bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700 text-white;
|
||||
}
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold;
|
||||
}
|
||||
@layer components {
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold;
|
||||
}
|
||||
|
||||
.status-scheduled {
|
||||
@apply bg-blue-100 text-blue-800 border border-blue-200;
|
||||
}
|
||||
.status-scheduled {
|
||||
@apply bg-blue-100 text-blue-800 border border-blue-200;
|
||||
}
|
||||
|
||||
.status-in-progress {
|
||||
@apply bg-amber-100 text-amber-800 border border-amber-200;
|
||||
}
|
||||
.status-in-progress {
|
||||
@apply bg-amber-100 text-amber-800 border border-amber-200;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
@apply bg-green-100 text-green-800 border border-green-200;
|
||||
}
|
||||
.status-completed {
|
||||
@apply bg-green-100 text-green-800 border border-green-200;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
@apply bg-red-100 text-red-800 border border-red-200;
|
||||
.status-cancelled {
|
||||
@apply bg-red-100 text-red-800 border border-red-200;
|
||||
}
|
||||
}
|
||||
|
||||
/* Card enhancements */
|
||||
.card-modern {
|
||||
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply bg-gradient-to-r from-slate-50 to-slate-100 px-6 py-4 border-b border-slate-200/60;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
@layer components {
|
||||
.card-modern {
|
||||
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply bg-gradient-to-r from-slate-50 to-slate-100 px-6 py-4 border-b border-slate-200/60;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
@apply p-6;
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading-spinner {
|
||||
@apply animate-spin rounded-full border-4 border-blue-600 border-t-transparent;
|
||||
}
|
||||
@layer components {
|
||||
.loading-spinner {
|
||||
@apply animate-spin rounded-full border-4 border-blue-600 border-t-transparent;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
@apply text-slate-600 animate-pulse;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-slate-200 rounded;
|
||||
.loading-text {
|
||||
@apply text-slate-600 animate-pulse;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
@apply animate-pulse bg-slate-200 rounded;
|
||||
}
|
||||
}
|
||||
|
||||
/* Form enhancements */
|
||||
.form-modern {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.form-group-modern {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.form-label-modern {
|
||||
@apply block text-sm font-semibold text-slate-700;
|
||||
}
|
||||
|
||||
.form-input-modern {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200;
|
||||
}
|
||||
|
||||
.form-select-modern {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white transition-all duration-200;
|
||||
@layer components {
|
||||
.form-modern {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.form-group-modern {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.form-label-modern {
|
||||
@apply block text-sm font-semibold text-slate-700;
|
||||
}
|
||||
|
||||
.form-input-modern {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200;
|
||||
}
|
||||
|
||||
.form-select-modern {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white transition-all duration-200;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation utilities */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
@layer utilities {
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -149,23 +161,27 @@
|
||||
}
|
||||
|
||||
/* Glass morphism effect */
|
||||
.glass {
|
||||
@apply bg-white/80 backdrop-blur-lg border border-white/20;
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply bg-slate-900/80 backdrop-blur-lg border border-slate-700/20;
|
||||
@layer utilities {
|
||||
.glass {
|
||||
@apply bg-white/80 backdrop-blur-lg border border-white/20;
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
@apply bg-slate-900/80 backdrop-blur-lg border border-slate-700/20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover effects */
|
||||
.hover-lift {
|
||||
@apply transition-transform duration-200 hover:-translate-y-1;
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
@apply transition-shadow duration-200 hover:shadow-2xl;
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
@apply transition-transform duration-200 hover:scale-105;
|
||||
@layer utilities {
|
||||
.hover-lift {
|
||||
@apply transition-transform duration-200 hover:-translate-y-1;
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
@apply transition-shadow duration-200 hover:shadow-2xl;
|
||||
}
|
||||
|
||||
.hover-scale {
|
||||
@apply transition-transform duration-200 hover:scale-105;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { apiCall } from './config/api';
|
||||
import VipList from './pages/VipList';
|
||||
import VipDetails from './pages/VipDetails';
|
||||
@@ -12,102 +11,54 @@ import UserManagement from './components/UserManagement';
|
||||
import Login from './components/Login';
|
||||
import './App.css';
|
||||
|
||||
const AUTH0_AUDIENCE = import.meta.env.VITE_AUTH0_AUDIENCE;
|
||||
|
||||
function App() {
|
||||
const {
|
||||
isLoading: authLoading,
|
||||
isAuthenticated,
|
||||
loginWithRedirect,
|
||||
logout,
|
||||
getAccessTokenSilently,
|
||||
user: auth0User,
|
||||
error: authError
|
||||
} = useAuth0();
|
||||
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [pendingApproval, setPendingApproval] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const bootstrap = async () => {
|
||||
if (!isAuthenticated) {
|
||||
setUser(null);
|
||||
setStatusMessage(null);
|
||||
setPendingApproval(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setPendingApproval(false);
|
||||
setStatusMessage(null);
|
||||
|
||||
try {
|
||||
const token = await getAccessTokenSilently({
|
||||
authorizationParams: {
|
||||
...(AUTH0_AUDIENCE ? { audience: AUTH0_AUDIENCE } : {}),
|
||||
scope: 'openid profile email'
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.setItem('authToken', token);
|
||||
|
||||
const response = await apiCall('/auth/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
const data = await response.json();
|
||||
setUser(null);
|
||||
setPendingApproval(true);
|
||||
setStatusMessage(data.message || 'Your account is pending administrator approval.');
|
||||
return;
|
||||
// Check if user is already authenticated
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
apiCall('/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load profile (${response.status})`);
|
||||
})
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
} else {
|
||||
// Token is invalid, remove it
|
||||
localStorage.removeItem('authToken');
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const userRecord = data.user || data;
|
||||
|
||||
const resolvedName =
|
||||
userRecord.name ||
|
||||
auth0User?.name ||
|
||||
auth0User?.nickname ||
|
||||
auth0User?.email ||
|
||||
userRecord.email;
|
||||
|
||||
setUser({
|
||||
...userRecord,
|
||||
name: resolvedName,
|
||||
role: userRecord.role,
|
||||
picture: userRecord.picture || auth0User?.picture
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Authentication bootstrap failed:', error);
|
||||
setUser(null);
|
||||
setStatusMessage('Authentication failed. Please try signing in again.');
|
||||
} finally {
|
||||
})
|
||||
.then(userData => {
|
||||
setUser(userData);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authLoading) {
|
||||
bootstrap();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Auth check failed:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isAuthenticated, authLoading, getAccessTokenSilently, auth0User]);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (userData: any) => {
|
||||
setUser(userData);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
logout({ logoutParams: { returnTo: window.location.origin } });
|
||||
setUser(null);
|
||||
// Optionally call logout endpoint
|
||||
apiCall('/auth/logout', { method: 'POST' })
|
||||
.catch(error => console.error('Logout error:', error));
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4">
|
||||
@@ -118,68 +69,23 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingApproval) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-amber-50 to-rose-50 flex justify-center items-center px-4">
|
||||
<div className="bg-white border border-amber-200/60 rounded-2xl shadow-xl max-w-xl w-full p-8 space-y-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-3xl">
|
||||
⏳
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-800">Awaiting Administrator Approval</h1>
|
||||
<p className="text-slate-600">
|
||||
{statusMessage ||
|
||||
'Thanks for signing in. An administrator needs to approve your account before you can access the dashboard.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-secondary mt-4"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Handle OAuth callback route even when not logged in
|
||||
if (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback') {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
const beginLogin = async () => {
|
||||
try {
|
||||
await loginWithRedirect({
|
||||
authorizationParams: {
|
||||
...(AUTH0_AUDIENCE ? { audience: AUTH0_AUDIENCE } : {}),
|
||||
scope: 'openid profile email',
|
||||
redirect_uri: `${window.location.origin}/auth/callback`
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('Auth0 login failed:', error);
|
||||
setStatusMessage(error?.message || 'Authentication failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<Login
|
||||
onLogin={beginLogin}
|
||||
errorMessage={statusMessage || authError?.message}
|
||||
/>
|
||||
);
|
||||
if (!user) {
|
||||
return <Login onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
const displayName =
|
||||
(user.name && user.name.trim().length > 0)
|
||||
? user.name
|
||||
: (user.email || 'User');
|
||||
const displayInitial = displayName.trim().charAt(0).toUpperCase();
|
||||
const userRole = user.role || 'user';
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50">
|
||||
{/* Modern Navigation */}
|
||||
<nav className="bg-white/80 backdrop-blur-lg border-b border-slate-200/60 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo/Brand */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">VC</span>
|
||||
@@ -189,36 +95,37 @@ function App() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className="hidden md:flex items-center space-x-1">
|
||||
<Link
|
||||
to="/"
|
||||
<Link
|
||||
to="/"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/vips"
|
||||
<Link
|
||||
to="/vips"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
VIPs
|
||||
</Link>
|
||||
<Link
|
||||
to="/drivers"
|
||||
<Link
|
||||
to="/drivers"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Drivers
|
||||
</Link>
|
||||
{userRole === 'administrator' && (
|
||||
<Link
|
||||
to="/admin"
|
||||
{(user.role === 'administrator' || user.role === 'coordinator') && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
{userRole === 'administrator' && (
|
||||
<Link
|
||||
to="/users"
|
||||
{user.role === 'administrator' && (
|
||||
<Link
|
||||
to="/users"
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 hover:text-purple-600 hover:bg-purple-50 rounded-lg transition-all duration-200"
|
||||
>
|
||||
Users
|
||||
@@ -226,23 +133,20 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="hidden sm:flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center overflow-hidden">
|
||||
{user.picture ? (
|
||||
<img src={user.picture} alt={displayName} className="w-8 h-8 object-cover" />
|
||||
) : (
|
||||
<span className="text-white text-xs font-medium">
|
||||
{displayInitial}
|
||||
</span>
|
||||
)}
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-slate-400 to-slate-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-xs font-medium">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-slate-900">{displayName}</div>
|
||||
<div className="text-slate-500 capitalize">{userRole}</div>
|
||||
<div className="font-medium text-slate-900">{user.name}</div>
|
||||
<div className="text-slate-500 capitalize">{user.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 shadow-lg hover:shadow-xl"
|
||||
>
|
||||
@@ -253,6 +157,7 @@ function App() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-6 lg:px-8 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
|
||||
@@ -94,101 +94,6 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dev-login-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.dev-login-divider::before,
|
||||
.dev-login-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.dev-login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dev-login-form h3 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dev-login-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dev-login-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.dev-login-form input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #cbd5f5;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dev-login-form input:focus {
|
||||
outline: none;
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||
}
|
||||
|
||||
.dev-login-error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dev-login-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dev-login-button:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.dev-login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
border-top: 1px solid #eee;
|
||||
padding-top: 20px;
|
||||
|
||||
@@ -3,15 +3,15 @@ import { apiCall } from '../config/api';
|
||||
import './Login.css';
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void;
|
||||
errorMessage?: string | null | undefined;
|
||||
onLogin: (user: any) => void;
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
|
||||
const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
const [setupStatus, setSetupStatus] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check system setup status
|
||||
apiCall('/auth/setup')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
@@ -22,7 +22,82 @@ const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
|
||||
console.error('Error checking setup status:', error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Check for OAuth callback code in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const error = urlParams.get('error');
|
||||
const token = urlParams.get('token');
|
||||
|
||||
if (code && (window.location.pathname === '/auth/google/callback' || window.location.pathname === '/auth/callback')) {
|
||||
// Exchange code for token
|
||||
apiCall('/auth/google/exchange', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ code })
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to exchange code for token');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(({ token, user }) => {
|
||||
localStorage.setItem('authToken', token);
|
||||
onLogin(user);
|
||||
// Clean up URL and redirect to dashboard
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('OAuth exchange failed:', error);
|
||||
alert('Login failed. Please try again.');
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
});
|
||||
} else if (token && (window.location.pathname === '/auth/callback' || window.location.pathname === '/auth/google/callback')) {
|
||||
// Direct token from URL (from backend redirect)
|
||||
localStorage.setItem('authToken', token);
|
||||
|
||||
apiCall('/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(user => {
|
||||
onLogin(user);
|
||||
// Clean up URL and redirect to dashboard
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error getting user info:', error);
|
||||
localStorage.removeItem('authToken');
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
});
|
||||
} else if (error) {
|
||||
console.error('Authentication error:', error);
|
||||
alert(`Login error: ${error}`);
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, '/');
|
||||
}
|
||||
}, [onLogin]);
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
// Get OAuth URL from backend
|
||||
const response = await apiCall('/auth/google/url');
|
||||
const { url } = await response.json();
|
||||
|
||||
// Redirect to Google OAuth
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error('Failed to get OAuth URL:', error);
|
||||
alert('Login failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -45,38 +120,55 @@ const Login: React.FC<LoginProps> = ({ onLogin, errorMessage }) => {
|
||||
{!setupStatus?.firstAdminCreated && (
|
||||
<div className="setup-notice">
|
||||
<h3>🚀 First Time Setup</h3>
|
||||
<p>The first person to sign in will be promoted to administrator automatically.</p>
|
||||
<p>The first person to log in will become the system administrator.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="login-content">
|
||||
<button
|
||||
<button
|
||||
className="google-login-btn"
|
||||
onClick={onLogin}
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={false}
|
||||
>
|
||||
<svg className="google-icon" viewBox="0 0 24 24">
|
||||
<path fill="#635dff" d="M22 12.07c0-5.52-4.48-10-10-10s-10 4.48-10 10a9.97 9.97 0 006.85 9.48.73.73 0 00.95-.7v-3.05c-2.79.61-3.38-1.19-3.38-1.19-.46-1.17-1.12-1.49-1.12-1.49-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.9 1.53 2.37 1.09 2.96.83.09-.65.35-1.09.63-1.34-2.23-.25-4.57-1.12-4.57-4.96 0-1.1.39-2 1.03-2.7-.1-.25-.45-1.25.1-2.6 0 0 .84-.27 2.75 1.02a9.53 9.53 0 015 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.35.1 2.6.64.7 1.03 1.6 1.03 2.7 0 3.85-2.34 4.71-4.58 4.95.36.31.69.92.69 1.86v2.75c0 .39.27.71.66.79a10 10 0 007.61-9.71z"/>
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Continue with Auth0
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<div className="login-info">
|
||||
<p>
|
||||
{setupStatus?.authProvider === 'auth0'
|
||||
? 'Sign in with your organisation account. We use Auth0 for secure authentication.'
|
||||
: 'Authentication service is being configured. Please try again later.'}
|
||||
{setupStatus?.firstAdminCreated
|
||||
? "Sign in with your Google account to access the VIP Coordinator."
|
||||
: "Sign in with Google to set up your administrator account."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="dev-login-error" style={{ marginTop: '1rem' }}>
|
||||
{errorMessage}
|
||||
{setupStatus && !setupStatus.setupCompleted && (
|
||||
<div style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #ffeaa7',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
<strong>⚠️ Setup Required:</strong>
|
||||
<p style={{ margin: '0.5rem 0 0 0' }}>
|
||||
Google OAuth credentials need to be configured. If the login doesn't work,
|
||||
please follow the setup guide in <code>GOOGLE_OAUTH_SETUP.md</code> to configure
|
||||
your Google Cloud Console credentials in the admin dashboard.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>Secure authentication powered by Auth0</p>
|
||||
<p>Secure authentication powered by Google OAuth</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
const DEFAULT_API_BASE =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.origin
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export const API_BASE_URL =
|
||||
import.meta.env.VITE_API_URL?.replace(/\/$/, '') || DEFAULT_API_BASE;
|
||||
// API Configuration
|
||||
// Use environment variable with fallback to production URL
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_URL || 'https://api.bsa.madeamess.online';
|
||||
|
||||
// Helper function for API calls
|
||||
export const apiCall = (endpoint: string, options?: RequestInit) => {
|
||||
const url = /^https?:\/\//.test(endpoint)
|
||||
? endpoint
|
||||
: `${API_BASE_URL}${endpoint}`;
|
||||
const url = endpoint.startsWith('/') ? `${API_BASE_URL}${endpoint}` : endpoint;
|
||||
return fetch(url, options);
|
||||
};
|
||||
|
||||
@@ -1,49 +1,4 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom base styles */
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
*:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Custom base styles */
|
||||
@layer base {
|
||||
@@ -92,117 +47,304 @@
|
||||
@layer components {
|
||||
/* Modern Button Styles */
|
||||
.btn {
|
||||
@apply px-6 py-3 rounded-xl font-semibold text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
ring: 2px;
|
||||
ring-offset: 2px;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-0.125rem);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white focus:ring-blue-500;
|
||||
background: linear-gradient(to right, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(to right, #2563eb, #1d4ed8);
|
||||
}
|
||||
|
||||
.btn-primary:focus {
|
||||
ring-color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gradient-to-r from-slate-500 to-slate-600 hover:from-slate-600 hover:to-slate-700 text-white focus:ring-slate-500;
|
||||
background: linear-gradient(to right, #64748b, #475569);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: linear-gradient(to right, #475569, #334155);
|
||||
}
|
||||
|
||||
.btn-secondary:focus {
|
||||
ring-color: #64748b;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white focus:ring-red-500;
|
||||
background: linear-gradient(to right, #ef4444, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: linear-gradient(to right, #dc2626, #b91c1c);
|
||||
}
|
||||
|
||||
.btn-danger:focus {
|
||||
ring-color: #ef4444;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-gradient-to-r from-green-500 to-green-600 hover:from-green-600 hover:to-green-700 text-white focus:ring-green-500;
|
||||
background: linear-gradient(to right, #22c55e, #16a34a);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: linear-gradient(to right, #16a34a, #15803d);
|
||||
}
|
||||
|
||||
.btn-success:focus {
|
||||
ring-color: #22c55e;
|
||||
}
|
||||
|
||||
/* Modern Card Styles */
|
||||
.card {
|
||||
@apply bg-white rounded-2xl shadow-lg border border-slate-200/60 overflow-hidden backdrop-blur-sm;
|
||||
background-color: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Modern Form Styles */
|
||||
.form-group {
|
||||
@apply mb-6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-semibold text-slate-700 mb-3;
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
background-color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white transition-all duration-200;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
background-color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
@apply w-full px-4 py-3 border border-slate-300 rounded-xl shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all duration-200 bg-white resize-none;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
background-color: white;
|
||||
transition: all 0.2s;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
@apply w-5 h-5 text-blue-600 border-slate-300 rounded focus:ring-blue-500 focus:ring-2;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #2563eb;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.form-checkbox:focus {
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
}
|
||||
|
||||
.form-radio {
|
||||
@apply w-4 h-4 text-blue-600 border-slate-300 focus:ring-blue-500 focus:ring-2;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: #2563eb;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.form-radio:focus {
|
||||
ring: 2px;
|
||||
ring-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
@apply fixed inset-0 bg-black/50 backdrop-blur-sm flex justify-center items-center z-50 p-4;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply bg-white rounded-2xl shadow-2xl max-w-4xl w-full max-h-[90vh] overflow-y-auto;
|
||||
background-color: white;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
max-width: 56rem;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply bg-gradient-to-r from-blue-50 to-indigo-50 px-8 py-6 border-b border-slate-200/60;
|
||||
background: linear-gradient(to right, #eff6ff, #eef2ff);
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.6);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@apply p-8;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply bg-slate-50 px-8 py-6 border-t border-slate-200/60 flex justify-end space-x-4;
|
||||
background-color: #f8fafc;
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.6);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Form Actions */
|
||||
.form-actions {
|
||||
@apply flex justify-end space-x-4 pt-6 border-t border-slate-200/60 mt-8;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(226, 232, 240, 0.6);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Form Sections */
|
||||
.form-section {
|
||||
@apply bg-slate-50 rounded-xl p-6 mb-6 border border-slate-200/60;
|
||||
background-color: #f8fafc;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(226, 232, 240, 0.6);
|
||||
}
|
||||
|
||||
.form-section-header {
|
||||
@apply flex items-center justify-between mb-4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
@apply text-lg font-bold text-slate-800;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Radio Group */
|
||||
.radio-group {
|
||||
@apply flex gap-6 mt-3;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
@apply flex items-center cursor-pointer bg-white rounded-lg px-4 py-3 border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all duration-200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.radio-option:hover {
|
||||
border-color: #93c5fd;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.radio-option.selected {
|
||||
@apply border-blue-500 bg-blue-50 ring-2 ring-blue-200;
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
ring: 2px;
|
||||
ring-color: #bfdbfe;
|
||||
}
|
||||
|
||||
/* Checkbox Group */
|
||||
.checkbox-option {
|
||||
@apply flex items-center cursor-pointer bg-white rounded-lg px-4 py-3 border border-slate-200 hover:border-blue-300 hover:bg-blue-50 transition-all duration-200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.checkbox-option:hover {
|
||||
border-color: #93c5fd;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.checkbox-option.checked {
|
||||
@apply border-blue-500 bg-blue-50;
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Auth0Provider } from '@auth0/auth0-react';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
const domain = import.meta.env.VITE_AUTH0_DOMAIN;
|
||||
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
|
||||
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
|
||||
|
||||
if (!domain || !clientId) {
|
||||
throw new Error('Auth0 environment variables are missing. Please set VITE_AUTH0_DOMAIN and VITE_AUTH0_CLIENT_ID.');
|
||||
}
|
||||
|
||||
const authorizationParams: Record<string, string> = {
|
||||
redirect_uri: `${window.location.origin}/auth/callback`,
|
||||
scope: 'openid profile email'
|
||||
};
|
||||
|
||||
if (audience) {
|
||||
authorizationParams.audience = audience;
|
||||
}
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Auth0Provider
|
||||
domain={domain}
|
||||
clientId={clientId}
|
||||
authorizationParams={authorizationParams}
|
||||
cacheLocation="localstorage"
|
||||
useRefreshTokens={true}
|
||||
>
|
||||
<App />
|
||||
</Auth0Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiCall, API_BASE_URL } from '../config/api';
|
||||
import { apiCall } from '../config/api';
|
||||
import { generateTestVips, getTestOrganizations, generateVipSchedule } from '../utils/testVipData';
|
||||
|
||||
interface ApiKeys {
|
||||
aviationStackKey?: string;
|
||||
googleMapsKey?: string;
|
||||
twilioKey?: string;
|
||||
auth0Domain?: string;
|
||||
auth0ClientId?: string;
|
||||
auth0ClientSecret?: string;
|
||||
auth0Audience?: string;
|
||||
googleClientId?: string;
|
||||
googleClientSecret?: string;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
@@ -22,89 +20,92 @@ interface SystemSettings {
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [apiKeys, setApiKeys] = useState<ApiKeys>({});
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({});
|
||||
const [testResults, setTestResults] = useState<{ [key: string]: string }>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
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 [maskedKeyHints, setMaskedKeyHints] = useState<{ [key: string]: string }>({});
|
||||
const [testDataLoading, setTestDataLoading] = useState(false);
|
||||
const [testDataStatus, setTestDataStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const buildAuthHeaders = (includeJson = false) => {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
useEffect(() => {
|
||||
// Check if already authenticated
|
||||
const authStatus = sessionStorage.getItem('adminAuthenticated');
|
||||
if (authStatus === 'true') {
|
||||
setIsAuthenticated(true);
|
||||
loadSettings();
|
||||
}
|
||||
if (includeJson) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}, []);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/authenticate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: adminPassword })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsAuthenticated(true);
|
||||
sessionStorage.setItem('adminAuthenticated', 'true');
|
||||
loadSettings();
|
||||
} else {
|
||||
alert('Invalid admin password');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Authentication failed');
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await apiCall('/api/admin/settings', {
|
||||
headers: buildAuthHeaders()
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
headers: {
|
||||
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Track which keys are already saved (masked keys start with ***)
|
||||
const saved: { [key: string]: boolean } = {};
|
||||
const maskedHints: { [key: string]: string } = {};
|
||||
const cleanedApiKeys: ApiKeys = {};
|
||||
|
||||
if (data.apiKeys) {
|
||||
Object.entries(data.apiKeys).forEach(([key, value]) => {
|
||||
if (value && (value as string).startsWith('***')) {
|
||||
saved[key] = true;
|
||||
maskedHints[key] = value as string;
|
||||
} else if (value) {
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setSavedKeys(saved);
|
||||
setMaskedKeyHints(maskedHints);
|
||||
setApiKeys(cleanedApiKeys);
|
||||
setSystemSettings(data.systemSettings || {});
|
||||
} else if (response.status === 403) {
|
||||
setError('You need administrator access to view this page.');
|
||||
} else if (response.status === 401) {
|
||||
setError('Authentication required. Please sign in again.');
|
||||
} else {
|
||||
setError('Failed to load admin settings.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
setError('Failed to load admin settings.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
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 }));
|
||||
setMaskedKeyHints(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,9 +117,12 @@ const AdminDashboard: React.FC = () => {
|
||||
setTestResults(prev => ({ ...prev, [apiType]: 'Testing...' }));
|
||||
|
||||
try {
|
||||
const response = await apiCall(`/api/admin/test-api/${apiType}`, {
|
||||
const response = await fetch(`/api/admin/test-api/${apiType}`, {
|
||||
method: 'POST',
|
||||
headers: buildAuthHeaders(true),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: apiKeys[apiType as keyof ApiKeys]
|
||||
})
|
||||
@@ -146,13 +150,16 @@ const AdminDashboard: React.FC = () => {
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setSaving(true);
|
||||
setLoading(true);
|
||||
setSaveStatus(null);
|
||||
|
||||
try {
|
||||
const response = await apiCall('/api/admin/settings', {
|
||||
const response = await fetch('/api/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: buildAuthHeaders(true),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Admin-Auth': sessionStorage.getItem('adminAuthenticated') || ''
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKeys,
|
||||
systemSettings
|
||||
@@ -161,8 +168,16 @@ const AdminDashboard: React.FC = () => {
|
||||
|
||||
if (response.ok) {
|
||||
setSaveStatus('Settings saved successfully!');
|
||||
// Refresh the latest settings so saved states/labels stay accurate
|
||||
await loadSettings();
|
||||
// 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');
|
||||
@@ -170,14 +185,14 @@ const AdminDashboard: React.FC = () => {
|
||||
} catch (error) {
|
||||
setSaveStatus('Error saving settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
sessionStorage.removeItem('adminAuthenticated');
|
||||
setIsAuthenticated(false);
|
||||
navigate('/');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Test VIP functions
|
||||
@@ -337,29 +352,37 @@ const AdminDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 flex items-center space-x-4 border border-slate-200/60">
|
||||
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-lg font-medium text-slate-700">Loading admin settings...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex justify-center items-center">
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-xl border border-rose-200/70">
|
||||
<h2 className="text-2xl font-bold text-rose-700 mb-4">Admin access required</h2>
|
||||
<p className="text-slate-600 mb-6">{error}</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
Return to dashboard
|
||||
</button>
|
||||
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-md border border-slate-200/60">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-amber-500 to-orange-600 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<div className="w-8 h-8 bg-white rounded-full flex items-center justify-center">
|
||||
<div className="w-4 h-4 bg-amber-500 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800">Admin Login</h2>
|
||||
<p className="text-slate-600 mt-2">Enter your admin password to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
<div className="form-group">
|
||||
<label htmlFor="password" className="form-label">Admin Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className="form-input"
|
||||
placeholder="Enter admin password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary w-full">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -415,20 +438,24 @@ const AdminDashboard: React.FC = () => {
|
||||
<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>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={savedKeys.aviationStackKey && maskedKeyHints.aviationStackKey
|
||||
? `Saved (${maskedKeyHints.aviationStackKey.slice(-4)})`
|
||||
: 'Enter AviationStack API key'}
|
||||
value={apiKeys.aviationStackKey || ''}
|
||||
onChange={(e) => handleApiKeyChange('aviationStackKey', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
{savedKeys.aviationStackKey && maskedKeyHints.aviationStackKey && !apiKeys.aviationStackKey && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Currently saved key ends with {maskedKeyHints.aviationStackKey.slice(-4)}. Enter a new value to replace it.
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
@@ -457,91 +484,72 @@ const AdminDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth0 Credentials */}
|
||||
{/* Google OAuth Credentials */}
|
||||
<div className="form-section">
|
||||
<div className="form-section-header">
|
||||
<h3 className="form-section-title">Auth0 Configuration</h3>
|
||||
{(savedKeys.auth0Domain || savedKeys.auth0ClientId || savedKeys.auth0ClientSecret) && (
|
||||
<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">Auth0 Domain</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={savedKeys.auth0Domain && maskedKeyHints.auth0Domain
|
||||
? `Saved (${maskedKeyHints.auth0Domain.slice(-4)})`
|
||||
: 'e.g. dev-1234abcd.us.auth0.com'}
|
||||
value={apiKeys.auth0Domain || ''}
|
||||
onChange={(e) => handleApiKeyChange('auth0Domain', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Client ID</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={savedKeys.auth0ClientId && maskedKeyHints.auth0ClientId
|
||||
? `Saved (${maskedKeyHints.auth0ClientId.slice(-4)})`
|
||||
: 'Enter Auth0 application Client ID'}
|
||||
value={apiKeys.auth0ClientId || ''}
|
||||
onChange={(e) => handleApiKeyChange('auth0ClientId', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
{savedKeys.auth0ClientId && maskedKeyHints.auth0ClientId && !apiKeys.auth0ClientId && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Saved client ID ends with {maskedKeyHints.auth0ClientId.slice(-4)}. Provide a new ID to update it.
|
||||
</p>
|
||||
)}
|
||||
<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>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={savedKeys.auth0ClientSecret && maskedKeyHints.auth0ClientSecret
|
||||
? `Saved (${maskedKeyHints.auth0ClientSecret.slice(-4)})`
|
||||
: 'Enter Auth0 application Client Secret'}
|
||||
value={apiKeys.auth0ClientSecret || ''}
|
||||
onChange={(e) => handleApiKeyChange('auth0ClientSecret', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
{savedKeys.auth0ClientSecret && maskedKeyHints.auth0ClientSecret && !apiKeys.auth0ClientSecret && (
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Saved client secret ends with {maskedKeyHints.auth0ClientSecret.slice(-4)}. Provide a new secret to rotate it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">API Audience (Identifier)</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={apiKeys.auth0Audience || 'https://your-api-identifier'}
|
||||
value={apiKeys.auth0Audience || ''}
|
||||
onChange={(e) => handleApiKeyChange('auth0Audience', e.target.value)}
|
||||
className="form-input"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Create an API in Auth0 and use its Identifier here (e.g. https://vip-coordinator-api).
|
||||
</p>
|
||||
<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>Sign in to the Auth0 Dashboard</li>
|
||||
<li>Create a <strong>Single Page Application</strong> for the frontend</li>
|
||||
<li>Set Allowed Callback URL to <code>https://bsa.madeamess.online/auth/callback</code></li>
|
||||
<li>Set Allowed Logout URL to <code>https://bsa.madeamess.online/</code></li>
|
||||
<li>Set Allowed Web Origins to <code>https://bsa.madeamess.online</code></li>
|
||||
<li>Create an <strong>API</strong> in Auth0 for the backend and use its Identifier as the audience</li>
|
||||
<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: http://bsa.madeamess.online:3000/auth/google/callback</li>
|
||||
<li>Set authorized JavaScript origins: http://bsa.madeamess.online:5173</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -751,7 +759,7 @@ const AdminDashboard: React.FC = () => {
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary w-full mb-2"
|
||||
onClick={() => window.open(`${API_BASE_URL}/api-docs.html`, '_blank')}
|
||||
onClick={() => window.open('http://localhost:3000/api-docs.html', '_blank')}
|
||||
>
|
||||
Open API Documentation
|
||||
</button>
|
||||
@@ -803,9 +811,9 @@ const AdminDashboard: React.FC = () => {
|
||||
<button
|
||||
className="btn btn-success text-lg px-8 py-4"
|
||||
onClick={saveSettings}
|
||||
disabled={saving}
|
||||
disabled={loading}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save All Settings'}
|
||||
{loading ? 'Saving...' : 'Save All Settings'}
|
||||
</button>
|
||||
|
||||
{saveStatus && (
|
||||
|
||||
@@ -8,5 +8,4 @@ export default {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
],
|
||||
css: {
|
||||
postcss: './postcss.config.js',
|
||||
postcss: './postcss.config.mjs',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
@@ -45,10 +47,6 @@ export default defineConfig({
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth/dev-login': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user