254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
/**
|
|
* Login Page Component
|
|
* Professional login form connected to the API with visual improvements
|
|
*/
|
|
|
|
import React, { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLogin } from '../hooks/useAuth';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
|
|
import OAuthButtons from '../components/OAuthButtons';
|
|
import LanguageSelector from '../components/LanguageSelector';
|
|
import { DevQuickLogin } from '../components/DevQuickLogin';
|
|
import { AlertCircle, Loader2, User, Lock, ArrowRight } from 'lucide-react';
|
|
|
|
const LoginPage: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const [username, setUsername] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [error, setError] = useState('');
|
|
|
|
const navigate = useNavigate();
|
|
const loginMutation = useLogin();
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError('');
|
|
|
|
loginMutation.mutate(
|
|
{ username, password },
|
|
{
|
|
onSuccess: (data) => {
|
|
const user = data.user;
|
|
const currentHostname = window.location.hostname;
|
|
const currentPort = window.location.port;
|
|
|
|
// Check if we're on the root domain (no subdomain)
|
|
const isRootDomain = currentHostname === 'lvh.me' || currentHostname === 'localhost';
|
|
|
|
// Roles allowed to login at the root domain
|
|
const rootAllowedRoles = ['superuser', 'platform_manager', 'platform_support', 'owner'];
|
|
|
|
// If on root domain, only allow specific roles
|
|
if (isRootDomain && !rootAllowedRoles.includes(user.role)) {
|
|
setError(t('auth.loginAtSubdomain'));
|
|
return;
|
|
}
|
|
|
|
// Determine the correct subdomain based on user role
|
|
let targetSubdomain: string | null = null;
|
|
|
|
// Platform users (superuser, platform_manager, platform_support)
|
|
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
|
|
targetSubdomain = 'platform';
|
|
}
|
|
// Business users - redirect to their business subdomain
|
|
else if (user.business_subdomain) {
|
|
targetSubdomain = user.business_subdomain;
|
|
}
|
|
|
|
// Check if we need to redirect to a different subdomain
|
|
// Need to redirect if we have a target subdomain AND we're not already on it
|
|
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
|
|
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
|
|
|
|
if (needsRedirect) {
|
|
// Pass tokens in URL to ensure they're available immediately on the new subdomain
|
|
// This avoids race conditions where cookies might not be set before the page loads
|
|
const portStr = currentPort ? `:${currentPort}` : '';
|
|
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`;
|
|
return;
|
|
}
|
|
|
|
// Already on correct subdomain - navigate to dashboard
|
|
navigate('/');
|
|
},
|
|
onError: (err: any) => {
|
|
setError(err.response?.data?.error || t('auth.invalidCredentials'));
|
|
},
|
|
}
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex bg-white dark:bg-gray-900 transition-colors duration-200">
|
|
{/* Left Side - Image & Branding (Hidden on mobile) */}
|
|
<div className="hidden lg:flex lg:w-1/2 relative bg-gray-900 text-white overflow-hidden">
|
|
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1497215728101-856f4ea42174?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1950&q=80')] bg-cover bg-center opacity-40"></div>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-transparent to-gray-900/50"></div>
|
|
|
|
<div className="relative z-10 flex flex-col justify-between w-full p-12">
|
|
<div>
|
|
<div className="flex items-center gap-3 text-white/90">
|
|
<SmoothScheduleLogo className="w-8 h-8 text-brand-500" />
|
|
<span className="font-bold text-xl tracking-tight">Smooth Schedule</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-6 max-w-md">
|
|
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
|
{t('marketing.tagline')}
|
|
</h1>
|
|
<p className="text-lg text-gray-300">
|
|
{t('marketing.description')}
|
|
</p>
|
|
<div className="flex gap-2 pt-4">
|
|
<div className="h-1 w-12 bg-brand-500 rounded-full"></div>
|
|
<div className="h-1 w-4 bg-gray-600 rounded-full"></div>
|
|
<div className="h-1 w-4 bg-gray-600 rounded-full"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-500">
|
|
© {new Date().getFullYear()} {t('marketing.copyright')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Side - Login Form */}
|
|
<div className="flex-1 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8 lg:w-1/2 xl:px-24 bg-gray-50 dark:bg-gray-900">
|
|
<div className="mx-auto w-full max-w-sm lg:max-w-md">
|
|
<div className="text-center lg:text-left mb-10">
|
|
<div className="lg:hidden flex justify-center mb-6">
|
|
<SmoothScheduleLogo className="w-12 h-12 text-brand-600" />
|
|
</div>
|
|
<h2 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">
|
|
{t('auth.welcomeBack')}
|
|
</h2>
|
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
|
{t('auth.pleaseEnterDetails')}
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-6 rounded-lg bg-red-50 dark:bg-red-900/20 p-4 border border-red-100 dark:border-red-800/50 animate-in fade-in slide-in-from-top-2">
|
|
<div className="flex">
|
|
<div className="flex-shrink-0">
|
|
<AlertCircle className="h-5 w-5 text-red-500 dark:text-red-400" aria-hidden="true" />
|
|
</div>
|
|
<div className="ml-3">
|
|
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
|
|
{t('auth.authError')}
|
|
</h3>
|
|
<div className="mt-1 text-sm text-red-700 dark:text-red-300">
|
|
{error}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
|
<div className="space-y-4">
|
|
{/* Username */}
|
|
<div>
|
|
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('auth.username')}
|
|
</label>
|
|
<div className="relative rounded-md shadow-sm">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<User className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
|
</div>
|
|
<input
|
|
id="username"
|
|
name="username"
|
|
type="text"
|
|
autoComplete="username"
|
|
required
|
|
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
|
|
placeholder={t('auth.enterUsername')}
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Password */}
|
|
<div>
|
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('auth.password')}
|
|
</label>
|
|
<div className="relative rounded-md shadow-sm">
|
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
<Lock className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
|
</div>
|
|
<input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
className="focus:ring-brand-500 focus:border-brand-500 block w-full pl-10 sm:text-sm border-gray-300 dark:border-gray-700 rounded-lg py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 transition-colors"
|
|
placeholder="••••••••"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={loginMutation.isPending}
|
|
className="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-70 disabled:cursor-not-allowed transition-all duration-200 ease-in-out transform active:scale-[0.98]"
|
|
>
|
|
{loginMutation.isPending ? (
|
|
<span className="flex items-center gap-2">
|
|
<Loader2 className="animate-spin h-5 w-5" />
|
|
{t('auth.signingIn')}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-2">
|
|
{t('auth.signIn')}
|
|
<ArrowRight className="h-4 w-4" />
|
|
</span>
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
{/* OAuth Divider and Buttons */}
|
|
<div className="mt-6">
|
|
<div className="relative">
|
|
<div className="absolute inset-0 flex items-center">
|
|
<div className="w-full border-t border-gray-300 dark:border-gray-700"></div>
|
|
</div>
|
|
<div className="relative flex justify-center text-sm">
|
|
<span className="px-4 bg-gray-50 dark:bg-gray-900 text-gray-500 dark:text-gray-400">
|
|
{t('auth.orContinueWith')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6">
|
|
<OAuthButtons
|
|
disabled={loginMutation.isPending}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Language Selector */}
|
|
<div className="mt-8 flex justify-center">
|
|
<LanguageSelector />
|
|
</div>
|
|
|
|
{/* Dev Quick Login */}
|
|
<DevQuickLogin embedded />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LoginPage;
|