Initial commit: SmoothSchedule multi-tenant scheduling platform
This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
557
frontend/src/App.tsx
Normal file
557
frontend/src/App.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Main App Component - Integrated with Real API
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useCurrentUser, useMasquerade, useLogout } from './hooks/useAuth';
|
||||
import { useCurrentBusiness } from './hooks/useBusiness';
|
||||
import { useUpdateBusiness } from './hooks/useBusiness';
|
||||
import { setCookie } from './utils/cookies';
|
||||
import { DevQuickLogin } from './components/DevQuickLogin';
|
||||
|
||||
// Import Login Page
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
|
||||
// Import layouts
|
||||
import BusinessLayout from './layouts/BusinessLayout';
|
||||
import PlatformLayout from './layouts/PlatformLayout';
|
||||
import CustomerLayout from './layouts/CustomerLayout';
|
||||
import MarketingLayout from './layouts/MarketingLayout';
|
||||
|
||||
// Import marketing pages
|
||||
import HomePage from './pages/marketing/HomePage';
|
||||
import FeaturesPage from './pages/marketing/FeaturesPage';
|
||||
import PricingPage from './pages/marketing/PricingPage';
|
||||
import AboutPage from './pages/marketing/AboutPage';
|
||||
import ContactPage from './pages/marketing/ContactPage';
|
||||
import SignupPage from './pages/marketing/SignupPage';
|
||||
|
||||
// Import pages
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Scheduler from './pages/Scheduler';
|
||||
import Customers from './pages/Customers';
|
||||
import Settings from './pages/Settings';
|
||||
import Payments from './pages/Payments';
|
||||
import Resources from './pages/Resources';
|
||||
import Services from './pages/Services';
|
||||
import Staff from './pages/Staff';
|
||||
import CustomerDashboard from './pages/customer/CustomerDashboard';
|
||||
import ResourceDashboard from './pages/resource/ResourceDashboard';
|
||||
import BookingPage from './pages/customer/BookingPage';
|
||||
import TrialExpired from './pages/TrialExpired';
|
||||
import Upgrade from './pages/Upgrade';
|
||||
|
||||
// Import platform pages
|
||||
import PlatformDashboard from './pages/platform/PlatformDashboard';
|
||||
import PlatformBusinesses from './pages/platform/PlatformBusinesses';
|
||||
import PlatformSupport from './pages/platform/PlatformSupport';
|
||||
import PlatformUsers from './pages/platform/PlatformUsers';
|
||||
import PlatformSettings from './pages/platform/PlatformSettings';
|
||||
import ProfileSettings from './pages/ProfileSettings';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import EmailVerificationRequired from './pages/EmailVerificationRequired';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 30000, // 30 seconds
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Loading Component
|
||||
*/
|
||||
const LoadingScreen: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Error Component
|
||||
*/
|
||||
const ErrorScreen: React.FC<{ error: Error }> = ({ error }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md">
|
||||
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">{t('common.error')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">{error.message}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{t('common.reload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* App Content - Handles routing based on auth state
|
||||
*/
|
||||
const AppContent: React.FC = () => {
|
||||
// Check for tokens in URL FIRST - before any queries execute
|
||||
// This handles login/masquerade redirects that pass tokens in the URL
|
||||
const [processingUrlTokens] = useState(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return !!(params.get('access_token') && params.get('refresh_token'));
|
||||
});
|
||||
|
||||
const { data: user, isLoading: userLoading, error: userError } = useCurrentUser();
|
||||
const { data: business, isLoading: businessLoading, error: businessError } = useCurrentBusiness();
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const updateBusinessMutation = useUpdateBusiness();
|
||||
const masqueradeMutation = useMasquerade();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
// Apply dark mode class
|
||||
React.useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', darkMode);
|
||||
}, [darkMode]);
|
||||
|
||||
// Handle tokens in URL (from login or masquerade redirect)
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('access_token');
|
||||
const refreshToken = params.get('refresh_token');
|
||||
|
||||
if (accessToken && refreshToken) {
|
||||
// Extract masquerade stack if present (for masquerade banner)
|
||||
const masqueradeStackParam = params.get('masquerade_stack');
|
||||
if (masqueradeStackParam) {
|
||||
try {
|
||||
const masqueradeStack = JSON.parse(decodeURIComponent(masqueradeStackParam));
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(masqueradeStack));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse masquerade stack', e);
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility, also check for original_user parameter
|
||||
const originalUserParam = params.get('original_user');
|
||||
if (originalUserParam && !masqueradeStackParam) {
|
||||
try {
|
||||
const originalUser = JSON.parse(decodeURIComponent(originalUserParam));
|
||||
// Convert old format to new stack format (single entry)
|
||||
const stack = [{
|
||||
user_id: originalUser.id,
|
||||
username: originalUser.username,
|
||||
role: originalUser.role,
|
||||
business_id: originalUser.business,
|
||||
business_subdomain: originalUser.business_subdomain,
|
||||
}];
|
||||
localStorage.setItem('masquerade_stack', JSON.stringify(stack));
|
||||
} catch (e) {
|
||||
console.error('Failed to parse original user', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Set cookies using helper (handles domain correctly)
|
||||
setCookie('access_token', accessToken, 7);
|
||||
setCookie('refresh_token', refreshToken, 7);
|
||||
|
||||
// Clear session cookie to prevent interference with JWT
|
||||
// (Django session cookie might take precedence over JWT)
|
||||
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=.lvh.me';
|
||||
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
|
||||
// Clean URL
|
||||
const newUrl = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
|
||||
// Force reload to ensure auth state is picked up
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Show loading while processing URL tokens (before reload happens)
|
||||
if (processingUrlTokens) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (userLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Helper to detect root domain (for marketing site)
|
||||
const isRootDomain = (): boolean => {
|
||||
const hostname = window.location.hostname;
|
||||
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
};
|
||||
|
||||
// Not authenticated - show public routes
|
||||
if (!user) {
|
||||
// On root domain, show marketing site
|
||||
if (isRootDomain()) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<MarketingLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/features" element={<FeaturesPage />} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// On business subdomain, show login
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (userError) {
|
||||
return <ErrorScreen error={userError as Error} />;
|
||||
}
|
||||
|
||||
// Handlers
|
||||
const toggleTheme = () => setDarkMode((prev) => !prev);
|
||||
const handleSignOut = () => {
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
const handleUpdateBusiness = (updates: Partial<any>) => {
|
||||
updateBusinessMutation.mutate(updates);
|
||||
};
|
||||
|
||||
const handleMasquerade = (targetUser: any) => {
|
||||
// Call the masquerade API with the target user's username
|
||||
// Fallback to email prefix if username is not available
|
||||
const username = targetUser.username || targetUser.email?.split('@')[0];
|
||||
if (!username) {
|
||||
console.error('Cannot masquerade: no username or email available', targetUser);
|
||||
return;
|
||||
}
|
||||
masqueradeMutation.mutate(username);
|
||||
};
|
||||
|
||||
// Helper to check access based on roles
|
||||
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
||||
|
||||
// Platform users (superuser, platform_manager, platform_support)
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
|
||||
if (isPlatformUser) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<PlatformLayout
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
toggleTheme={toggleTheme}
|
||||
onSignOut={handleSignOut}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(user.role === 'superuser' || user.role === 'platform_manager') && (
|
||||
<>
|
||||
<Route path="/platform/dashboard" element={<PlatformDashboard />} />
|
||||
<Route path="/platform/businesses" element={<PlatformBusinesses onMasquerade={handleMasquerade} />} />
|
||||
<Route path="/platform/users" element={<PlatformUsers onMasquerade={handleMasquerade} />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="/platform/support" element={<PlatformSupport />} />
|
||||
{user.role === 'superuser' && (
|
||||
<Route path="/platform/settings" element={<PlatformSettings />} />
|
||||
)}
|
||||
<Route path="/platform/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Navigate
|
||||
to={
|
||||
user.role === 'superuser' || user.role === 'platform_manager'
|
||||
? '/platform/dashboard'
|
||||
: '/platform/support'
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Customer users
|
||||
if (user.role === 'customer') {
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<CustomerLayout
|
||||
business={business || ({} as any)}
|
||||
user={user}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<CustomerDashboard />} />
|
||||
<Route path="/book" element={<BookingPage />} />
|
||||
<Route path="/payments" element={<Payments />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Business loading - show loading with user info
|
||||
if (businessLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Check if we're on root/platform domain without proper business context
|
||||
const currentHostname = window.location.hostname;
|
||||
const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me';
|
||||
|
||||
// Business error or no business found
|
||||
if (businessError || !business) {
|
||||
// If user is a business owner on root domain, redirect to their business
|
||||
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// If on root/platform and shouldn't be here, show appropriate message
|
||||
if (isRootOrPlatform) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<h2 className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{user.business_subdomain
|
||||
? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me`
|
||||
: 'Your account is not associated with a business. Please contact support.'}
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
{user.business_subdomain && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Go to Business
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<h2 className="text-2xl font-bold text-red-600 dark:text-red-400 mb-4">Business Not Found</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{businessError instanceof Error ? businessError.message : 'Unable to load business data. Please check your subdomain or try again.'}
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Business users (owner, manager, staff, resource)
|
||||
if (['owner', 'manager', 'staff', 'resource'].includes(user.role)) {
|
||||
// Check if email verification is required
|
||||
if (!user.email_verified) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/email-verification-required" element={<EmailVerificationRequired />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/email-verification-required" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if trial has expired
|
||||
const isTrialExpired = business.isTrialExpired || (business.status === 'Trial' && business.trialEnd && new Date(business.trialEnd) < new Date());
|
||||
|
||||
// Allowed routes when trial is expired
|
||||
const allowedWhenExpired = ['/trial-expired', '/upgrade', '/settings', '/profile'];
|
||||
const currentPath = window.location.pathname;
|
||||
const isOnAllowedRoute = allowedWhenExpired.some(route => currentPath.startsWith(route));
|
||||
|
||||
// If trial expired and not on allowed route, redirect to trial-expired
|
||||
if (isTrialExpired && !isOnAllowedRoute) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route
|
||||
path="/settings"
|
||||
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/trial-expired" />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/trial-expired" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
element={
|
||||
<BusinessLayout
|
||||
business={business}
|
||||
user={user}
|
||||
darkMode={darkMode}
|
||||
toggleTheme={toggleTheme}
|
||||
onSignOut={handleSignOut}
|
||||
updateBusiness={handleUpdateBusiness}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{/* Trial and Upgrade Routes */}
|
||||
<Route path="/trial-expired" element={<TrialExpired />} />
|
||||
<Route path="/upgrade" element={<Upgrade />} />
|
||||
|
||||
{/* Regular Routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
||||
/>
|
||||
<Route path="/scheduler" element={<Scheduler />} />
|
||||
<Route
|
||||
path="/customers"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Customers onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/services"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Services />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/resources"
|
||||
element={
|
||||
hasAccess(['owner', 'manager', 'staff']) ? (
|
||||
<Resources onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/staff"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Staff onMasquerade={handleMasquerade} effectiveUser={user} />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/payments"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? <Payments /> : <Navigate to="/" />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/messages"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<div className="p-8">
|
||||
<h1 className="text-2xl font-bold mb-4">Messages</h1>
|
||||
<p className="text-gray-600">Messages feature coming soon...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={hasAccess(['owner']) ? <Settings /> : <Navigate to="/" />}
|
||||
/>
|
||||
<Route path="/profile" element={<ProfileSettings />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return <Navigate to="/" />;
|
||||
};
|
||||
|
||||
/**
|
||||
* Main App Component
|
||||
*/
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router>
|
||||
<AppContent />
|
||||
</Router>
|
||||
<DevQuickLogin />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user