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:
2025-06-07 18:32:00 +02:00
parent aa900505b9
commit ae3702c3b1
32 changed files with 2120 additions and 1494 deletions

View File

@@ -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;
}
}

View File

@@ -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 />} />

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);
};

View File

@@ -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;
}
}

View File

@@ -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>,
)

View File

@@ -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 && (