Compare commits
2 Commits
a2f74ee769
...
65faaae864
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65faaae864 | ||
|
|
dbe91ec2ff |
@@ -5,7 +5,7 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
export interface LoginCredentials {
|
export interface LoginCredentials {
|
||||||
username: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
|
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
|
||||||
|
|
||||||
export interface TestUser {
|
export interface TestUser {
|
||||||
username: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
role: string;
|
role: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -14,56 +14,56 @@ export interface TestUser {
|
|||||||
|
|
||||||
const testUsers: TestUser[] = [
|
const testUsers: TestUser[] = [
|
||||||
{
|
{
|
||||||
username: 'superuser',
|
email: 'superuser@platform.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'SUPERUSER',
|
role: 'SUPERUSER',
|
||||||
label: 'Platform Superuser',
|
label: 'Platform Superuser',
|
||||||
color: 'bg-purple-600 hover:bg-purple-700',
|
color: 'bg-purple-600 hover:bg-purple-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'platform_manager',
|
email: 'manager@platform.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'PLATFORM_MANAGER',
|
role: 'PLATFORM_MANAGER',
|
||||||
label: 'Platform Manager',
|
label: 'Platform Manager',
|
||||||
color: 'bg-blue-600 hover:bg-blue-700',
|
color: 'bg-blue-600 hover:bg-blue-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'platform_sales',
|
email: 'sales@platform.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'PLATFORM_SALES',
|
role: 'PLATFORM_SALES',
|
||||||
label: 'Platform Sales',
|
label: 'Platform Sales',
|
||||||
color: 'bg-green-600 hover:bg-green-700',
|
color: 'bg-green-600 hover:bg-green-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'platform_support',
|
email: 'support@platform.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'PLATFORM_SUPPORT',
|
role: 'PLATFORM_SUPPORT',
|
||||||
label: 'Platform Support',
|
label: 'Platform Support',
|
||||||
color: 'bg-yellow-600 hover:bg-yellow-700',
|
color: 'bg-yellow-600 hover:bg-yellow-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'tenant_owner',
|
email: 'owner@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'TENANT_OWNER',
|
role: 'TENANT_OWNER',
|
||||||
label: 'Business Owner',
|
label: 'Business Owner',
|
||||||
color: 'bg-indigo-600 hover:bg-indigo-700',
|
color: 'bg-indigo-600 hover:bg-indigo-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'tenant_manager',
|
email: 'manager@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'TENANT_MANAGER',
|
role: 'TENANT_MANAGER',
|
||||||
label: 'Business Manager',
|
label: 'Business Manager',
|
||||||
color: 'bg-pink-600 hover:bg-pink-700',
|
color: 'bg-pink-600 hover:bg-pink-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'tenant_staff',
|
email: 'staff@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'TENANT_STAFF',
|
role: 'TENANT_STAFF',
|
||||||
label: 'Staff Member',
|
label: 'Staff Member',
|
||||||
color: 'bg-teal-600 hover:bg-teal-700',
|
color: 'bg-teal-600 hover:bg-teal-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
username: 'customer',
|
email: 'customer@demo.com',
|
||||||
password: 'test123',
|
password: 'test123',
|
||||||
role: 'CUSTOMER',
|
role: 'CUSTOMER',
|
||||||
label: 'Customer',
|
label: 'Customer',
|
||||||
@@ -86,11 +86,11 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleQuickLogin = async (user: TestUser) => {
|
const handleQuickLogin = async (user: TestUser) => {
|
||||||
setLoading(user.username);
|
setLoading(user.email);
|
||||||
try {
|
try {
|
||||||
// Call token auth API
|
// Call token auth API - username field contains email since we use email as username
|
||||||
const response = await apiClient.post('/auth-token/', {
|
const response = await apiClient.post('/auth-token/', {
|
||||||
username: user.username,
|
username: user.email,
|
||||||
password: user.password,
|
password: user.password,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,12 +176,12 @@ export function DevQuickLogin({ embedded = false }: DevQuickLoginProps) {
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{testUsers.map((user) => (
|
{testUsers.map((user) => (
|
||||||
<button
|
<button
|
||||||
key={user.username}
|
key={user.email}
|
||||||
onClick={() => handleQuickLogin(user)}
|
onClick={() => handleQuickLogin(user)}
|
||||||
disabled={loading !== null}
|
disabled={loading !== null}
|
||||||
className={`${user.color} text-white px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
|
className={`${user.color} text-white px-3 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
>
|
>
|
||||||
{loading === user.username ? (
|
{loading === user.email ? (
|
||||||
<span className="flex items-center justify-center">
|
<span className="flex items-center justify-center">
|
||||||
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
<svg className="animate-spin h-4 w-4 mr-2" viewBox="0 0 24 24">
|
||||||
<circle
|
<circle
|
||||||
|
|||||||
@@ -31,12 +31,12 @@
|
|||||||
"signIn": "Anmelden",
|
"signIn": "Anmelden",
|
||||||
"signOut": "Abmelden",
|
"signOut": "Abmelden",
|
||||||
"signingIn": "Anmeldung läuft...",
|
"signingIn": "Anmeldung läuft...",
|
||||||
"username": "Benutzername",
|
"email": "E-Mail",
|
||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
"enterUsername": "Geben Sie Ihren Benutzernamen ein",
|
"enterEmail": "Geben Sie Ihre E-Mail-Adresse ein",
|
||||||
"enterPassword": "Geben Sie Ihr Passwort ein",
|
"enterPassword": "Geben Sie Ihr Passwort ein",
|
||||||
"welcomeBack": "Willkommen zurück",
|
"welcomeBack": "Willkommen zurück",
|
||||||
"pleaseEnterDetails": "Bitte geben Sie Ihre Daten ein, um sich anzumelden.",
|
"pleaseEnterDetails": "Bitte geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein, um sich anzumelden.",
|
||||||
"authError": "Authentifizierungsfehler",
|
"authError": "Authentifizierungsfehler",
|
||||||
"invalidCredentials": "Ungültige Anmeldedaten",
|
"invalidCredentials": "Ungültige Anmeldedaten",
|
||||||
"orContinueWith": "Oder fortfahren mit",
|
"orContinueWith": "Oder fortfahren mit",
|
||||||
|
|||||||
@@ -56,12 +56,12 @@
|
|||||||
"signIn": "Sign in",
|
"signIn": "Sign in",
|
||||||
"signOut": "Sign Out",
|
"signOut": "Sign Out",
|
||||||
"signingIn": "Signing in...",
|
"signingIn": "Signing in...",
|
||||||
"username": "Username",
|
"email": "Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"enterUsername": "Enter your username",
|
"enterEmail": "Enter your email",
|
||||||
"enterPassword": "Enter your password",
|
"enterPassword": "Enter your password",
|
||||||
"welcomeBack": "Welcome back",
|
"welcomeBack": "Welcome back",
|
||||||
"pleaseEnterDetails": "Please enter your details to sign in.",
|
"pleaseEnterDetails": "Please enter your email and password to sign in.",
|
||||||
"authError": "Authentication Error",
|
"authError": "Authentication Error",
|
||||||
"invalidCredentials": "Invalid credentials",
|
"invalidCredentials": "Invalid credentials",
|
||||||
"orContinueWith": "Or continue with",
|
"orContinueWith": "Or continue with",
|
||||||
|
|||||||
@@ -31,12 +31,12 @@
|
|||||||
"signIn": "Iniciar sesión",
|
"signIn": "Iniciar sesión",
|
||||||
"signOut": "Cerrar Sesión",
|
"signOut": "Cerrar Sesión",
|
||||||
"signingIn": "Iniciando sesión...",
|
"signingIn": "Iniciando sesión...",
|
||||||
"username": "Nombre de usuario",
|
"email": "Correo electrónico",
|
||||||
"password": "Contraseña",
|
"password": "Contraseña",
|
||||||
"enterUsername": "Ingresa tu nombre de usuario",
|
"enterEmail": "Ingresa tu correo electrónico",
|
||||||
"enterPassword": "Ingresa tu contraseña",
|
"enterPassword": "Ingresa tu contraseña",
|
||||||
"welcomeBack": "Bienvenido de nuevo",
|
"welcomeBack": "Bienvenido de nuevo",
|
||||||
"pleaseEnterDetails": "Por favor ingresa tus datos para iniciar sesión.",
|
"pleaseEnterDetails": "Por favor ingresa tu correo electrónico y contraseña para iniciar sesión.",
|
||||||
"authError": "Error de Autenticación",
|
"authError": "Error de Autenticación",
|
||||||
"invalidCredentials": "Credenciales inválidas",
|
"invalidCredentials": "Credenciales inválidas",
|
||||||
"orContinueWith": "O continuar con",
|
"orContinueWith": "O continuar con",
|
||||||
|
|||||||
@@ -31,12 +31,12 @@
|
|||||||
"signIn": "Se connecter",
|
"signIn": "Se connecter",
|
||||||
"signOut": "Déconnexion",
|
"signOut": "Déconnexion",
|
||||||
"signingIn": "Connexion en cours...",
|
"signingIn": "Connexion en cours...",
|
||||||
"username": "Nom d'utilisateur",
|
"email": "E-mail",
|
||||||
"password": "Mot de passe",
|
"password": "Mot de passe",
|
||||||
"enterUsername": "Entrez votre nom d'utilisateur",
|
"enterEmail": "Entrez votre e-mail",
|
||||||
"enterPassword": "Entrez votre mot de passe",
|
"enterPassword": "Entrez votre mot de passe",
|
||||||
"welcomeBack": "Bon retour",
|
"welcomeBack": "Bon retour",
|
||||||
"pleaseEnterDetails": "Veuillez entrer vos informations pour vous connecter.",
|
"pleaseEnterDetails": "Veuillez entrer votre e-mail et mot de passe pour vous connecter.",
|
||||||
"authError": "Erreur d'Authentification",
|
"authError": "Erreur d'Authentification",
|
||||||
"invalidCredentials": "Identifiants invalides",
|
"invalidCredentials": "Identifiants invalides",
|
||||||
"orContinueWith": "Ou continuer avec",
|
"orContinueWith": "Ou continuer avec",
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import SmoothScheduleLogo from '../components/SmoothScheduleLogo';
|
|||||||
import OAuthButtons from '../components/OAuthButtons';
|
import OAuthButtons from '../components/OAuthButtons';
|
||||||
import LanguageSelector from '../components/LanguageSelector';
|
import LanguageSelector from '../components/LanguageSelector';
|
||||||
import { DevQuickLogin } from '../components/DevQuickLogin';
|
import { DevQuickLogin } from '../components/DevQuickLogin';
|
||||||
import { AlertCircle, Loader2, User, Lock, ArrowRight } from 'lucide-react';
|
import { AlertCircle, Loader2, Mail, Lock, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
const LoginPage: React.FC = () => {
|
const LoginPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [username, setUsername] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ const LoginPage: React.FC = () => {
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
loginMutation.mutate(
|
loginMutation.mutate(
|
||||||
{ username, password },
|
{ email, password },
|
||||||
{
|
{
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
// Check if MFA is required
|
// Check if MFA is required
|
||||||
@@ -202,25 +202,25 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Username */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{t('auth.username')}
|
{t('auth.email')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative rounded-md shadow-sm">
|
<div className="relative rounded-md shadow-sm">
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<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" />
|
<Mail className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="email"
|
||||||
name="username"
|
name="email"
|
||||||
type="text"
|
type="email"
|
||||||
autoComplete="username"
|
autoComplete="email"
|
||||||
required
|
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"
|
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')}
|
placeholder={t('auth.enterEmail')}
|
||||||
value={username}
|
value={email}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,60 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useOutletContext, Link } from 'react-router-dom';
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
import { User, Business, Appointment } from '../../types';
|
import { User, Business, Appointment } from '../../types';
|
||||||
import { APPOINTMENTS, SERVICES } from '../../mockData';
|
import { useAppointments, useUpdateAppointment } from '../../hooks/useAppointments';
|
||||||
import { Calendar, Clock, MapPin, AlertTriangle } from 'lucide-react';
|
import { useServices } from '../../hooks/useServices';
|
||||||
|
import { Calendar, Clock, MapPin, AlertTriangle, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, business }) => {
|
const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, business }) => {
|
||||||
const [appointments, setAppointments] = useState(APPOINTMENTS);
|
|
||||||
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
||||||
|
|
||||||
const myAppointments = useMemo(() => appointments.filter(apt => apt.customerName.includes(user.name.split(' ')[0])).sort((a, b) => b.startTime.getTime() - a.startTime.getTime()), [user.name, appointments]);
|
// Fetch appointments from API - backend filters for current customer
|
||||||
|
const { data: appointments = [], isLoading, error } = useAppointments();
|
||||||
const upcomingAppointments = myAppointments.filter(apt => new Date(apt.startTime) >= new Date() && apt.status !== 'CANCELLED');
|
const { data: services = [] } = useServices();
|
||||||
const pastAppointments = myAppointments.filter(apt => new Date(apt.startTime) < new Date() || apt.status === 'CANCELLED');
|
const updateAppointment = useUpdateAppointment();
|
||||||
|
|
||||||
const handleCancel = (appointment: Appointment) => {
|
// Sort appointments by start time (newest first)
|
||||||
|
const sortedAppointments = useMemo(() =>
|
||||||
|
[...appointments].sort((a, b) => b.startTime.getTime() - a.startTime.getTime()),
|
||||||
|
[appointments]
|
||||||
|
);
|
||||||
|
|
||||||
|
const upcomingAppointments = sortedAppointments.filter(apt => new Date(apt.startTime) >= new Date() && apt.status !== 'CANCELLED');
|
||||||
|
const pastAppointments = sortedAppointments.filter(apt => new Date(apt.startTime) < new Date() || apt.status === 'CANCELLED');
|
||||||
|
|
||||||
|
const handleCancel = async (appointment: Appointment) => {
|
||||||
const hoursBefore = (new Date(appointment.startTime).getTime() - new Date().getTime()) / 3600000;
|
const hoursBefore = (new Date(appointment.startTime).getTime() - new Date().getTime()) / 3600000;
|
||||||
if (hoursBefore < business.cancellationWindowHours) {
|
if (hoursBefore < business.cancellationWindowHours) {
|
||||||
const service = SERVICES.find(s => s.id === appointment.serviceId);
|
const service = services.find(s => s.id === appointment.serviceId);
|
||||||
const fee = service ? (service.price * (business.lateCancellationFeePercent / 100)).toFixed(2) : 'a fee';
|
const fee = service ? (service.price * (business.lateCancellationFeePercent / 100)).toFixed(2) : 'a fee';
|
||||||
if (!window.confirm(`Cancelling within the ${business.cancellationWindowHours}-hour window may incur a fee of $${fee}. Are you sure?`)) return;
|
if (!window.confirm(`Cancelling within the ${business.cancellationWindowHours}-hour window may incur a fee of $${fee}. Are you sure?`)) return;
|
||||||
} else {
|
} else {
|
||||||
if (!window.confirm("Are you sure you want to cancel this appointment?")) return;
|
if (!window.confirm("Are you sure you want to cancel this appointment?")) return;
|
||||||
}
|
}
|
||||||
setAppointments(prev => prev.map(apt => apt.id === appointment.id ? {...apt, status: 'CANCELLED'} : apt));
|
try {
|
||||||
|
await updateAppointment.mutateAsync({ id: appointment.id, updates: { status: 'CANCELLED' } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to cancel appointment:', err);
|
||||||
|
alert('Failed to cancel appointment. Please try again.');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="mt-8 text-center py-8 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||||
|
<p className="text-red-600 dark:text-red-400">Failed to load appointments. Please try again later.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold mb-4">Your Appointments</h2>
|
<h2 className="text-xl font-bold mb-4">Your Appointments</h2>
|
||||||
@@ -34,14 +64,22 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).map(apt => {
|
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).map(apt => {
|
||||||
const service = SERVICES.find(s => s.id === apt.serviceId);
|
const service = services.find(s => s.id === apt.serviceId);
|
||||||
return (
|
return (
|
||||||
<div key={apt.id} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div key={apt.id} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{service?.name}</h3>
|
<h3 className="font-semibold">{service?.name || 'Appointment'}</h3>
|
||||||
<p className="text-sm text-gray-500">{new Date(apt.startTime).toLocaleString()}</p>
|
<p className="text-sm text-gray-500">{new Date(apt.startTime).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === 'upcoming' && <button onClick={() => handleCancel(apt)} className="text-sm font-medium text-red-600 hover:underline">Cancel</button>}
|
{activeTab === 'upcoming' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancel(apt)}
|
||||||
|
disabled={updateAppointment.isPending}
|
||||||
|
className="text-sm font-medium text-red-600 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateAppointment.isPending ? 'Cancelling...' : 'Cancel'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -834,6 +834,36 @@ class TenantViewSet(viewsets.ModelViewSet):
|
|||||||
status=status.HTTP_403_FORBIDDEN
|
status=status.HTTP_403_FORBIDDEN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# First, unlink staff_resources from users WITHIN the tenant's schema
|
||||||
|
# This prevents cross-schema SET_NULL cascade issues when users are deleted
|
||||||
|
with schema_context(tenant.schema_name):
|
||||||
|
from schedule.models import Resource
|
||||||
|
# Unlink all resources from users (set user_id to NULL)
|
||||||
|
Resource.objects.filter(user__isnull=False).update(user=None)
|
||||||
|
|
||||||
|
# Delete all users associated with this tenant
|
||||||
|
# Use _raw_delete to avoid triggering cascades
|
||||||
|
# (cascades would try to access tenant schema tables which may not exist from public)
|
||||||
|
user_ids = list(User.objects.filter(tenant=tenant).values_list('id', flat=True))
|
||||||
|
if user_ids:
|
||||||
|
# Delete related objects that are in the public schema first
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
Token.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
|
||||||
|
# Delete MFA-related objects
|
||||||
|
from smoothschedule.users.models import EmailVerificationToken, MFAVerificationCode, TrustedDevice
|
||||||
|
EmailVerificationToken.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
MFAVerificationCode.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
TrustedDevice.objects.filter(user_id__in=user_ids).delete()
|
||||||
|
|
||||||
|
# Now delete users using raw SQL to skip Django's cascade
|
||||||
|
from django.db import connection
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
"DELETE FROM users_user WHERE id = ANY(%s)",
|
||||||
|
[user_ids]
|
||||||
|
)
|
||||||
|
|
||||||
# Delete the tenant (this will drop the schema due to django-tenants)
|
# Delete the tenant (this will drop the schema due to django-tenants)
|
||||||
tenant.delete()
|
tenant.delete()
|
||||||
|
|
||||||
|
|||||||
@@ -61,21 +61,15 @@ class CustomerSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
"""Create a customer with auto-generated username"""
|
"""Create a customer with email as username"""
|
||||||
import uuid
|
import uuid
|
||||||
email = validated_data.get('email', '')
|
email = validated_data.get('email', '')
|
||||||
# Generate username from email or use a UUID if no email
|
# Use email as username, or generate a UUID-based username if no email
|
||||||
if email:
|
if email:
|
||||||
base_username = email.split('@')[0]
|
validated_data['username'] = email.lower()
|
||||||
username = base_username
|
|
||||||
counter = 1
|
|
||||||
while User.objects.filter(username=username).exists():
|
|
||||||
username = f"{base_username}{counter}"
|
|
||||||
counter += 1
|
|
||||||
else:
|
else:
|
||||||
username = f"customer_{uuid.uuid4().hex[:8]}"
|
validated_data['username'] = f"customer_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
validated_data['username'] = username
|
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
def get_name(self, obj):
|
def get_name(self, obj):
|
||||||
@@ -192,14 +186,36 @@ class ResourceSerializer(serializers.ModelSerializer):
|
|||||||
ret['user_id'] = instance.user_id
|
ret['user_id'] = instance.user_id
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
def _get_valid_user(self, user_id):
|
||||||
|
"""
|
||||||
|
Get a user by ID, validating they belong to the same tenant as the request user.
|
||||||
|
Returns None if user doesn't exist or doesn't belong to the same tenant.
|
||||||
|
|
||||||
|
CRITICAL: This prevents cross-tenant user linking (multi-tenancy security).
|
||||||
|
"""
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
request = self.context.get('request')
|
||||||
|
if not request or not request.user.is_authenticated:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(id=user_id)
|
||||||
|
# Verify user belongs to the same tenant
|
||||||
|
if request.user.tenant and user.tenant == request.user.tenant:
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
"""Handle user_id when creating a resource"""
|
"""Handle user_id when creating a resource"""
|
||||||
user_id = validated_data.pop('user_id', None)
|
user_id = validated_data.pop('user_id', None)
|
||||||
if user_id:
|
if user_id:
|
||||||
try:
|
user = self._get_valid_user(user_id)
|
||||||
validated_data['user'] = User.objects.get(id=user_id)
|
if user:
|
||||||
except User.DoesNotExist:
|
validated_data['user'] = user
|
||||||
pass
|
|
||||||
return super().create(validated_data)
|
return super().create(validated_data)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
@@ -207,10 +223,9 @@ class ResourceSerializer(serializers.ModelSerializer):
|
|||||||
user_id = validated_data.pop('user_id', None)
|
user_id = validated_data.pop('user_id', None)
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
if user_id:
|
if user_id:
|
||||||
try:
|
user = self._get_valid_user(user_id)
|
||||||
validated_data['user'] = User.objects.get(id=user_id)
|
if user:
|
||||||
except User.DoesNotExist:
|
validated_data['user'] = user
|
||||||
pass
|
|
||||||
else:
|
else:
|
||||||
validated_data['user'] = None
|
validated_data['user'] = None
|
||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
@@ -290,12 +305,17 @@ class EventSerializer(serializers.ModelSerializer):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text="List of Staff (User) IDs to assign"
|
help_text="List of Staff (User) IDs to assign"
|
||||||
)
|
)
|
||||||
|
customer = serializers.IntegerField(
|
||||||
|
write_only=True,
|
||||||
|
required=False,
|
||||||
|
help_text="Customer (User) ID to assign"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Event
|
model = Event
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'title', 'start_time', 'end_time', 'status', 'notes',
|
'id', 'title', 'start_time', 'end_time', 'status', 'notes',
|
||||||
'duration_minutes', 'participants', 'resource_ids', 'staff_ids',
|
'duration_minutes', 'participants', 'resource_ids', 'staff_ids', 'customer',
|
||||||
'resource_id', 'customer_id', 'service_id', 'customer_name', 'service_name', 'is_paid',
|
'resource_id', 'customer_id', 'service_id', 'customer_name', 'service_name', 'is_paid',
|
||||||
'created_at', 'updated_at', 'created_by',
|
'created_at', 'updated_at', 'created_by',
|
||||||
]
|
]
|
||||||
@@ -432,17 +452,18 @@ class EventSerializer(serializers.ModelSerializer):
|
|||||||
"""Create event and associated participants"""
|
"""Create event and associated participants"""
|
||||||
resource_ids = validated_data.pop('resource_ids', [])
|
resource_ids = validated_data.pop('resource_ids', [])
|
||||||
staff_ids = validated_data.pop('staff_ids', [])
|
staff_ids = validated_data.pop('staff_ids', [])
|
||||||
|
customer_id = validated_data.pop('customer', None)
|
||||||
|
|
||||||
# Set created_by from request user (only if authenticated)
|
# Set created_by from request user (only if authenticated)
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
||||||
validated_data['created_by'] = request.user
|
validated_data['created_by'] = request.user
|
||||||
else:
|
else:
|
||||||
validated_data['created_by'] = None # TODO: Remove for production
|
validated_data['created_by'] = None # TODO: Remove for production
|
||||||
|
|
||||||
# Create the event
|
# Create the event
|
||||||
event = Event.objects.create(**validated_data)
|
event = Event.objects.create(**validated_data)
|
||||||
|
|
||||||
# Create Resource participants
|
# Create Resource participants
|
||||||
resource_content_type = ContentType.objects.get_for_model(Resource)
|
resource_content_type = ContentType.objects.get_for_model(Resource)
|
||||||
for resource_id in resource_ids:
|
for resource_id in resource_ids:
|
||||||
@@ -452,7 +473,7 @@ class EventSerializer(serializers.ModelSerializer):
|
|||||||
object_id=resource_id,
|
object_id=resource_id,
|
||||||
role=Participant.Role.RESOURCE
|
role=Participant.Role.RESOURCE
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create Staff participants
|
# Create Staff participants
|
||||||
from smoothschedule.users.models import User
|
from smoothschedule.users.models import User
|
||||||
user_content_type = ContentType.objects.get_for_model(User)
|
user_content_type = ContentType.objects.get_for_model(User)
|
||||||
@@ -463,13 +484,23 @@ class EventSerializer(serializers.ModelSerializer):
|
|||||||
object_id=staff_id,
|
object_id=staff_id,
|
||||||
role=Participant.Role.STAFF
|
role=Participant.Role.STAFF
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create Customer participant
|
||||||
|
if customer_id:
|
||||||
|
Participant.objects.create(
|
||||||
|
event=event,
|
||||||
|
content_type=user_content_type,
|
||||||
|
object_id=customer_id,
|
||||||
|
role=Participant.Role.CUSTOMER
|
||||||
|
)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
"""Update event. Participants managed separately."""
|
"""Update event. Participants managed separately."""
|
||||||
validated_data.pop('resource_ids', None)
|
validated_data.pop('resource_ids', None)
|
||||||
validated_data.pop('staff_ids', None)
|
validated_data.pop('staff_ids', None)
|
||||||
|
validated_data.pop('customer', None)
|
||||||
|
|
||||||
for attr, value in validated_data.items():
|
for attr, value in validated_data.items():
|
||||||
setattr(instance, attr, value)
|
setattr(instance, attr, value)
|
||||||
|
|||||||
@@ -77,18 +77,37 @@ class ResourceViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Resource.objects.all()
|
queryset = Resource.objects.all()
|
||||||
serializer_class = ResourceSerializer
|
serializer_class = ResourceSerializer
|
||||||
# TODO: Re-enable authentication for production
|
permission_classes = [IsAuthenticated]
|
||||||
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
|
|
||||||
|
|
||||||
filterset_fields = ['is_active', 'max_concurrent_events']
|
filterset_fields = ['is_active', 'max_concurrent_events']
|
||||||
search_fields = ['name', 'description']
|
search_fields = ['name', 'description']
|
||||||
ordering_fields = ['name', 'created_at', 'max_concurrent_events']
|
ordering_fields = ['name', 'created_at', 'max_concurrent_events']
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Return resources for the current tenant.
|
||||||
|
|
||||||
|
CRITICAL: Validates user belongs to the current tenant.
|
||||||
|
"""
|
||||||
|
queryset = Resource.objects.all()
|
||||||
|
|
||||||
|
user = self.request.user
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Validate user belongs to the current tenant
|
||||||
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if user.tenant and request_tenant:
|
||||||
|
if user.tenant.schema_name != request_tenant.schema_name:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Create resource (quota-checked by HasQuota permission)"""
|
"""Create resource (quota-checked by HasQuota permission)"""
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""Update resource"""
|
"""Update resource"""
|
||||||
serializer.save()
|
serializer.save()
|
||||||
@@ -113,8 +132,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||||||
"""
|
"""
|
||||||
queryset = Event.objects.all()
|
queryset = Event.objects.all()
|
||||||
serializer_class = EventSerializer
|
serializer_class = EventSerializer
|
||||||
# TODO: Re-enable authentication for production
|
permission_classes = [IsAuthenticated]
|
||||||
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
|
|
||||||
|
|
||||||
filterset_fields = ['status']
|
filterset_fields = ['status']
|
||||||
search_fields = ['title', 'notes']
|
search_fields = ['title', 'notes']
|
||||||
@@ -123,10 +141,41 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Filter events by date range if start_date and end_date are provided.
|
Filter events by date range and user role.
|
||||||
|
|
||||||
|
CRITICAL for multi-tenancy:
|
||||||
|
- Users can only see events from their own tenant
|
||||||
|
- Customers can only see events where they are a participant
|
||||||
|
- Staff/Managers/Owners see all events in their tenant
|
||||||
"""
|
"""
|
||||||
queryset = Event.objects.all()
|
queryset = Event.objects.all()
|
||||||
|
|
||||||
|
# CRITICAL: Validate user belongs to the current tenant
|
||||||
|
user = self.request.user
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Get the current tenant from the request (set by TenantMainMiddleware/TenantHeaderMiddleware)
|
||||||
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
|
|
||||||
|
# If user has a tenant, verify it matches the request tenant
|
||||||
|
# This prevents users from accessing other tenants' data via subdomain/header manipulation
|
||||||
|
if user.tenant and request_tenant:
|
||||||
|
if user.tenant.schema_name != request_tenant.schema_name:
|
||||||
|
# User is accessing a tenant they don't belong to - return empty
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Filter by user role
|
||||||
|
if user.role == User.Role.CUSTOMER:
|
||||||
|
# Customers only see events where they are a participant
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
user_content_type = ContentType.objects.get_for_model(User)
|
||||||
|
participant_event_ids = Participant.objects.filter(
|
||||||
|
content_type=user_content_type,
|
||||||
|
object_id=user.id
|
||||||
|
).values_list('event_id', flat=True)
|
||||||
|
queryset = queryset.filter(id__in=participant_event_ids)
|
||||||
|
|
||||||
# Filter by date range
|
# Filter by date range
|
||||||
start_date = self.request.query_params.get('start_date')
|
start_date = self.request.query_params.get('start_date')
|
||||||
end_date = self.request.query_params.get('end_date')
|
end_date = self.request.query_params.get('end_date')
|
||||||
@@ -153,11 +202,7 @@ class EventViewSet(viewsets.ModelViewSet):
|
|||||||
to check if resources have capacity. If not, DRF automatically
|
to check if resources have capacity. If not, DRF automatically
|
||||||
returns 400 Bad Request with error details.
|
returns 400 Bad Request with error details.
|
||||||
"""
|
"""
|
||||||
# TODO: Re-enable authentication - this is temporary for development
|
serializer.save(created_by=self.request.user)
|
||||||
if self.request.user.is_authenticated:
|
|
||||||
serializer.save(created_by=self.request.user)
|
|
||||||
else:
|
|
||||||
serializer.save(created_by=None)
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""
|
"""
|
||||||
@@ -184,6 +229,26 @@ class ParticipantViewSet(viewsets.ModelViewSet):
|
|||||||
ordering_fields = ['created_at']
|
ordering_fields = ['created_at']
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Return participants for the current tenant.
|
||||||
|
|
||||||
|
CRITICAL: Validates user belongs to the current tenant.
|
||||||
|
"""
|
||||||
|
queryset = Participant.objects.all()
|
||||||
|
|
||||||
|
user = self.request.user
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Validate user belongs to the current tenant
|
||||||
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if user.tenant and request_tenant:
|
||||||
|
if user.tenant.schema_name != request_tenant.schema_name:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
class CustomerViewSet(viewsets.ModelViewSet):
|
class CustomerViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -192,8 +257,7 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
|||||||
Customers are Users with role=CUSTOMER belonging to the current tenant.
|
Customers are Users with role=CUSTOMER belonging to the current tenant.
|
||||||
"""
|
"""
|
||||||
serializer_class = CustomerSerializer
|
serializer_class = CustomerSerializer
|
||||||
# TODO: Re-enable authentication for production
|
permission_classes = [IsAuthenticated]
|
||||||
permission_classes = [AllowAny] # Temporarily allow unauthenticated access for development
|
|
||||||
|
|
||||||
filterset_fields = ['is_active']
|
filterset_fields = ['is_active']
|
||||||
search_fields = ['email', 'first_name', 'last_name']
|
search_fields = ['email', 'first_name', 'last_name']
|
||||||
@@ -207,12 +271,28 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
|||||||
Customers are Users with role=CUSTOMER.
|
Customers are Users with role=CUSTOMER.
|
||||||
In sandbox mode, only returns customers with is_sandbox=True.
|
In sandbox mode, only returns customers with is_sandbox=True.
|
||||||
In live mode, only returns customers with is_sandbox=False.
|
In live mode, only returns customers with is_sandbox=False.
|
||||||
|
|
||||||
|
CRITICAL: Only returns customers belonging to the current user's tenant.
|
||||||
"""
|
"""
|
||||||
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
||||||
|
|
||||||
# Filter by tenant if user is authenticated and has a tenant
|
user = self.request.user
|
||||||
if self.request.user.is_authenticated and self.request.user.tenant:
|
if not user.is_authenticated:
|
||||||
queryset = queryset.filter(tenant=self.request.user.tenant)
|
return queryset.none()
|
||||||
|
|
||||||
|
# CRITICAL: Validate user belongs to the current request tenant
|
||||||
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if user.tenant and request_tenant:
|
||||||
|
if user.tenant.schema_name != request_tenant.schema_name:
|
||||||
|
# User is accessing a tenant they don't belong to - return empty
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Filter by user's tenant for multi-tenancy security
|
||||||
|
if user.tenant:
|
||||||
|
queryset = queryset.filter(tenant=user.tenant)
|
||||||
|
else:
|
||||||
|
# If user has no tenant, return empty queryset for safety
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
# Filter by sandbox mode - check request.sandbox_mode set by middleware
|
# Filter by sandbox mode - check request.sandbox_mode set by middleware
|
||||||
is_sandbox = getattr(self.request, 'sandbox_mode', False)
|
is_sandbox = getattr(self.request, 'sandbox_mode', False)
|
||||||
@@ -319,8 +399,7 @@ class StaffViewSet(viewsets.ModelViewSet):
|
|||||||
- POST /api/staff/{id}/toggle_active/ - Toggle active status
|
- POST /api/staff/{id}/toggle_active/ - Toggle active status
|
||||||
"""
|
"""
|
||||||
serializer_class = StaffSerializer
|
serializer_class = StaffSerializer
|
||||||
# TODO: Re-enable authentication for production
|
permission_classes = [IsAuthenticated]
|
||||||
permission_classes = [AllowAny]
|
|
||||||
|
|
||||||
search_fields = ['email', 'first_name', 'last_name']
|
search_fields = ['email', 'first_name', 'last_name']
|
||||||
ordering_fields = ['email', 'first_name', 'last_name']
|
ordering_fields = ['email', 'first_name', 'last_name']
|
||||||
@@ -337,9 +416,22 @@ class StaffViewSet(viewsets.ModelViewSet):
|
|||||||
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
||||||
In sandbox mode, only returns staff with is_sandbox=True.
|
In sandbox mode, only returns staff with is_sandbox=True.
|
||||||
In live mode, only returns staff with is_sandbox=False.
|
In live mode, only returns staff with is_sandbox=False.
|
||||||
|
|
||||||
|
CRITICAL: Only returns users belonging to the current user's tenant.
|
||||||
"""
|
"""
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
|
user = self.request.user
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return User.objects.none()
|
||||||
|
|
||||||
|
# CRITICAL: Validate user belongs to the current request tenant
|
||||||
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if user.tenant and request_tenant:
|
||||||
|
if user.tenant.schema_name != request_tenant.schema_name:
|
||||||
|
# User is accessing a tenant they don't belong to - return empty
|
||||||
|
return User.objects.none()
|
||||||
|
|
||||||
# Include inactive staff for listing (so admins can reactivate them)
|
# Include inactive staff for listing (so admins can reactivate them)
|
||||||
show_inactive = self.request.query_params.get('show_inactive', 'true')
|
show_inactive = self.request.query_params.get('show_inactive', 'true')
|
||||||
|
|
||||||
@@ -352,10 +444,12 @@ class StaffViewSet(viewsets.ModelViewSet):
|
|||||||
if show_inactive.lower() != 'true':
|
if show_inactive.lower() != 'true':
|
||||||
queryset = queryset.filter(is_active=True)
|
queryset = queryset.filter(is_active=True)
|
||||||
|
|
||||||
# Filter by tenant if user is authenticated and has a tenant
|
# Filter by user's tenant for multi-tenancy security
|
||||||
# TODO: Re-enable this when authentication is enabled
|
if user.tenant:
|
||||||
# if self.request.user.is_authenticated and self.request.user.tenant:
|
queryset = queryset.filter(tenant=user.tenant)
|
||||||
# queryset = queryset.filter(tenant=self.request.user.tenant)
|
else:
|
||||||
|
# If user has no tenant, return empty queryset for safety
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
# Filter by sandbox mode - check request.sandbox_mode set by middleware
|
# Filter by sandbox mode - check request.sandbox_mode set by middleware
|
||||||
is_sandbox = getattr(self.request, 'sandbox_mode', False)
|
is_sandbox = getattr(self.request, 'sandbox_mode', False)
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from django_tenants.utils import schema_context
|
|||||||
@permission_classes([AllowAny])
|
@permission_classes([AllowAny])
|
||||||
def login_view(request):
|
def login_view(request):
|
||||||
"""
|
"""
|
||||||
Login user with username/email and password.
|
Login user with email and password.
|
||||||
POST /api/auth/login/
|
POST /api/auth/login/
|
||||||
|
|
||||||
If MFA is enabled:
|
If MFA is enabled:
|
||||||
@@ -36,25 +36,24 @@ def login_view(request):
|
|||||||
If MFA is not enabled or device is trusted:
|
If MFA is not enabled or device is trusted:
|
||||||
- Returns access/refresh tokens and user data
|
- Returns access/refresh tokens and user data
|
||||||
"""
|
"""
|
||||||
username = request.data.get('username', '').strip()
|
# Accept both 'email' and 'username' fields for backward compatibility
|
||||||
|
email = request.data.get('email', '') or request.data.get('username', '')
|
||||||
|
email = email.strip().lower()
|
||||||
password = request.data.get('password', '')
|
password = request.data.get('password', '')
|
||||||
|
|
||||||
if not username or not password:
|
if not email or not password:
|
||||||
return Response(
|
return Response(
|
||||||
{'error': 'Username and password are required'},
|
{'error': 'Email and password are required'},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Authenticate user (supports username or email)
|
# Look up user by email and authenticate
|
||||||
user = authenticate(request, username=username, password=password)
|
user = None
|
||||||
|
try:
|
||||||
# If authentication with username failed, try email
|
user_by_email = User.objects.get(email__iexact=email)
|
||||||
if user is None:
|
user = authenticate(request, username=user_by_email.username, password=password)
|
||||||
try:
|
except User.DoesNotExist:
|
||||||
user_by_email = User.objects.get(email__iexact=username)
|
pass
|
||||||
user = authenticate(request, username=user_by_email.username, password=password)
|
|
||||||
except User.DoesNotExist:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
return Response(
|
return Response(
|
||||||
@@ -732,20 +731,12 @@ def accept_invitation_view(request, token):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the user
|
# Create the user with email as username
|
||||||
username = invitation.email.split('@')[0]
|
|
||||||
# Ensure username is unique
|
|
||||||
base_username = username
|
|
||||||
counter = 1
|
|
||||||
while User.objects.filter(username=username).exists():
|
|
||||||
username = f"{base_username}{counter}"
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
# Determine sandbox mode from request (set by middleware)
|
# Determine sandbox mode from request (set by middleware)
|
||||||
is_sandbox = getattr(request, 'sandbox_mode', False)
|
is_sandbox = getattr(request, 'sandbox_mode', False)
|
||||||
|
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
username=username,
|
username=invitation.email.lower(), # Use email as username
|
||||||
email=invitation.email,
|
email=invitation.email,
|
||||||
password=password,
|
password=password,
|
||||||
first_name=first_name,
|
first_name=first_name,
|
||||||
@@ -1010,7 +1001,7 @@ def signup_view(request):
|
|||||||
|
|
||||||
# 5. Create User (Owner)
|
# 5. Create User (Owner)
|
||||||
user = User.objects.create_user(
|
user = User.objects.create_user(
|
||||||
username=email.split('@')[0], # Fallback username
|
username=email, # Use email as username
|
||||||
email=email,
|
email=email,
|
||||||
password=password,
|
password=password,
|
||||||
first_name=data.get('first_name', ''),
|
first_name=data.get('first_name', ''),
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
test_users = [
|
test_users = [
|
||||||
# Platform users (no tenant)
|
# Platform users (no tenant) - username is set to email
|
||||||
{
|
{
|
||||||
'username': 'superuser',
|
'username': 'superuser@platform.com',
|
||||||
'email': 'superuser@platform.com',
|
'email': 'superuser@platform.com',
|
||||||
'password': 'test123',
|
'password': 'test123',
|
||||||
'role': User.Role.SUPERUSER,
|
'role': User.Role.SUPERUSER,
|
||||||
@@ -32,7 +32,7 @@ class Command(BaseCommand):
|
|||||||
'tenant': None,
|
'tenant': None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'username': 'platform_manager',
|
'username': 'manager@platform.com',
|
||||||
'email': 'manager@platform.com',
|
'email': 'manager@platform.com',
|
||||||
'password': 'test123',
|
'password': 'test123',
|
||||||
'role': User.Role.PLATFORM_MANAGER,
|
'role': User.Role.PLATFORM_MANAGER,
|
||||||
@@ -41,7 +41,7 @@ class Command(BaseCommand):
|
|||||||
'tenant': None,
|
'tenant': None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'username': 'platform_sales',
|
'username': 'sales@platform.com',
|
||||||
'email': 'sales@platform.com',
|
'email': 'sales@platform.com',
|
||||||
'password': 'test123',
|
'password': 'test123',
|
||||||
'role': User.Role.PLATFORM_SALES,
|
'role': User.Role.PLATFORM_SALES,
|
||||||
@@ -50,7 +50,7 @@ class Command(BaseCommand):
|
|||||||
'tenant': None,
|
'tenant': None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'username': 'platform_support',
|
'username': 'support@platform.com',
|
||||||
'email': 'support@platform.com',
|
'email': 'support@platform.com',
|
||||||
'password': 'test123',
|
'password': 'test123',
|
||||||
'role': User.Role.PLATFORM_SUPPORT,
|
'role': User.Role.PLATFORM_SUPPORT,
|
||||||
@@ -58,9 +58,9 @@ class Command(BaseCommand):
|
|||||||
'last_name': 'Agent',
|
'last_name': 'Agent',
|
||||||
'tenant': None,
|
'tenant': None,
|
||||||
},
|
},
|
||||||
# Tenant users (with tenant)
|
# Tenant users (with tenant) - username is set to email
|
||||||
{
|
{
|
||||||
'username': 'tenant_owner',
|
'username': 'owner@demo.com',
|
||||||
'email': 'owner@demo.com',
|
'email': 'owner@demo.com',
|
||||||
'password': 'test123',
|
'password': 'test123',
|
||||||
'role': User.Role.TENANT_OWNER,
|
'role': User.Role.TENANT_OWNER,
|
||||||
@@ -69,7 +69,7 @@ class Command(BaseCommand):
|
|||||||
'tenant': demo_tenant,
|
'tenant': demo_tenant,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'username': 'tenant_manager',
|
'username': 'manager@demo.com',
|
||||||
'email': 'manager@demo.com',
|
'email': 'manager@demo.com',
|
||||||
'password': 'test123',
|
'password': 'test123',
|
||||||
'role': User.Role.TENANT_MANAGER,
|
'role': User.Role.TENANT_MANAGER,
|
||||||
@@ -78,7 +78,7 @@ class Command(BaseCommand):
|
|||||||
'tenant': demo_tenant,
|
'tenant': demo_tenant,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'username': 'tenant_staff',
|
'username': 'staff@demo.com',
|
||||||
'email': 'staff@demo.com',
|
'email': 'staff@demo.com',
|
||||||
'password': 'test123',
|
'password': 'test123',
|
||||||
'role': User.Role.TENANT_STAFF,
|
'role': User.Role.TENANT_STAFF,
|
||||||
@@ -87,7 +87,7 @@ class Command(BaseCommand):
|
|||||||
'tenant': demo_tenant,
|
'tenant': demo_tenant,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'username': 'customer',
|
'username': 'customer@demo.com',
|
||||||
'email': 'customer@demo.com',
|
'email': 'customer@demo.com',
|
||||||
'password': 'test123',
|
'password': 'test123',
|
||||||
'role': User.Role.CUSTOMER,
|
'role': User.Role.CUSTOMER,
|
||||||
|
|||||||
Reference in New Issue
Block a user