feat: Implement staff invitation system with role-based permissions
- Add StaffInvitation model with token-based 7-day expiration
- Create invitation API endpoints (create, cancel, resend, accept, decline)
- Add permissions JSONField to User model for granular access control
- Implement frontend invite modal with role-specific permissions:
- Manager: can_invite_staff, can_manage_resources, can_manage_services,
can_view_reports, can_access_settings, can_refund_payments
- Staff: can_view_all_schedules, can_manage_own_appointments
- Add edit staff modal with permissions management and deactivate option
- Create AcceptInvitePage for invitation acceptance flow
- Add active/inactive staff separation with collapsible section
- Auto-create bookable resource when configured at invite time
- Remove Quick Add Appointment from dashboard
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -53,6 +53,7 @@ import PlatformSettings from './pages/platform/PlatformSettings';
|
|||||||
import ProfileSettings from './pages/ProfileSettings';
|
import ProfileSettings from './pages/ProfileSettings';
|
||||||
import VerifyEmail from './pages/VerifyEmail';
|
import VerifyEmail from './pages/VerifyEmail';
|
||||||
import EmailVerificationRequired from './pages/EmailVerificationRequired';
|
import EmailVerificationRequired from './pages/EmailVerificationRequired';
|
||||||
|
import AcceptInvitePage from './pages/AcceptInvitePage';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -210,6 +211,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
@@ -222,6 +224,7 @@ const AppContent: React.FC = () => {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/accept-invite" element={<AcceptInvitePage />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ import {
|
|||||||
} from '../api/auth';
|
} from '../api/auth';
|
||||||
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
|
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper hook to set auth tokens (used by invitation acceptance)
|
||||||
|
*/
|
||||||
|
export const useAuth = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const setTokens = (accessToken: string, refreshToken: string) => {
|
||||||
|
setCookie('access_token', accessToken, 7);
|
||||||
|
setCookie('refresh_token', refreshToken, 7);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { setTokens };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to get current user
|
* Hook to get current user
|
||||||
*/
|
*/
|
||||||
|
|||||||
165
frontend/src/hooks/useInvitations.ts
Normal file
165
frontend/src/hooks/useInvitations.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Staff Invitations Management Hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
|
export interface StaffInvitation {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
|
||||||
|
role_display: string;
|
||||||
|
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED';
|
||||||
|
invited_by: number | null;
|
||||||
|
invited_by_name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
accepted_at: string | null;
|
||||||
|
create_bookable_resource: boolean;
|
||||||
|
resource_name: string;
|
||||||
|
permissions: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationDetails {
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
role_display: string;
|
||||||
|
business_name: string;
|
||||||
|
invited_by: string | null;
|
||||||
|
expires_at: string;
|
||||||
|
create_bookable_resource: boolean;
|
||||||
|
resource_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaffPermissions {
|
||||||
|
// Manager permissions
|
||||||
|
can_invite_staff?: boolean;
|
||||||
|
can_manage_resources?: boolean;
|
||||||
|
can_manage_services?: boolean;
|
||||||
|
can_view_reports?: boolean;
|
||||||
|
can_access_settings?: boolean;
|
||||||
|
can_refund_payments?: boolean;
|
||||||
|
// Staff permissions
|
||||||
|
can_view_all_schedules?: boolean;
|
||||||
|
can_manage_own_appointments?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInvitationData {
|
||||||
|
email: string;
|
||||||
|
role: 'TENANT_MANAGER' | 'TENANT_STAFF';
|
||||||
|
create_bookable_resource?: boolean;
|
||||||
|
resource_name?: string;
|
||||||
|
permissions?: StaffPermissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch pending invitations for the current business
|
||||||
|
*/
|
||||||
|
export const useInvitations = () => {
|
||||||
|
return useQuery<StaffInvitation[]>({
|
||||||
|
queryKey: ['invitations'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get('/api/staff/invitations/');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create a new staff invitation
|
||||||
|
*/
|
||||||
|
export const useCreateInvitation = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (invitationData: CreateInvitationData) => {
|
||||||
|
const { data } = await apiClient.post('/api/staff/invitations/', invitationData);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to cancel a pending invitation
|
||||||
|
*/
|
||||||
|
export const useCancelInvitation = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (invitationId: number) => {
|
||||||
|
await apiClient.delete(`/api/staff/invitations/${invitationId}/`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to resend an invitation email
|
||||||
|
*/
|
||||||
|
export const useResendInvitation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (invitationId: number) => {
|
||||||
|
const { data } = await apiClient.post(`/api/staff/invitations/${invitationId}/resend/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to get invitation details by token (for acceptance page)
|
||||||
|
*/
|
||||||
|
export const useInvitationDetails = (token: string | null) => {
|
||||||
|
return useQuery<InvitationDetails>({
|
||||||
|
queryKey: ['invitation', token],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get(`/api/staff/invitations/token/${token}/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!token,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to accept an invitation
|
||||||
|
*/
|
||||||
|
export const useAcceptInvitation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
token,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
password,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
password: string;
|
||||||
|
}) => {
|
||||||
|
const { data } = await apiClient.post(`/api/staff/invitations/token/${token}/accept/`, {
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to decline an invitation
|
||||||
|
*/
|
||||||
|
export const useDeclineInvitation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (token: string) => {
|
||||||
|
const { data } = await apiClient.post(`/api/staff/invitations/token/${token}/decline/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,14 +2,22 @@
|
|||||||
* Staff Management Hooks
|
* Staff Management Hooks
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import apiClient from '../api/client';
|
import apiClient from '../api/client';
|
||||||
|
|
||||||
|
export interface StaffPermissions {
|
||||||
|
can_invite_staff?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StaffMember {
|
export interface StaffMember {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
role: string;
|
||||||
|
is_active: boolean;
|
||||||
|
permissions: StaffPermissions;
|
||||||
|
can_invite_staff: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StaffFilters {
|
interface StaffFilters {
|
||||||
@@ -26,6 +34,7 @@ export const useStaff = (filters?: StaffFilters) => {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.search) params.append('search', filters.search);
|
if (filters?.search) params.append('search', filters.search);
|
||||||
|
params.append('show_inactive', 'true'); // Always fetch inactive staff too
|
||||||
|
|
||||||
const { data } = await apiClient.get(`/api/staff/?${params}`);
|
const { data } = await apiClient.get(`/api/staff/?${params}`);
|
||||||
|
|
||||||
@@ -35,8 +44,54 @@ export const useStaff = (filters?: StaffFilters) => {
|
|||||||
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
|
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
|
||||||
email: s.email || '',
|
email: s.email || '',
|
||||||
phone: s.phone || '',
|
phone: s.phone || '',
|
||||||
|
role: s.role || 'staff',
|
||||||
|
is_active: s.is_active ?? true,
|
||||||
|
permissions: s.permissions || {},
|
||||||
|
can_invite_staff: s.can_invite_staff ?? false,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to update a staff member's settings
|
||||||
|
*/
|
||||||
|
export const useUpdateStaff = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
id,
|
||||||
|
updates,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
updates: { is_active?: boolean; permissions?: StaffPermissions };
|
||||||
|
}) => {
|
||||||
|
const { data } = await apiClient.patch(`/api/staff/${id}/`, updates);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to toggle a staff member's active status
|
||||||
|
*/
|
||||||
|
export const useToggleStaffActive = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const { data } = await apiClient.post(`/api/staff/${id}/toggle_active/`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['businessUsers'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
352
frontend/src/pages/AcceptInvitePage.tsx
Normal file
352
frontend/src/pages/AcceptInvitePage.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
useInvitationDetails,
|
||||||
|
useAcceptInvitation,
|
||||||
|
useDeclineInvitation,
|
||||||
|
} from '../hooks/useInvitations';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Building2,
|
||||||
|
Mail,
|
||||||
|
User,
|
||||||
|
Lock,
|
||||||
|
AlertCircle,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const AcceptInvitePage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
|
||||||
|
const { data: invitation, isLoading, error } = useInvitationDetails(token);
|
||||||
|
const acceptInvitationMutation = useAcceptInvitation();
|
||||||
|
const declineInvitationMutation = useDeclineInvitation();
|
||||||
|
const { setTokens } = useAuth();
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState('');
|
||||||
|
const [lastName, setLastName] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
|
const [declined, setDeclined] = useState(false);
|
||||||
|
|
||||||
|
const handleAccept = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError('');
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (!firstName.trim()) {
|
||||||
|
setFormError(t('acceptInvite.firstNameRequired', 'First name is required'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
setFormError(t('acceptInvite.passwordMinLength', 'Password must be at least 8 characters'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setFormError(t('acceptInvite.passwordsMustMatch', 'Passwords do not match'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await acceptInvitationMutation.mutateAsync({
|
||||||
|
token: token!,
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set auth tokens and redirect to dashboard
|
||||||
|
setTokens(result.access, result.refresh);
|
||||||
|
setAccepted(true);
|
||||||
|
|
||||||
|
// Redirect after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/');
|
||||||
|
}, 2000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setFormError(err.response?.data?.error || t('acceptInvite.acceptFailed', 'Failed to accept invitation'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDecline = async () => {
|
||||||
|
if (!confirm(t('acceptInvite.confirmDecline', 'Are you sure you want to decline this invitation?'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await declineInvitationMutation.mutateAsync(token!);
|
||||||
|
setDeclined(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
setFormError(err.response?.data?.error || t('acceptInvite.declineFailed', 'Failed to decline invitation'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No token provided
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
|
||||||
|
<XCircle size={48} className="mx-auto text-red-500 mb-4" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
{t('acceptInvite.invalidLink', 'Invalid Invitation Link')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('acceptInvite.noToken', 'This invitation link is invalid. Please check your email for the correct link.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 size={48} className="mx-auto text-brand-600 animate-spin mb-4" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('acceptInvite.loading', 'Loading invitation...')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state (invalid/expired token)
|
||||||
|
if (error || !invitation) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
|
||||||
|
<AlertCircle size={48} className="mx-auto text-amber-500 mb-4" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
{t('acceptInvite.expiredTitle', 'Invitation Expired or Invalid')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t(
|
||||||
|
'acceptInvite.expiredDescription',
|
||||||
|
'This invitation has expired or is no longer valid. Please contact the person who sent the invitation to request a new one.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accepted state
|
||||||
|
if (accepted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
|
||||||
|
<CheckCircle size={48} className="mx-auto text-green-500 mb-4" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
{t('acceptInvite.welcomeTitle', 'Welcome to the Team!')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('acceptInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')}
|
||||||
|
</p>
|
||||||
|
<Loader2 size={24} className="mx-auto text-brand-600 animate-spin mt-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declined state
|
||||||
|
if (declined) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
|
||||||
|
<XCircle size={48} className="mx-auto text-gray-400 mb-4" />
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
{t('acceptInvite.declinedTitle', 'Invitation Declined')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('acceptInvite.declinedDescription', "You've declined this invitation. You can close this page.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main acceptance form
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
|
||||||
|
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-brand-600 p-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-white mb-2">
|
||||||
|
{t('acceptInvite.title', "You're Invited!")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-brand-100">
|
||||||
|
{t('acceptInvite.subtitle', 'Join the team and start scheduling')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invitation Details */}
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center text-brand-600 dark:text-brand-400">
|
||||||
|
<Building2 size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
{t('acceptInvite.business', 'Business')}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">{invitation.business_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-600 dark:text-gray-400">
|
||||||
|
<Mail size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
{t('acceptInvite.invitedAs', 'Invited As')}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{invitation.role_display} •{' '}
|
||||||
|
<span className="font-normal text-gray-600 dark:text-gray-400">{invitation.email}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invitation.invited_by && (
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 text-center pt-2">
|
||||||
|
{t('acceptInvite.invitedBy', 'Invited by')} <span className="font-medium">{invitation.invited_by}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleAccept} className="p-6 space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
{t('acceptInvite.formDescription', 'Create your account to accept this invitation.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('acceptInvite.firstName', 'First Name')} *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder="John"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('acceptInvite.lastName', 'Last Name')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('acceptInvite.password', 'Password')} *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full pl-10 pr-10 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('acceptInvite.confirmPassword', 'Confirm Password')} *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full pl-10 pr-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder="Repeat password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{formError && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex flex-col gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={acceptInvitationMutation.isPending}
|
||||||
|
className="w-full px-4 py-3 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{acceptInvitationMutation.isPending ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle size={18} />
|
||||||
|
)}
|
||||||
|
{t('acceptInvite.acceptButton', 'Accept Invitation & Create Account')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDecline}
|
||||||
|
disabled={declineInvitationMutation.isPending}
|
||||||
|
className="w-full px-4 py-2 text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
{t('acceptInvite.declineButton', 'Decline Invitation')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AcceptInvitePage;
|
||||||
@@ -16,7 +16,6 @@ import { useServices } from '../hooks/useServices';
|
|||||||
import { useResources } from '../hooks/useResources';
|
import { useResources } from '../hooks/useResources';
|
||||||
import { useAppointments } from '../hooks/useAppointments';
|
import { useAppointments } from '../hooks/useAppointments';
|
||||||
import { useCustomers } from '../hooks/useCustomers';
|
import { useCustomers } from '../hooks/useCustomers';
|
||||||
import QuickAddAppointment from '../components/QuickAddAppointment';
|
|
||||||
|
|
||||||
interface Metric {
|
interface Metric {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -147,14 +146,9 @@ const Dashboard: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6">
|
||||||
{/* Quick Add Appointment */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
<QuickAddAppointment />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Revenue Chart */}
|
{/* Revenue Chart */}
|
||||||
<div className="lg:col-span-2 p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
|
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm transition-colors duration-200">
|
||||||
<h3 className="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{t('dashboard.totalRevenue')}</h3>
|
<h3 className="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{t('dashboard.totalRevenue')}</h3>
|
||||||
<div className="h-80">
|
<div className="h-80">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,9 @@ from rest_framework.authtoken.views import obtain_auth_token
|
|||||||
|
|
||||||
from smoothschedule.users.api_views import (
|
from smoothschedule.users.api_views import (
|
||||||
current_user_view, logout_view, send_verification_email, verify_email,
|
current_user_view, logout_view, send_verification_email, verify_email,
|
||||||
hijack_acquire_view, hijack_release_view
|
hijack_acquire_view, hijack_release_view,
|
||||||
|
staff_invitations_view, cancel_invitation_view, resend_invitation_view,
|
||||||
|
invitation_details_view, accept_invitation_view, decline_invitation_view
|
||||||
)
|
)
|
||||||
from schedule.api_views import current_business_view, update_business_view
|
from schedule.api_views import current_business_view, update_business_view
|
||||||
|
|
||||||
@@ -47,6 +49,13 @@ urlpatterns += [
|
|||||||
# Hijack (masquerade) API
|
# Hijack (masquerade) API
|
||||||
path("api/auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
path("api/auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
||||||
path("api/auth/hijack/release/", hijack_release_view, name="hijack_release"),
|
path("api/auth/hijack/release/", hijack_release_view, name="hijack_release"),
|
||||||
|
# Staff Invitations API
|
||||||
|
path("api/staff/invitations/", staff_invitations_view, name="staff_invitations"),
|
||||||
|
path("api/staff/invitations/<int:invitation_id>/", cancel_invitation_view, name="cancel_invitation"),
|
||||||
|
path("api/staff/invitations/<int:invitation_id>/resend/", resend_invitation_view, name="resend_invitation"),
|
||||||
|
path("api/staff/invitations/token/<str:token>/", invitation_details_view, name="invitation_details"),
|
||||||
|
path("api/staff/invitations/token/<str:token>/accept/", accept_invitation_view, name="accept_invitation"),
|
||||||
|
path("api/staff/invitations/token/<str:token>/decline/", decline_invitation_view, name="decline_invitation"),
|
||||||
# Business API
|
# Business API
|
||||||
path("api/business/current/", current_business_view, name="current_business"),
|
path("api/business/current/", current_business_view, name="current_business"),
|
||||||
path("api/business/current/update/", update_business_view, name="update_business"),
|
path("api/business/current/update/", update_business_view, name="update_business"),
|
||||||
|
|||||||
@@ -102,13 +102,15 @@ class StaffSerializer(serializers.ModelSerializer):
|
|||||||
"""Serializer for Staff members (Users with staff roles)"""
|
"""Serializer for Staff members (Users with staff roles)"""
|
||||||
name = serializers.SerializerMethodField()
|
name = serializers.SerializerMethodField()
|
||||||
role = serializers.SerializerMethodField()
|
role = serializers.SerializerMethodField()
|
||||||
|
can_invite_staff = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'username', 'name', 'email', 'phone', 'role',
|
'id', 'username', 'name', 'email', 'phone', 'role',
|
||||||
|
'is_active', 'permissions', 'can_invite_staff',
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = ['id', 'username', 'email', 'role', 'can_invite_staff']
|
||||||
|
|
||||||
def get_name(self, obj):
|
def get_name(self, obj):
|
||||||
return obj.full_name
|
return obj.full_name
|
||||||
@@ -122,6 +124,9 @@ class StaffSerializer(serializers.ModelSerializer):
|
|||||||
}
|
}
|
||||||
return role_mapping.get(obj.role, obj.role.lower())
|
return role_mapping.get(obj.role, obj.role.lower())
|
||||||
|
|
||||||
|
def get_can_invite_staff(self, obj):
|
||||||
|
return obj.can_invite_staff()
|
||||||
|
|
||||||
|
|
||||||
class ServiceSerializer(serializers.ModelSerializer):
|
class ServiceSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for Service model"""
|
"""Serializer for Service model"""
|
||||||
@@ -139,19 +144,13 @@ class ServiceSerializer(serializers.ModelSerializer):
|
|||||||
class ResourceSerializer(serializers.ModelSerializer):
|
class ResourceSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for Resource model"""
|
"""Serializer for Resource model"""
|
||||||
capacity_description = serializers.SerializerMethodField()
|
capacity_description = serializers.SerializerMethodField()
|
||||||
user_id = serializers.IntegerField(source='user.id', read_only=True, allow_null=True)
|
user_id = serializers.IntegerField(required=False, allow_null=True)
|
||||||
user_name = serializers.CharField(source='user.full_name', read_only=True, allow_null=True)
|
user_name = serializers.CharField(source='user.full_name', read_only=True, allow_null=True)
|
||||||
user = serializers.PrimaryKeyRelatedField(
|
|
||||||
queryset=User.objects.all(),
|
|
||||||
required=False,
|
|
||||||
allow_null=True,
|
|
||||||
write_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Resource
|
model = Resource
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'type', 'user', 'user_id', 'user_name',
|
'id', 'name', 'type', 'user_id', 'user_name',
|
||||||
'description', 'max_concurrent_events',
|
'description', 'max_concurrent_events',
|
||||||
'buffer_duration', 'is_active', 'capacity_description',
|
'buffer_duration', 'is_active', 'capacity_description',
|
||||||
'saved_lane_count', 'created_at', 'updated_at',
|
'saved_lane_count', 'created_at', 'updated_at',
|
||||||
@@ -165,6 +164,35 @@ class ResourceSerializer(serializers.ModelSerializer):
|
|||||||
return "Exclusive use (1 at a time)"
|
return "Exclusive use (1 at a time)"
|
||||||
return f"Up to {obj.max_concurrent_events} concurrent events"
|
return f"Up to {obj.max_concurrent_events} concurrent events"
|
||||||
|
|
||||||
|
def to_representation(self, instance):
|
||||||
|
"""Add user_id to the output"""
|
||||||
|
ret = super().to_representation(instance)
|
||||||
|
ret['user_id'] = instance.user_id
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Handle user_id when creating a resource"""
|
||||||
|
user_id = validated_data.pop('user_id', None)
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
validated_data['user'] = User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
"""Handle user_id when updating a resource"""
|
||||||
|
user_id = validated_data.pop('user_id', None)
|
||||||
|
if user_id is not None:
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
validated_data['user'] = User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
validated_data['user'] = None
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class ParticipantSerializer(serializers.ModelSerializer):
|
class ParticipantSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for Participant model"""
|
"""Serializer for Participant model"""
|
||||||
|
|||||||
@@ -282,12 +282,17 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
return Response({'status': 'ok', 'updated': len(order)})
|
return Response({'status': 'ok', 'updated': len(order)})
|
||||||
|
|
||||||
|
|
||||||
class StaffViewSet(viewsets.ReadOnlyModelViewSet):
|
class StaffViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
API endpoint for listing staff members (Users who can be assigned to resources).
|
API endpoint for managing staff members (Users who can be assigned to resources).
|
||||||
|
|
||||||
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
||||||
This endpoint is read-only for assigning staff to resources.
|
|
||||||
|
Supports:
|
||||||
|
- GET /api/staff/ - List staff members
|
||||||
|
- GET /api/staff/{id}/ - Get staff member details
|
||||||
|
- PATCH /api/staff/{id}/ - Update staff member (is_active, permissions)
|
||||||
|
- POST /api/staff/{id}/toggle_active/ - Toggle active status
|
||||||
"""
|
"""
|
||||||
serializer_class = StaffSerializer
|
serializer_class = StaffSerializer
|
||||||
# TODO: Re-enable authentication for production
|
# TODO: Re-enable authentication for production
|
||||||
@@ -297,6 +302,10 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
ordering_fields = ['email', 'first_name', 'last_name']
|
ordering_fields = ['email', 'first_name', 'last_name']
|
||||||
ordering = ['first_name', 'last_name']
|
ordering = ['first_name', 'last_name']
|
||||||
|
|
||||||
|
# Disable create and delete - staff are managed via invitations
|
||||||
|
# Note: 'post' is needed for custom actions like toggle_active
|
||||||
|
http_method_names = ['get', 'patch', 'post', 'head', 'options']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Return staff members for the current tenant.
|
Return staff members for the current tenant.
|
||||||
@@ -305,11 +314,17 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
"""
|
"""
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
|
# Include inactive staff for listing (so admins can reactivate them)
|
||||||
|
show_inactive = self.request.query_params.get('show_inactive', 'true')
|
||||||
|
|
||||||
queryset = User.objects.filter(
|
queryset = User.objects.filter(
|
||||||
Q(role=User.Role.TENANT_OWNER) |
|
Q(role=User.Role.TENANT_OWNER) |
|
||||||
Q(role=User.Role.TENANT_MANAGER) |
|
Q(role=User.Role.TENANT_MANAGER) |
|
||||||
Q(role=User.Role.TENANT_STAFF)
|
Q(role=User.Role.TENANT_STAFF)
|
||||||
).filter(is_active=True)
|
)
|
||||||
|
|
||||||
|
if show_inactive.lower() != 'true':
|
||||||
|
queryset = queryset.filter(is_active=True)
|
||||||
|
|
||||||
# Filter by tenant if user is authenticated and has a tenant
|
# Filter by tenant if user is authenticated and has a tenant
|
||||||
# TODO: Re-enable this when authentication is enabled
|
# TODO: Re-enable this when authentication is enabled
|
||||||
@@ -326,3 +341,55 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
def partial_update(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Update staff member.
|
||||||
|
|
||||||
|
Allowed fields: is_active, permissions
|
||||||
|
|
||||||
|
Owners can edit any staff member.
|
||||||
|
Managers can only edit staff (not other managers or owners).
|
||||||
|
"""
|
||||||
|
instance = self.get_object()
|
||||||
|
|
||||||
|
# TODO: Add permission checks when authentication is enabled
|
||||||
|
# current_user = request.user
|
||||||
|
# if current_user.role == User.Role.TENANT_MANAGER:
|
||||||
|
# if instance.role in [User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER]:
|
||||||
|
# return Response(
|
||||||
|
# {'error': 'Managers cannot edit owners or other managers.'},
|
||||||
|
# status=status.HTTP_403_FORBIDDEN
|
||||||
|
# )
|
||||||
|
|
||||||
|
# Only allow updating specific fields
|
||||||
|
allowed_fields = {'is_active', 'permissions'}
|
||||||
|
update_data = {k: v for k, v in request.data.items() if k in allowed_fields}
|
||||||
|
|
||||||
|
serializer = self.get_serializer(instance, data=update_data, partial=True)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
serializer.save()
|
||||||
|
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def toggle_active(self, request, pk=None):
|
||||||
|
"""Toggle the active status of a staff member."""
|
||||||
|
staff = self.get_object()
|
||||||
|
|
||||||
|
# Prevent deactivating yourself
|
||||||
|
# TODO: Enable this check when authentication is enabled
|
||||||
|
# if request.user.id == staff.id:
|
||||||
|
# return Response(
|
||||||
|
# {'error': 'You cannot deactivate your own account.'},
|
||||||
|
# status=status.HTTP_400_BAD_REQUEST
|
||||||
|
# )
|
||||||
|
|
||||||
|
staff.is_active = not staff.is_active
|
||||||
|
staff.save(update_fields=['is_active'])
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'id': staff.id,
|
||||||
|
'is_active': staff.is_active,
|
||||||
|
'message': f"Staff member {'activated' if staff.is_active else 'deactivated'} successfully."
|
||||||
|
})
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ from rest_framework.permissions import IsAuthenticated, AllowAny
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from .models import User, EmailVerificationToken
|
from .models import User, EmailVerificationToken, StaffInvitation
|
||||||
from core.permissions import can_hijack
|
from core.permissions import can_hijack
|
||||||
|
from rest_framework import serializers
|
||||||
|
from schedule.models import Resource, ResourceType
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@@ -61,9 +63,12 @@ def current_user_view(request):
|
|||||||
'email_verified': user.email_verified,
|
'email_verified': user.email_verified,
|
||||||
'is_staff': user.is_staff,
|
'is_staff': user.is_staff,
|
||||||
'is_superuser': user.is_superuser,
|
'is_superuser': user.is_superuser,
|
||||||
|
'is_active': user.is_active,
|
||||||
'business': user.tenant_id,
|
'business': user.tenant_id,
|
||||||
'business_name': business_name,
|
'business_name': business_name,
|
||||||
'business_subdomain': business_subdomain,
|
'business_subdomain': business_subdomain,
|
||||||
|
'permissions': user.permissions,
|
||||||
|
'can_invite_staff': user.can_invite_staff(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response(user_data, status=status.HTTP_200_OK)
|
return Response(user_data, status=status.HTTP_200_OK)
|
||||||
@@ -201,9 +206,12 @@ def _get_user_data(user):
|
|||||||
'email_verified': user.email_verified,
|
'email_verified': user.email_verified,
|
||||||
'is_staff': user.is_staff,
|
'is_staff': user.is_staff,
|
||||||
'is_superuser': user.is_superuser,
|
'is_superuser': user.is_superuser,
|
||||||
|
'is_active': user.is_active,
|
||||||
'business': user.tenant_id,
|
'business': user.tenant_id,
|
||||||
'business_name': business_name,
|
'business_name': business_name,
|
||||||
'business_subdomain': business_subdomain,
|
'business_subdomain': business_subdomain,
|
||||||
|
'permissions': user.permissions,
|
||||||
|
'can_invite_staff': user.can_invite_staff(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -329,3 +337,407 @@ def hijack_release_view(request):
|
|||||||
'user': _get_user_data(original_user),
|
'user': _get_user_data(original_user),
|
||||||
'masquerade_stack': masquerade_stack, # Return remaining stack (should be empty now)
|
'masquerade_stack': masquerade_stack, # Return remaining stack (should be empty now)
|
||||||
}, status=status.HTTP_200_OK)
|
}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Staff Invitation Endpoints
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class StaffInvitationSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for staff invitations"""
|
||||||
|
invited_by_name = serializers.SerializerMethodField()
|
||||||
|
role_display = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = StaffInvitation
|
||||||
|
fields = [
|
||||||
|
'id', 'email', 'role', 'role_display', 'status',
|
||||||
|
'invited_by', 'invited_by_name',
|
||||||
|
'created_at', 'expires_at', 'accepted_at',
|
||||||
|
'create_bookable_resource', 'resource_name', 'permissions'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'status', 'invited_by', 'created_at', 'expires_at', 'accepted_at']
|
||||||
|
|
||||||
|
def get_invited_by_name(self, obj):
|
||||||
|
return obj.invited_by.full_name if obj.invited_by else None
|
||||||
|
|
||||||
|
def get_role_display(self, obj):
|
||||||
|
role_map = {
|
||||||
|
'TENANT_MANAGER': 'Manager',
|
||||||
|
'TENANT_STAFF': 'Staff',
|
||||||
|
}
|
||||||
|
return role_map.get(obj.role, obj.role)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET', 'POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def staff_invitations_view(request):
|
||||||
|
"""
|
||||||
|
List pending invitations or create a new invitation.
|
||||||
|
GET /api/staff/invitations/ - List invitations for current tenant
|
||||||
|
POST /api/staff/invitations/ - Create new invitation
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
# Check permission - only owners and managers with permission can manage invitations
|
||||||
|
if not user.can_invite_staff():
|
||||||
|
return Response(
|
||||||
|
{"error": "You do not have permission to invite staff members."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Must have a tenant
|
||||||
|
if not user.tenant:
|
||||||
|
return Response(
|
||||||
|
{"error": "No business associated with your account."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
# List invitations for this tenant
|
||||||
|
invitations = StaffInvitation.objects.filter(
|
||||||
|
tenant=user.tenant,
|
||||||
|
status=StaffInvitation.Status.PENDING
|
||||||
|
)
|
||||||
|
serializer = StaffInvitationSerializer(invitations, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
elif request.method == 'POST':
|
||||||
|
email = request.data.get('email', '').strip().lower()
|
||||||
|
role = request.data.get('role', User.Role.TENANT_STAFF)
|
||||||
|
create_bookable_resource = request.data.get('create_bookable_resource', False)
|
||||||
|
resource_name = request.data.get('resource_name', '').strip()
|
||||||
|
permissions = request.data.get('permissions', {})
|
||||||
|
|
||||||
|
# Validate email
|
||||||
|
if not email:
|
||||||
|
return Response(
|
||||||
|
{"error": "Email is required."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate role - only allow manager and staff roles
|
||||||
|
if role not in [User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF]:
|
||||||
|
return Response(
|
||||||
|
{"error": "Invalid role. Must be 'TENANT_MANAGER' or 'TENANT_STAFF'."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Managers can only invite staff, not other managers
|
||||||
|
# TODO: Add owner control to allow/disallow managers inviting managers
|
||||||
|
if user.role == User.Role.TENANT_MANAGER and role == User.Role.TENANT_MANAGER:
|
||||||
|
return Response(
|
||||||
|
{"error": "Managers can only invite staff members, not other managers."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user already exists in this tenant
|
||||||
|
existing_user = User.objects.filter(
|
||||||
|
email=email,
|
||||||
|
tenant=user.tenant
|
||||||
|
).first()
|
||||||
|
if existing_user:
|
||||||
|
return Response(
|
||||||
|
{"error": f"A user with email {email} already exists in your business."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate permissions is a dict
|
||||||
|
if not isinstance(permissions, dict):
|
||||||
|
permissions = {}
|
||||||
|
|
||||||
|
# Create invitation with bookable resource settings
|
||||||
|
invitation = StaffInvitation.create_invitation(
|
||||||
|
email=email,
|
||||||
|
role=role,
|
||||||
|
tenant=user.tenant,
|
||||||
|
invited_by=user,
|
||||||
|
create_bookable_resource=create_bookable_resource,
|
||||||
|
resource_name=resource_name,
|
||||||
|
permissions=permissions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send invitation email
|
||||||
|
_send_invitation_email(invitation)
|
||||||
|
|
||||||
|
serializer = StaffInvitationSerializer(invitation)
|
||||||
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def cancel_invitation_view(request, invitation_id):
|
||||||
|
"""
|
||||||
|
Cancel a pending invitation.
|
||||||
|
DELETE /api/staff/invitations/<id>/
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.can_manage_users():
|
||||||
|
return Response(
|
||||||
|
{"error": "You do not have permission to manage staff invitations."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.tenant:
|
||||||
|
return Response(
|
||||||
|
{"error": "No business associated with your account."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
invitation = get_object_or_404(
|
||||||
|
StaffInvitation,
|
||||||
|
id=invitation_id,
|
||||||
|
tenant=user.tenant
|
||||||
|
)
|
||||||
|
|
||||||
|
if invitation.status != StaffInvitation.Status.PENDING:
|
||||||
|
return Response(
|
||||||
|
{"error": "Only pending invitations can be cancelled."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
invitation.cancel()
|
||||||
|
return Response({"detail": "Invitation cancelled."}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def resend_invitation_view(request, invitation_id):
|
||||||
|
"""
|
||||||
|
Resend an invitation email.
|
||||||
|
POST /api/staff/invitations/<id>/resend/
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user.can_manage_users():
|
||||||
|
return Response(
|
||||||
|
{"error": "You do not have permission to manage staff invitations."},
|
||||||
|
status=status.HTTP_403_FORBIDDEN
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user.tenant:
|
||||||
|
return Response(
|
||||||
|
{"error": "No business associated with your account."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
invitation = get_object_or_404(
|
||||||
|
StaffInvitation,
|
||||||
|
id=invitation_id,
|
||||||
|
tenant=user.tenant,
|
||||||
|
status=StaffInvitation.Status.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resend the email
|
||||||
|
_send_invitation_email(invitation)
|
||||||
|
|
||||||
|
return Response({"detail": "Invitation email resent."}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def invitation_details_view(request, token):
|
||||||
|
"""
|
||||||
|
Get invitation details by token (public endpoint for acceptance page).
|
||||||
|
GET /api/staff/invitations/token/<token>/
|
||||||
|
"""
|
||||||
|
invitation = get_object_or_404(StaffInvitation, token=token)
|
||||||
|
|
||||||
|
if not invitation.is_valid():
|
||||||
|
return Response(
|
||||||
|
{"error": "This invitation has expired or is no longer valid."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return limited info for the acceptance page
|
||||||
|
role_map = {
|
||||||
|
'TENANT_MANAGER': 'Manager',
|
||||||
|
'TENANT_STAFF': 'Staff',
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'email': invitation.email,
|
||||||
|
'role': invitation.role,
|
||||||
|
'role_display': role_map.get(invitation.role, invitation.role),
|
||||||
|
'business_name': invitation.tenant.name,
|
||||||
|
'invited_by': invitation.invited_by.full_name if invitation.invited_by else None,
|
||||||
|
'expires_at': invitation.expires_at,
|
||||||
|
'create_bookable_resource': invitation.create_bookable_resource,
|
||||||
|
'resource_name': invitation.resource_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def accept_invitation_view(request, token):
|
||||||
|
"""
|
||||||
|
Accept an invitation and create user account.
|
||||||
|
POST /api/staff/invitations/token/<token>/accept/
|
||||||
|
|
||||||
|
Body: {
|
||||||
|
"first_name": "John",
|
||||||
|
"last_name": "Doe",
|
||||||
|
"password": "securepassword123"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
invitation = get_object_or_404(StaffInvitation, token=token)
|
||||||
|
|
||||||
|
if not invitation.is_valid():
|
||||||
|
return Response(
|
||||||
|
{"error": "This invitation has expired or is no longer valid."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
first_name = request.data.get('first_name', '').strip()
|
||||||
|
last_name = request.data.get('last_name', '').strip()
|
||||||
|
password = request.data.get('password', '')
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not first_name:
|
||||||
|
return Response(
|
||||||
|
{"error": "First name is required."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
if not password or len(password) < 8:
|
||||||
|
return Response(
|
||||||
|
{"error": "Password must be at least 8 characters."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if email is already taken (in any tenant)
|
||||||
|
if User.objects.filter(email=invitation.email).exists():
|
||||||
|
return Response(
|
||||||
|
{"error": "An account with this email already exists. Please login instead."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the user
|
||||||
|
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
|
||||||
|
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username=username,
|
||||||
|
email=invitation.email,
|
||||||
|
password=password,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
role=invitation.role,
|
||||||
|
tenant=invitation.tenant,
|
||||||
|
email_verified=True, # Email is verified since they received the invitation
|
||||||
|
permissions=invitation.permissions, # Copy permissions from invitation
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark invitation as accepted
|
||||||
|
invitation.accept(user)
|
||||||
|
|
||||||
|
# Create bookable resource if configured
|
||||||
|
resource_created = None
|
||||||
|
if invitation.create_bookable_resource:
|
||||||
|
# Get the resource name (use invitation setting or user's full name)
|
||||||
|
resource_name = invitation.resource_name or user.full_name
|
||||||
|
|
||||||
|
# Find or create the default STAFF resource type
|
||||||
|
staff_resource_type = ResourceType.objects.filter(
|
||||||
|
category=ResourceType.Category.STAFF,
|
||||||
|
is_default=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
# Create the resource
|
||||||
|
resource_created = Resource.objects.create(
|
||||||
|
name=resource_name,
|
||||||
|
type=Resource.Type.STAFF, # Legacy field
|
||||||
|
resource_type=staff_resource_type, # New field
|
||||||
|
user=user,
|
||||||
|
max_concurrent_events=1, # Default to exclusive booking
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create auth token for immediate login
|
||||||
|
Token.objects.filter(user=user).delete()
|
||||||
|
auth_token = Token.objects.create(user=user)
|
||||||
|
|
||||||
|
response_data = {
|
||||||
|
'access': auth_token.key,
|
||||||
|
'refresh': auth_token.key,
|
||||||
|
'user': _get_user_data(user),
|
||||||
|
'detail': 'Account created successfully.',
|
||||||
|
}
|
||||||
|
|
||||||
|
if resource_created:
|
||||||
|
response_data['resource_created'] = {
|
||||||
|
'id': resource_created.id,
|
||||||
|
'name': resource_created.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def decline_invitation_view(request, token):
|
||||||
|
"""
|
||||||
|
Decline an invitation.
|
||||||
|
POST /api/staff/invitations/token/<token>/decline/
|
||||||
|
"""
|
||||||
|
invitation = get_object_or_404(StaffInvitation, token=token)
|
||||||
|
|
||||||
|
if invitation.status != StaffInvitation.Status.PENDING:
|
||||||
|
return Response(
|
||||||
|
{"error": "This invitation is no longer pending."},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
invitation.decline()
|
||||||
|
return Response({"detail": "Invitation declined."}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_invitation_email(invitation):
|
||||||
|
"""Send invitation email to the invitee."""
|
||||||
|
# Build invitation URL
|
||||||
|
port = ':5173' if settings.DEBUG else ''
|
||||||
|
|
||||||
|
# Get subdomain for the tenant
|
||||||
|
primary_domain = invitation.tenant.domains.filter(is_primary=True).first()
|
||||||
|
if primary_domain:
|
||||||
|
subdomain = primary_domain.domain.split('.')[0] + '.'
|
||||||
|
else:
|
||||||
|
subdomain = invitation.tenant.schema_name + '.'
|
||||||
|
|
||||||
|
invite_url = f"http://{subdomain}lvh.me{port}/accept-invite?token={invitation.token}"
|
||||||
|
|
||||||
|
role_map = {
|
||||||
|
'TENANT_MANAGER': 'Manager',
|
||||||
|
'TENANT_STAFF': 'Staff Member',
|
||||||
|
}
|
||||||
|
role_display = role_map.get(invitation.role, 'team member')
|
||||||
|
|
||||||
|
subject = f"You're invited to join {invitation.tenant.name} on Smooth Schedule"
|
||||||
|
message = f"""Hi there,
|
||||||
|
|
||||||
|
{invitation.invited_by.full_name if invitation.invited_by else 'Someone'} has invited you to join {invitation.tenant.name} as a {role_display} on Smooth Schedule.
|
||||||
|
|
||||||
|
Click the link below to accept this invitation and create your account:
|
||||||
|
|
||||||
|
{invite_url}
|
||||||
|
|
||||||
|
This invitation will expire in 7 days.
|
||||||
|
|
||||||
|
If you did not expect this invitation, you can safely ignore this email.
|
||||||
|
|
||||||
|
Thanks,
|
||||||
|
The Smooth Schedule Team
|
||||||
|
"""
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@smoothschedule.com',
|
||||||
|
[invitation.email],
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-28 06:15
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0004_tenant_email_logo_alter_tenant_logo'),
|
||||||
|
('users', '0003_add_email_verification'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='StaffInvitation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('email', models.EmailField(help_text='Email address to send invitation to', max_length=254)),
|
||||||
|
('role', models.CharField(choices=[('TENANT_MANAGER', 'Manager'), ('TENANT_STAFF', 'Staff')], default='TENANT_STAFF', help_text='Role the invited user will have', max_length=20)),
|
||||||
|
('token', models.CharField(max_length=64, unique=True)),
|
||||||
|
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('DECLINED', 'Declined'), ('EXPIRED', 'Expired'), ('CANCELLED', 'Cancelled')], default='PENDING', max_length=20)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('accepted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('accepted_user', models.ForeignKey(blank=True, help_text='User account created when invitation was accepted', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accepted_invitation', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('invited_by', models.ForeignKey(help_text='User who sent the invitation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_invitations', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('tenant', models.ForeignKey(help_text='Business the user is being invited to', on_delete=django.db.models.deletion.CASCADE, related_name='staff_invitations', to='core.tenant')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [models.Index(fields=['token'], name='users_staff_token_139ef3_idx'), models.Index(fields=['email', 'tenant', 'status'], name='users_staff_email_eb223a_idx'), models.Index(fields=['status', 'expires_at'], name='users_staff_status_b43c24_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-28 06:44
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0004_add_staff_invitation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='staffinvitation',
|
||||||
|
name='create_bookable_resource',
|
||||||
|
field=models.BooleanField(default=False, help_text='Whether to create a bookable resource for this staff member'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='staffinvitation',
|
||||||
|
name='permissions',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Permission settings for the invited user'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='staffinvitation',
|
||||||
|
name='resource_name',
|
||||||
|
field=models.CharField(blank=True, help_text="Name for the bookable resource (defaults to user's name if empty)", max_length=200),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-11-28 06:46
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0005_add_bookable_and_permissions_to_invitation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='permissions',
|
||||||
|
field=models.JSONField(blank=True, default=dict, help_text='Role-specific permissions like can_invite_staff for managers'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -64,7 +64,14 @@ class User(AbstractUser):
|
|||||||
# Additional profile fields
|
# Additional profile fields
|
||||||
phone = models.CharField(max_length=20, blank=True)
|
phone = models.CharField(max_length=20, blank=True)
|
||||||
job_title = models.CharField(max_length=100, blank=True)
|
job_title = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
# Role-specific permissions (stored as JSON for flexibility)
|
||||||
|
permissions = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="Role-specific permissions like can_invite_staff for managers"
|
||||||
|
)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -126,6 +133,16 @@ class User(AbstractUser):
|
|||||||
self.Role.PLATFORM_MANAGER,
|
self.Role.PLATFORM_MANAGER,
|
||||||
self.Role.TENANT_OWNER,
|
self.Role.TENANT_OWNER,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def can_invite_staff(self):
|
||||||
|
"""Check if user can invite new staff members"""
|
||||||
|
# Owners can always invite
|
||||||
|
if self.role == self.Role.TENANT_OWNER:
|
||||||
|
return True
|
||||||
|
# Managers can invite if they have the permission
|
||||||
|
if self.role == self.Role.TENANT_MANAGER:
|
||||||
|
return self.permissions.get('can_invite_staff', False)
|
||||||
|
return False
|
||||||
|
|
||||||
def get_accessible_tenants(self):
|
def get_accessible_tenants(self):
|
||||||
"""
|
"""
|
||||||
@@ -195,3 +212,172 @@ class EmailVerificationToken(models.Model):
|
|||||||
cls.objects.filter(user=user, used=False).update(used=True)
|
cls.objects.filter(user=user, used=False).update(used=True)
|
||||||
# Create new token
|
# Create new token
|
||||||
return cls.objects.create(user=user)
|
return cls.objects.create(user=user)
|
||||||
|
|
||||||
|
|
||||||
|
class StaffInvitation(models.Model):
|
||||||
|
"""
|
||||||
|
Invitation for new staff members to join a business.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Owner/Manager creates invitation with email and role
|
||||||
|
2. System sends email with unique token link
|
||||||
|
3. Invitee clicks link, creates account, and is added to tenant
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
PENDING = 'PENDING', _('Pending')
|
||||||
|
ACCEPTED = 'ACCEPTED', _('Accepted')
|
||||||
|
DECLINED = 'DECLINED', _('Declined')
|
||||||
|
EXPIRED = 'EXPIRED', _('Expired')
|
||||||
|
CANCELLED = 'CANCELLED', _('Cancelled')
|
||||||
|
|
||||||
|
# Invitation target
|
||||||
|
email = models.EmailField(help_text="Email address to send invitation to")
|
||||||
|
role = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
(User.Role.TENANT_MANAGER, _('Manager')),
|
||||||
|
(User.Role.TENANT_STAFF, _('Staff')),
|
||||||
|
],
|
||||||
|
default=User.Role.TENANT_STAFF,
|
||||||
|
help_text="Role the invited user will have"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tenant association
|
||||||
|
tenant = models.ForeignKey(
|
||||||
|
'core.Tenant',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='staff_invitations',
|
||||||
|
help_text="Business the user is being invited to"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Invitation metadata
|
||||||
|
invited_by = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='sent_invitations',
|
||||||
|
help_text="User who sent the invitation"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Token for secure acceptance
|
||||||
|
token = models.CharField(max_length=64, unique=True)
|
||||||
|
|
||||||
|
# Status tracking
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
accepted_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
# Link to created user (after acceptance)
|
||||||
|
accepted_user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='accepted_invitation',
|
||||||
|
help_text="User account created when invitation was accepted"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bookable resource configuration
|
||||||
|
create_bookable_resource = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Whether to create a bookable resource for this staff member"
|
||||||
|
)
|
||||||
|
resource_name = models.CharField(
|
||||||
|
max_length=200,
|
||||||
|
blank=True,
|
||||||
|
help_text="Name for the bookable resource (defaults to user's name if empty)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Permissions configuration (stored as JSON for flexibility)
|
||||||
|
permissions = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
blank=True,
|
||||||
|
help_text="Permission settings for the invited user"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['token']),
|
||||||
|
models.Index(fields=['email', 'tenant', 'status']),
|
||||||
|
models.Index(fields=['status', 'expires_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Invitation for {self.email} to {self.tenant.name} ({self.get_status_display()})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.token:
|
||||||
|
self.token = secrets.token_urlsafe(32)
|
||||||
|
if not self.expires_at:
|
||||||
|
# Default expiration: 7 days
|
||||||
|
self.expires_at = timezone.now() + timedelta(days=7)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
"""Check if invitation can still be accepted"""
|
||||||
|
if self.status != self.Status.PENDING:
|
||||||
|
return False
|
||||||
|
if timezone.now() > self.expires_at:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def accept(self, user):
|
||||||
|
"""Mark invitation as accepted and link to user"""
|
||||||
|
self.status = self.Status.ACCEPTED
|
||||||
|
self.accepted_at = timezone.now()
|
||||||
|
self.accepted_user = user
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def decline(self):
|
||||||
|
"""Mark invitation as declined"""
|
||||||
|
self.status = self.Status.DECLINED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
"""Cancel a pending invitation"""
|
||||||
|
if self.status == self.Status.PENDING:
|
||||||
|
self.status = self.Status.CANCELLED
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_invitation(cls, email, role, tenant, invited_by,
|
||||||
|
create_bookable_resource=False, resource_name='', permissions=None):
|
||||||
|
"""
|
||||||
|
Create a new invitation, cancelling any existing pending invitations
|
||||||
|
for the same email/tenant combination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: Email address to invite
|
||||||
|
role: Role for the invited user (TENANT_MANAGER or TENANT_STAFF)
|
||||||
|
tenant: Tenant/business the user is being invited to
|
||||||
|
invited_by: User sending the invitation
|
||||||
|
create_bookable_resource: Whether to create a bookable resource when accepted
|
||||||
|
resource_name: Name for the bookable resource (optional)
|
||||||
|
permissions: Dict of permission settings (optional)
|
||||||
|
"""
|
||||||
|
# Cancel existing pending invitations for this email/tenant
|
||||||
|
cls.objects.filter(
|
||||||
|
email=email,
|
||||||
|
tenant=tenant,
|
||||||
|
status=cls.Status.PENDING
|
||||||
|
).update(status=cls.Status.CANCELLED)
|
||||||
|
|
||||||
|
# Create new invitation
|
||||||
|
return cls.objects.create(
|
||||||
|
email=email,
|
||||||
|
role=role,
|
||||||
|
tenant=tenant,
|
||||||
|
invited_by=invited_by,
|
||||||
|
create_bookable_resource=create_bookable_resource,
|
||||||
|
resource_name=resource_name,
|
||||||
|
permissions=permissions or {}
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user