Fix jwt-token
This commit is contained in:
255
client/src/contexts/MultiTenantAuthContext.jsx
Normal file
255
client/src/contexts/MultiTenantAuthContext.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Multi-Tenant Authentication Context
|
||||
* Handles authentication for different tenants and providers
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
const MultiTenantAuthContext = createContext();
|
||||
|
||||
const authReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_LOADING':
|
||||
return { ...state, loading: action.payload };
|
||||
case 'SET_TENANT':
|
||||
return { ...state, tenant: action.payload };
|
||||
case 'SET_AUTH_CONFIG':
|
||||
return { ...state, authConfig: action.payload };
|
||||
case 'LOGIN_START':
|
||||
return { ...state, loading: true, error: null };
|
||||
case 'LOGIN_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
tenant: action.payload.tenant,
|
||||
isAuthenticated: true,
|
||||
loading: false,
|
||||
error: null
|
||||
};
|
||||
case 'LOGIN_FAILURE':
|
||||
return { ...state, loading: false, error: action.payload, isAuthenticated: false };
|
||||
case 'LOGOUT':
|
||||
return { ...state, user: null, token: null, isAuthenticated: false, loading: false };
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload, loading: false };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
user: null,
|
||||
token: localStorage.getItem('token'),
|
||||
tenant: null,
|
||||
authConfig: null,
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
error: null
|
||||
};
|
||||
|
||||
export const MultiTenantAuthProvider = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
// Determine tenant from URL or storage
|
||||
const determineTenant = () => {
|
||||
// Method 1: Subdomain
|
||||
const hostname = window.location.hostname;
|
||||
const subdomain = hostname.split('.')[0];
|
||||
if (subdomain && subdomain !== 'www' && subdomain !== 'localhost' && !hostname.includes('127.0.0.1')) {
|
||||
return subdomain;
|
||||
}
|
||||
|
||||
// Method 2: URL parameter
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('tenant')) {
|
||||
return urlParams.get('tenant');
|
||||
}
|
||||
|
||||
// Method 3: Local storage
|
||||
const storedTenant = localStorage.getItem('tenant');
|
||||
if (storedTenant) {
|
||||
return storedTenant;
|
||||
}
|
||||
|
||||
// Default tenant
|
||||
return 'default';
|
||||
};
|
||||
|
||||
// Initialize authentication
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
const tenantId = determineTenant();
|
||||
dispatch({ type: 'SET_TENANT', payload: tenantId });
|
||||
localStorage.setItem('tenant', tenantId);
|
||||
|
||||
// Get tenant auth configuration
|
||||
const authConfigResponse = await api.get(`/auth/config/${tenantId}`);
|
||||
dispatch({ type: 'SET_AUTH_CONFIG', payload: authConfigResponse.data.data });
|
||||
|
||||
// Check if user is already authenticated
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
// Validate token with current tenant context
|
||||
const profileResponse = await api.get('/users/profile');
|
||||
dispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
payload: {
|
||||
user: profileResponse.data.data,
|
||||
token: token,
|
||||
tenant: tenantId
|
||||
}
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
// Handle URL token (from SSO redirects)
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlToken = urlParams.get('token');
|
||||
const urlTenant = urlParams.get('tenant');
|
||||
|
||||
if (urlToken && urlTenant) {
|
||||
localStorage.setItem('token', urlToken);
|
||||
localStorage.setItem('tenant', urlTenant);
|
||||
|
||||
// Clean URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
// Reload to reinitialize with new token
|
||||
window.location.reload();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Login function
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
dispatch({ type: 'LOGIN_START' });
|
||||
|
||||
const tenantId = state.tenant || determineTenant();
|
||||
|
||||
// For local authentication, use existing login endpoint
|
||||
if (state.authConfig?.provider === 'local' || state.authConfig?.provider === 'ldap') {
|
||||
const response = await api.post('/auth/login', {
|
||||
...credentials,
|
||||
tenant: tenantId
|
||||
});
|
||||
|
||||
const { user, token } = response.data.data;
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('tenant', tenantId);
|
||||
|
||||
dispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
payload: { user, token, tenant: tenantId }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
// For SSO providers, redirect to SSO endpoint
|
||||
const ssoUrl = getSSOLoginUrl(state.authConfig, tenantId);
|
||||
window.location.href = ssoUrl;
|
||||
return { success: true, redirect: true };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || 'Login failed';
|
||||
dispatch({ type: 'LOGIN_FAILURE', payload: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
// SSO Login
|
||||
const loginWithSSO = () => {
|
||||
const tenantId = state.tenant || determineTenant();
|
||||
const ssoUrl = getSSOLoginUrl(state.authConfig, tenantId);
|
||||
window.location.href = ssoUrl;
|
||||
};
|
||||
|
||||
// Get SSO login URL
|
||||
const getSSOLoginUrl = (authConfig, tenantId) => {
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
|
||||
switch (authConfig?.provider) {
|
||||
case 'saml':
|
||||
return `/auth/saml/${tenantId}/login?returnUrl=${returnUrl}`;
|
||||
case 'oauth':
|
||||
return `/auth/oauth/${tenantId}/login?returnUrl=${returnUrl}`;
|
||||
default:
|
||||
return `/login?tenant=${tenantId}`;
|
||||
}
|
||||
};
|
||||
|
||||
// Logout
|
||||
const logout = async () => {
|
||||
try {
|
||||
const tenantId = state.tenant;
|
||||
|
||||
// Call logout endpoint
|
||||
const response = await api.post('/auth/logout');
|
||||
|
||||
// Clear local storage
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('tenant');
|
||||
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
|
||||
// Handle provider-specific logout
|
||||
if (response.data.logout_url) {
|
||||
window.location.href = response.data.logout_url;
|
||||
} else {
|
||||
window.location.href = `/login?tenant=${tenantId}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Force logout even if API call fails
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('tenant');
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
window.location.href = '/login';
|
||||
}
|
||||
};
|
||||
|
||||
// Switch tenant
|
||||
const switchTenant = (newTenantId) => {
|
||||
localStorage.setItem('tenant', newTenantId);
|
||||
localStorage.removeItem('token'); // Clear token when switching tenants
|
||||
window.location.href = `/login?tenant=${newTenantId}`;
|
||||
};
|
||||
|
||||
const value = {
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
loginWithSSO,
|
||||
switchTenant,
|
||||
getSSOLoginUrl
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiTenantAuthContext.Provider value={value}>
|
||||
{children}
|
||||
</MultiTenantAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMultiTenantAuth = () => {
|
||||
const context = useContext(MultiTenantAuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useMultiTenantAuth must be used within a MultiTenantAuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default MultiTenantAuthContext;
|
||||
305
client/src/pages/MultiTenantLogin.jsx
Normal file
305
client/src/pages/MultiTenantLogin.jsx
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Enhanced Login Component with Multi-Tenant Support
|
||||
* Handles different authentication providers based on tenant configuration
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||
import { useMultiTenantAuth } from '../contexts/MultiTenantAuthContext';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const MultiTenantLogin = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [credentials, setCredentials] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const {
|
||||
login,
|
||||
loginWithSSO,
|
||||
loading,
|
||||
isAuthenticated,
|
||||
tenant,
|
||||
authConfig,
|
||||
error
|
||||
} = useMultiTenantAuth();
|
||||
|
||||
const tenantParam = searchParams.get('tenant');
|
||||
const errorParam = searchParams.get('error');
|
||||
|
||||
useEffect(() => {
|
||||
// Handle SSO errors from URL parameters
|
||||
if (errorParam) {
|
||||
const errorMessages = {
|
||||
'auth_failed': 'Authentication failed. Please try again.',
|
||||
'auth_cancelled': 'Authentication was cancelled.',
|
||||
'user_creation_failed': 'Failed to create user account.',
|
||||
'saml_error': 'SAML authentication error.',
|
||||
'oauth_error': 'OAuth authentication error.'
|
||||
};
|
||||
|
||||
toast.error(errorMessages[errorParam] || 'Authentication error occurred.');
|
||||
}
|
||||
}, [errorParam]);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!credentials.username || !credentials.password) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(credentials);
|
||||
|
||||
if (result.success && !result.redirect) {
|
||||
toast.success('Login successful!');
|
||||
} else if (!result.success) {
|
||||
toast.error(result.error || 'Login failed');
|
||||
}
|
||||
// If redirect is true, the page will redirect to SSO provider
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setCredentials({
|
||||
...credentials,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
loginWithSSO();
|
||||
};
|
||||
|
||||
const renderAuthenticationMethod = () => {
|
||||
if (!authConfig) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-600">Loading authentication configuration...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { provider, features } = authConfig;
|
||||
|
||||
// SSO-only providers
|
||||
if (provider === 'saml' || provider === 'oauth') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Single Sign-On Required
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Please sign in using your organization's identity provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSSOLogin}
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : (
|
||||
<>
|
||||
{provider === 'saml' ? '🔐 Sign in with SAML' : '🔑 Sign in with OAuth'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{tenant && tenant !== 'default' && (
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Organization: <span className="font-medium">{tenant}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Local or LDAP authentication with form
|
||||
return (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Username or Email
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username or Email"
|
||||
value={credentials.username}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={credentials.password}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show additional SSO option if available */}
|
||||
{features?.sso_login && (
|
||||
<div className="mt-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSSOLogin}
|
||||
className="mt-3 w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
🔑 Sign in with SSO
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo credentials for local auth */}
|
||||
{provider === 'local' && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Demo credentials: <br />
|
||||
Username: <code className="bg-gray-100 px-1 rounded">admin</code> <br />
|
||||
Password: <code className="bg-gray-100 px-1 rounded">admin123</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LDAP info */}
|
||||
{provider === 'ldap' && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Use your Active Directory credentials
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
|
||||
<svg className="h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Drone Detection System
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
</p>
|
||||
|
||||
{/* Tenant indicator */}
|
||||
{tenant && tenant !== 'default' && (
|
||||
<div className="mt-2 text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
||||
Organization: {tenant}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="text-sm text-red-700">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderAuthenticationMethod()}
|
||||
|
||||
{/* Tenant switcher for development */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<div className="text-center">
|
||||
<details className="text-xs text-gray-500">
|
||||
<summary className="cursor-pointer">Development Options</summary>
|
||||
<div className="mt-2 space-y-1">
|
||||
<button
|
||||
onClick={() => window.location.href = '/login?tenant=default'}
|
||||
className="block w-full text-left hover:text-primary-600"
|
||||
>
|
||||
Switch to Default Tenant
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = '/login?tenant=demo-saml'}
|
||||
className="block w-full text-left hover:text-primary-600"
|
||||
>
|
||||
Switch to SAML Demo
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.href = '/login?tenant=demo-oauth'}
|
||||
className="block w-full text-left hover:text-primary-600"
|
||||
>
|
||||
Switch to OAuth Demo
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiTenantLogin;
|
||||
Reference in New Issue
Block a user