diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c63b3af..69d0160 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,6 +53,7 @@ import PlatformSettings from './pages/platform/PlatformSettings'; import ProfileSettings from './pages/ProfileSettings'; import VerifyEmail from './pages/VerifyEmail'; import EmailVerificationRequired from './pages/EmailVerificationRequired'; +import AcceptInvitePage from './pages/AcceptInvitePage'; const queryClient = new QueryClient({ defaultOptions: { @@ -210,6 +211,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> ); @@ -222,6 +224,7 @@ const AppContent: React.FC = () => { } /> } /> } /> + } /> } /> ); diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 43af178..cf359ff 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -15,6 +15,20 @@ import { } from '../api/auth'; 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 */ diff --git a/frontend/src/hooks/useInvitations.ts b/frontend/src/hooks/useInvitations.ts new file mode 100644 index 0000000..c5d3b40 --- /dev/null +++ b/frontend/src/hooks/useInvitations.ts @@ -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; +} + +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({ + 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({ + 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; + }, + }); +}; diff --git a/frontend/src/hooks/useStaff.ts b/frontend/src/hooks/useStaff.ts index c9f580b..f74a6bd 100644 --- a/frontend/src/hooks/useStaff.ts +++ b/frontend/src/hooks/useStaff.ts @@ -2,14 +2,22 @@ * Staff Management Hooks */ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import apiClient from '../api/client'; +export interface StaffPermissions { + can_invite_staff?: boolean; +} + export interface StaffMember { id: string; name: string; email: string; phone?: string; + role: string; + is_active: boolean; + permissions: StaffPermissions; + can_invite_staff: boolean; } interface StaffFilters { @@ -26,6 +34,7 @@ export const useStaff = (filters?: StaffFilters) => { queryFn: async () => { const params = new URLSearchParams(); 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}`); @@ -35,8 +44,54 @@ export const useStaff = (filters?: StaffFilters) => { name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email, email: s.email || '', 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, }); }; + +/** + * 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'] }); + }, + }); +}; diff --git a/frontend/src/pages/AcceptInvitePage.tsx b/frontend/src/pages/AcceptInvitePage.tsx new file mode 100644 index 0000000..36b0b4b --- /dev/null +++ b/frontend/src/pages/AcceptInvitePage.tsx @@ -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 ( +
+
+ +

+ {t('acceptInvite.invalidLink', 'Invalid Invitation Link')} +

+

+ {t('acceptInvite.noToken', 'This invitation link is invalid. Please check your email for the correct link.')} +

+
+
+ ); + } + + // Loading state + if (isLoading) { + return ( +
+
+ +

+ {t('acceptInvite.loading', 'Loading invitation...')} +

+
+
+ ); + } + + // Error state (invalid/expired token) + if (error || !invitation) { + return ( +
+
+ +

+ {t('acceptInvite.expiredTitle', 'Invitation Expired or Invalid')} +

+

+ {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.' + )} +

+
+
+ ); + } + + // Accepted state + if (accepted) { + return ( +
+
+ +

+ {t('acceptInvite.welcomeTitle', 'Welcome to the Team!')} +

+

+ {t('acceptInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')} +

+ +
+
+ ); + } + + // Declined state + if (declined) { + return ( +
+
+ +

+ {t('acceptInvite.declinedTitle', 'Invitation Declined')} +

+

+ {t('acceptInvite.declinedDescription', "You've declined this invitation. You can close this page.")} +

+
+
+ ); + } + + // Main acceptance form + return ( +
+
+ {/* Header */} +
+

+ {t('acceptInvite.title', "You're Invited!")} +

+

+ {t('acceptInvite.subtitle', 'Join the team and start scheduling')} +

+
+ + {/* Invitation Details */} +
+
+
+
+ +
+
+

+ {t('acceptInvite.business', 'Business')} +

+

{invitation.business_name}

+
+
+ +
+
+ +
+
+

+ {t('acceptInvite.invitedAs', 'Invited As')} +

+

+ {invitation.role_display} •{' '} + {invitation.email} +

+
+
+ + {invitation.invited_by && ( +

+ {t('acceptInvite.invitedBy', 'Invited by')} {invitation.invited_by} +

+ )} +
+
+ + {/* Form */} +
+

+ {t('acceptInvite.formDescription', 'Create your account to accept this invitation.')} +

+ +
+
+ +
+ + 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" + /> +
+
+ +
+ + 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" + /> +
+
+ +
+ +
+ + 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" + /> + +
+
+ +
+ +
+ + 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" + /> +
+
+ + {/* Error Message */} + {formError && ( +
+

{formError}

+
+ )} + + {/* Buttons */} +
+ + + +
+
+
+
+ ); +}; + +export default AcceptInvitePage; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 947c7a2..c38113f 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -16,7 +16,6 @@ import { useServices } from '../hooks/useServices'; import { useResources } from '../hooks/useResources'; import { useAppointments } from '../hooks/useAppointments'; import { useCustomers } from '../hooks/useCustomers'; -import QuickAddAppointment from '../components/QuickAddAppointment'; interface Metric { label: string; @@ -147,14 +146,9 @@ const Dashboard: React.FC = () => { ))} -
- {/* Quick Add Appointment */} -
- -
- +
{/* Revenue Chart */} -
+

{t('dashboard.totalRevenue')}

diff --git a/frontend/src/pages/Staff.tsx b/frontend/src/pages/Staff.tsx index e6d070a..dd0c396 100644 --- a/frontend/src/pages/Staff.tsx +++ b/frontend/src/pages/Staff.tsx @@ -1,182 +1,1111 @@ - import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { User } from '../types'; -import { useBusinessUsers, useCreateResource, useResources } from '../hooks/useBusiness'; +import { useCreateResource, useResources } from '../hooks/useBusiness'; +import { useStaff, useToggleStaffActive, useUpdateStaff, StaffMember } from '../hooks/useStaff'; import { - Plus, - MoreHorizontal, - User as UserIcon, - Shield, - Briefcase, - Calendar + useInvitations, + useCreateInvitation, + useCancelInvitation, + useResendInvitation, + StaffInvitation, + CreateInvitationData, +} from '../hooks/useInvitations'; +import { + Plus, + User as UserIcon, + Shield, + Briefcase, + Calendar, + X, + Mail, + Clock, + Loader2, + Send, + Trash2, + RefreshCw, + Pencil, + ChevronDown, + ChevronRight, + UserX, + Power, } from 'lucide-react'; import Portal from '../components/Portal'; interface StaffProps { - onMasquerade: (user: User) => void; - effectiveUser: User; + onMasquerade: (user: User) => void; + effectiveUser: User; } const Staff: React.FC = ({ onMasquerade, effectiveUser }) => { - const { t } = useTranslation(); - const { data: users = [], isLoading, error } = useBusinessUsers(); - const { data: resources = [] } = useResources(); - const createResourceMutation = useCreateResource(); + const { t } = useTranslation(); + const { data: staffMembers = [], isLoading, error } = useStaff(); + const { data: resources = [] } = useResources(); + const { data: invitations = [], isLoading: invitationsLoading } = useInvitations(); + const createResourceMutation = useCreateResource(); + const createInvitationMutation = useCreateInvitation(); + const cancelInvitationMutation = useCancelInvitation(); + const resendInvitationMutation = useResendInvitation(); + const toggleActiveMutation = useToggleStaffActive(); + const updateStaffMutation = useUpdateStaff(); - const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + const [inviteEmail, setInviteEmail] = useState(''); + const [inviteRole, setInviteRole] = useState<'TENANT_MANAGER' | 'TENANT_STAFF'>('TENANT_STAFF'); + const [createBookableResource, setCreateBookableResource] = useState(false); + const [resourceName, setResourceName] = useState(''); + const [invitePermissions, setInvitePermissions] = useState>({}); + const [inviteError, setInviteError] = useState(''); + const [inviteSuccess, setInviteSuccess] = useState(''); + const [showInactiveStaff, setShowInactiveStaff] = useState(false); - // Helper to check if a user is already linked to a resource - const getLinkedResource = (userId: string) => { - return resources.find((r: any) => r.user_id === parseInt(userId)); - }; + // Edit modal state + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editingStaff, setEditingStaff] = useState(null); + const [editPermissions, setEditPermissions] = useState>({}); + const [editError, setEditError] = useState(''); + const [editSuccess, setEditSuccess] = useState(''); - const handleMakeBookable = (user: any) => { - if (confirm(`Create a bookable resource for ${user.name || user.username}?`)) { - createResourceMutation.mutate({ - name: user.name || user.username, - type: 'STAFF', - user_id: user.id - }); - } - }; + // Check if user can invite managers (only owners can) + const canInviteManagers = effectiveUser.role === 'owner'; - if (isLoading) { - return ( -
-
-
-
-
- ); + // Separate active and inactive staff + const activeStaff = staffMembers.filter((s) => s.is_active); + const inactiveStaff = staffMembers.filter((s) => !s.is_active); + + // Helper to check if a user is already linked to a resource + const getLinkedResource = (userId: string) => { + return resources.find((r: any) => r.user_id === parseInt(userId)); + }; + + const handleMakeBookable = (user: any) => { + if (confirm(`Create a bookable resource for ${user.name || user.username}?`)) { + createResourceMutation.mutate({ + name: user.name || user.username, + type: 'STAFF', + user_id: user.id, + }); + } + }; + + const handleInviteSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setInviteError(''); + setInviteSuccess(''); + + if (!inviteEmail.trim()) { + setInviteError(t('staff.emailRequired', 'Email is required')); + return; } - if (error) { - return ( -
-
-

{t('staff.errorLoading')}: {(error as Error).message}

-
-
- ); + try { + const invitationData: CreateInvitationData = { + email: inviteEmail.trim().toLowerCase(), + role: inviteRole, + create_bookable_resource: createBookableResource, + resource_name: resourceName.trim(), + permissions: invitePermissions, + }; + + await createInvitationMutation.mutateAsync(invitationData); + setInviteSuccess(t('staff.invitationSent', 'Invitation sent successfully!')); + setInviteEmail(''); + setCreateBookableResource(false); + setResourceName(''); + setInvitePermissions({}); + // Close modal after short delay + setTimeout(() => { + setIsInviteModalOpen(false); + setInviteSuccess(''); + }, 1500); + } catch (err: any) { + setInviteError(err.response?.data?.error || t('staff.invitationFailed', 'Failed to send invitation')); } + }; - // Filter for staff/management roles - const staffUsers = users.filter((u: any) => ['owner', 'manager', 'staff'].includes(u.role)); + const handleCancelInvitation = async (invitation: StaffInvitation) => { + if (confirm(t('staff.confirmCancelInvitation', `Cancel invitation to ${invitation.email}?`))) { + try { + await cancelInvitationMutation.mutateAsync(invitation.id); + } catch (err: any) { + alert(err.response?.data?.error || t('staff.cancelFailed', 'Failed to cancel invitation')); + } + } + }; + const handleResendInvitation = async (invitation: StaffInvitation) => { + try { + await resendInvitationMutation.mutateAsync(invitation.id); + alert(t('staff.invitationResent', 'Invitation resent successfully!')); + } catch (err: any) { + alert(err.response?.data?.error || t('staff.resendFailed', 'Failed to resend invitation')); + } + }; + + const openInviteModal = () => { + setInviteEmail(''); + setInviteRole('TENANT_STAFF'); + setCreateBookableResource(false); + setResourceName(''); + setInvitePermissions({}); + setInviteError(''); + setInviteSuccess(''); + setIsInviteModalOpen(true); + }; + + if (isLoading) { return ( -
- {/* Header */} -
-
-

{t('staff.title')}

-

{t('staff.description')}

-
- -
- - {/* Table */} -
-
- - - - - - - - - - - {staffUsers.map((user: any) => { - const linkedResource = getLinkedResource(user.id); - - // Only owners can masquerade as staff (per backend permissions) - const canMasquerade = effectiveUser.role === 'owner' && user.id !== effectiveUser.id; - - return ( - - - - - - - ); - })} - -
{t('staff.name')}{t('staff.role')}{t('staff.bookableResource')}{t('common.actions')}
-
-
- {user.name ? user.name.charAt(0).toUpperCase() : user.username.charAt(0).toUpperCase()} -
-
-
{user.name || user.username}
-
{user.email}
-
-
-
- - {user.role === 'owner' && } - {user.role === 'manager' && } - {user.role} - - - {linkedResource ? ( - - - {t('staff.yes')} ({linkedResource.name}) - - ) : ( - - )} - -
- {canMasquerade && ( - - )} - -
-
-
-
- - {/* Invite Modal Placeholder */} - {isInviteModalOpen && ( - -
-
-

{t('staff.inviteModalTitle')}

-

{t('staff.inviteModalDescription')}

-
- -
-
-
-
- )} +
+
+
+
); + } + + if (error) { + return ( +
+
+

+ {t('staff.errorLoading')}: {(error as Error).message} +

+
+
+ ); + } + + const handleToggleActive = async (user: any) => { + const action = user.is_active ? 'deactivate' : 'reactivate'; + if (confirm(t('staff.confirmToggleActive', `Are you sure you want to ${action} ${user.name}?`))) { + try { + await toggleActiveMutation.mutateAsync(user.id); + } catch (err: any) { + alert(err.response?.data?.error || t('staff.toggleFailed', `Failed to ${action} staff member`)); + } + } + }; + + const openEditModal = (staff: StaffMember) => { + setEditingStaff(staff); + setEditPermissions(staff.permissions || {}); + setEditError(''); + setEditSuccess(''); + setIsEditModalOpen(true); + }; + + const closeEditModal = () => { + setIsEditModalOpen(false); + setEditingStaff(null); + setEditPermissions({}); + setEditError(''); + setEditSuccess(''); + }; + + const handleSaveStaffSettings = async () => { + if (!editingStaff) return; + + setEditError(''); + try { + await updateStaffMutation.mutateAsync({ + id: editingStaff.id, + updates: { permissions: editPermissions }, + }); + setEditSuccess(t('staff.settingsSaved', 'Settings saved successfully')); + setTimeout(() => { + closeEditModal(); + }, 1000); + } catch (err: any) { + setEditError(err.response?.data?.error || t('staff.saveFailed', 'Failed to save settings')); + } + }; + + const handleDeactivateFromModal = async () => { + if (!editingStaff) return; + + const action = editingStaff.is_active ? 'deactivate' : 'reactivate'; + if (confirm(t('staff.confirmToggleActive', `Are you sure you want to ${action} ${editingStaff.name}?`))) { + try { + await toggleActiveMutation.mutateAsync(editingStaff.id); + closeEditModal(); + } catch (err: any) { + setEditError(err.response?.data?.error || t('staff.toggleFailed', `Failed to ${action} staff member`)); + } + } + }; + + return ( +
+ {/* Header */} +
+
+

{t('staff.title')}

+

{t('staff.description')}

+
+ +
+ + {/* Pending Invitations */} + {invitations.length > 0 && ( +
+

+ + {t('staff.pendingInvitations', 'Pending Invitations')} ({invitations.length}) +

+
+ {invitations.map((invitation) => ( +
+
+
+ +
+
+
{invitation.email}
+
+ {invitation.role_display} • {t('staff.expires', 'Expires')}{' '} + {new Date(invitation.expires_at).toLocaleDateString()} +
+
+
+
+ + +
+
+ ))} +
+
+ )} + + {/* Table */} +
+
+ + + + + + + + + + + {activeStaff.map((user: any) => { + const linkedResource = getLinkedResource(user.id); + + // Only owners can masquerade as staff (per backend permissions) + const canMasquerade = effectiveUser.role === 'owner' && user.id !== effectiveUser.id; + // Owners can deactivate anyone except themselves + const canDeactivate = effectiveUser.role === 'owner' && user.id !== effectiveUser.id && user.role !== 'owner'; + + return ( + + + + + + + ); + })} + +
{t('staff.name')}{t('staff.role')}{t('staff.bookableResource')}{t('common.actions')}
+
+
+ {user.name ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()} +
+
+
{user.name || user.email}
+
{user.email}
+
+
+
+ + {user.role === 'owner' && } + {user.role === 'manager' && } + {user.role} + + + {linkedResource ? ( + + + {t('staff.yes')} ({linkedResource.name}) + + ) : ( + + )} + +
+ {canMasquerade && ( + + )} + +
+
+ {activeStaff.length === 0 && ( +
+ +

{t('staff.noStaffFound', 'No staff members found')}

+

+ {t('staff.inviteFirstStaff', 'Invite your first team member to get started')} +

+
+ )} +
+
+ + {/* Inactive Staff Section */} + {inactiveStaff.length > 0 && ( +
+ + + {showInactiveStaff && ( +
+
+ + + {inactiveStaff.map((user: any) => { + const linkedResource = getLinkedResource(user.id); + + return ( + + + + + + + ); + })} + +
+
+
+ {user.name ? user.name.charAt(0).toUpperCase() : user.email.charAt(0).toUpperCase()} +
+
+
{user.name || user.email}
+
{user.email}
+
+
+
+ + {user.role === 'owner' && } + {user.role === 'manager' && } + {user.role} + + + {linkedResource ? ( + + + {linkedResource.name} + + ) : ( + - + )} + + +
+
+
+ )} +
+ )} + + {/* Invite Modal */} + {isInviteModalOpen && ( + +
+
+
+

{t('staff.inviteStaff')}

+ +
+ +
+

+ {t( + 'staff.inviteDescription', + "Enter the email address of the person you'd like to invite. They'll receive an email with instructions to join your team." + )} +

+ + {/* Email Input */} +
+ + setInviteEmail(e.target.value)} + placeholder={t('staff.emailPlaceholder', 'colleague@example.com')} + required + 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" + /> +
+ + {/* Role Selector */} +
+ + +

+ {inviteRole === 'TENANT_MANAGER' + ? t('staff.managerRoleHint', 'Managers can manage staff, resources, and view reports') + : t('staff.staffRoleHint', 'Staff members can manage their own schedule and appointments')} +

+
+ + {/* Manager Permissions */} + {inviteRole === 'TENANT_MANAGER' && ( +
+

+ {t('staff.managerPermissions', 'Manager Permissions')} +

+ + {/* Can Invite Staff */} + + + {/* Can Manage Resources */} + + + {/* Can Manage Services */} + + + {/* Can View Reports */} + + + {/* Can Access Settings */} + + + {/* Can Refund Payments */} + +
+ )} + + {/* Staff Permissions */} + {inviteRole === 'TENANT_STAFF' && ( +
+

+ {t('staff.staffPermissions', 'Staff Permissions')} +

+ + {/* Can View All Schedules */} + + + {/* Can Manage Own Appointments */} + +
+ )} + + {/* Make Bookable Option */} +
+ + + {/* Resource Name (only shown if bookable is checked) */} + {createBookableResource && ( +
+ + setResourceName(e.target.value)} + placeholder={t('staff.resourceNamePlaceholder', "Defaults to person's name")} + className="w-full px-3 py-2 text-sm 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" + /> +
+ )} +
+ + {/* Error Message */} + {inviteError && ( +
+

{inviteError}

+
+ )} + + {/* Success Message */} + {inviteSuccess && ( +
+

{inviteSuccess}

+
+ )} + + {/* Buttons */} +
+ + +
+
+
+
+
+ )} + + {/* Edit Staff Modal */} + {isEditModalOpen && editingStaff && ( + +
+
+
+

+ {t('staff.editStaff', 'Edit Staff Member')} +

+ +
+ +
+ {/* Staff Info */} +
+
+ {editingStaff.name.charAt(0).toUpperCase()} +
+
+
{editingStaff.name}
+
{editingStaff.email}
+
+ + {editingStaff.role === 'owner' && } + {editingStaff.role === 'manager' && } + {editingStaff.role} + +
+ + {/* Manager Permissions Section */} + {editingStaff.role === 'manager' && ( +
+

+ {t('staff.managerPermissions', 'Manager Permissions')} +

+ + {/* Can Invite Staff */} + + + {/* Can Manage Resources */} + + + {/* Can Manage Services */} + + + {/* Can View Reports */} + + + {/* Can Access Settings */} + + + {/* Can Refund Payments */} + +
+ )} + + {/* Staff Permissions Section (for non-managers) */} + {editingStaff.role === 'staff' && ( +
+

+ {t('staff.staffPermissions', 'Staff Permissions')} +

+ + {/* Can View Own Schedule Only */} + + + {/* Can Manage Own Appointments */} + +
+ )} + + {/* No permissions for owners */} + {editingStaff.role === 'owner' && ( +
+

+ {t('staff.ownerFullAccess', 'Owners have full access to all features and settings.')} +

+
+ )} + + {/* Error Message */} + {editError && ( +
+

{editError}

+
+ )} + + {/* Success Message */} + {editSuccess && ( +
+

{editSuccess}

+
+ )} + + {/* Danger Zone - Deactivate (only for non-owners, and current user can't deactivate themselves) */} + {editingStaff.role !== 'owner' && effectiveUser.id !== editingStaff.id && effectiveUser.role === 'owner' && ( +
+

+ {t('staff.dangerZone', 'Danger Zone')} +

+
+
+
+

+ {editingStaff.is_active + ? t('staff.deactivateAccount', 'Deactivate Account') + : t('staff.reactivateAccount', 'Reactivate Account')} +

+

+ {editingStaff.is_active + ? t('staff.deactivateHint', 'Prevent this user from logging in while keeping their data') + : t('staff.reactivateHint', 'Allow this user to log in again')} +

+
+ +
+
+
+ )} + + {/* Action Buttons */} +
+ + {editingStaff.role !== 'owner' && ( + + )} +
+
+
+
+
+ )} +
+ ); }; export default Staff; diff --git a/smoothschedule/config/urls.py b/smoothschedule/config/urls.py index 62d2d03..cbb760c 100644 --- a/smoothschedule/config/urls.py +++ b/smoothschedule/config/urls.py @@ -12,7 +12,9 @@ from rest_framework.authtoken.views import obtain_auth_token from smoothschedule.users.api_views import ( 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 @@ -47,6 +49,13 @@ urlpatterns += [ # Hijack (masquerade) API path("api/auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"), 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//", cancel_invitation_view, name="cancel_invitation"), + path("api/staff/invitations//resend/", resend_invitation_view, name="resend_invitation"), + path("api/staff/invitations/token//", invitation_details_view, name="invitation_details"), + path("api/staff/invitations/token//accept/", accept_invitation_view, name="accept_invitation"), + path("api/staff/invitations/token//decline/", decline_invitation_view, name="decline_invitation"), # Business API path("api/business/current/", current_business_view, name="current_business"), path("api/business/current/update/", update_business_view, name="update_business"), diff --git a/smoothschedule/schedule/serializers.py b/smoothschedule/schedule/serializers.py index 3836c5e..cd380d5 100644 --- a/smoothschedule/schedule/serializers.py +++ b/smoothschedule/schedule/serializers.py @@ -102,13 +102,15 @@ class StaffSerializer(serializers.ModelSerializer): """Serializer for Staff members (Users with staff roles)""" name = serializers.SerializerMethodField() role = serializers.SerializerMethodField() + can_invite_staff = serializers.SerializerMethodField() class Meta: model = User fields = [ '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): return obj.full_name @@ -122,6 +124,9 @@ class StaffSerializer(serializers.ModelSerializer): } 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): """Serializer for Service model""" @@ -139,19 +144,13 @@ class ServiceSerializer(serializers.ModelSerializer): class ResourceSerializer(serializers.ModelSerializer): """Serializer for Resource model""" 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 = serializers.PrimaryKeyRelatedField( - queryset=User.objects.all(), - required=False, - allow_null=True, - write_only=True - ) class Meta: model = Resource fields = [ - 'id', 'name', 'type', 'user', 'user_id', 'user_name', + 'id', 'name', 'type', 'user_id', 'user_name', 'description', 'max_concurrent_events', 'buffer_duration', 'is_active', 'capacity_description', 'saved_lane_count', 'created_at', 'updated_at', @@ -165,6 +164,35 @@ class ResourceSerializer(serializers.ModelSerializer): return "Exclusive use (1 at a time)" 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): """Serializer for Participant model""" diff --git a/smoothschedule/schedule/views.py b/smoothschedule/schedule/views.py index 540f14d..32ed461 100644 --- a/smoothschedule/schedule/views.py +++ b/smoothschedule/schedule/views.py @@ -282,12 +282,17 @@ class ServiceViewSet(viewsets.ModelViewSet): 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. - 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 # TODO: Re-enable authentication for production @@ -297,6 +302,10 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet): ordering_fields = ['email', '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): """ Return staff members for the current tenant. @@ -305,11 +314,17 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet): """ 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( Q(role=User.Role.TENANT_OWNER) | Q(role=User.Role.TENANT_MANAGER) | 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 # TODO: Re-enable this when authentication is enabled @@ -326,3 +341,55 @@ class StaffViewSet(viewsets.ReadOnlyModelViewSet): ) 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." + }) diff --git a/smoothschedule/smoothschedule/users/api_views.py b/smoothschedule/smoothschedule/users/api_views.py index 583e8f9..24e75a1 100644 --- a/smoothschedule/smoothschedule/users/api_views.py +++ b/smoothschedule/smoothschedule/users/api_views.py @@ -12,8 +12,10 @@ from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response 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 rest_framework import serializers +from schedule.models import Resource, ResourceType @api_view(['GET']) @@ -61,9 +63,12 @@ def current_user_view(request): 'email_verified': user.email_verified, 'is_staff': user.is_staff, 'is_superuser': user.is_superuser, + 'is_active': user.is_active, 'business': user.tenant_id, 'business_name': business_name, 'business_subdomain': business_subdomain, + 'permissions': user.permissions, + 'can_invite_staff': user.can_invite_staff(), } return Response(user_data, status=status.HTTP_200_OK) @@ -201,9 +206,12 @@ def _get_user_data(user): 'email_verified': user.email_verified, 'is_staff': user.is_staff, 'is_superuser': user.is_superuser, + 'is_active': user.is_active, 'business': user.tenant_id, 'business_name': business_name, '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), 'masquerade_stack': masquerade_stack, # Return remaining stack (should be empty now) }, 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// + """ + 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//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// + """ + 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//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//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, + ) diff --git a/smoothschedule/smoothschedule/users/migrations/0004_add_staff_invitation.py b/smoothschedule/smoothschedule/users/migrations/0004_add_staff_invitation.py new file mode 100644 index 0000000..d1975e1 --- /dev/null +++ b/smoothschedule/smoothschedule/users/migrations/0004_add_staff_invitation.py @@ -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')], + }, + ), + ] diff --git a/smoothschedule/smoothschedule/users/migrations/0005_add_bookable_and_permissions_to_invitation.py b/smoothschedule/smoothschedule/users/migrations/0005_add_bookable_and_permissions_to_invitation.py new file mode 100644 index 0000000..0956505 --- /dev/null +++ b/smoothschedule/smoothschedule/users/migrations/0005_add_bookable_and_permissions_to_invitation.py @@ -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), + ), + ] diff --git a/smoothschedule/smoothschedule/users/migrations/0006_add_permissions_to_user.py b/smoothschedule/smoothschedule/users/migrations/0006_add_permissions_to_user.py new file mode 100644 index 0000000..679f78b --- /dev/null +++ b/smoothschedule/smoothschedule/users/migrations/0006_add_permissions_to_user.py @@ -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'), + ), + ] diff --git a/smoothschedule/smoothschedule/users/models.py b/smoothschedule/smoothschedule/users/models.py index e0cff5e..8b4700c 100644 --- a/smoothschedule/smoothschedule/users/models.py +++ b/smoothschedule/smoothschedule/users/models.py @@ -64,7 +64,14 @@ class User(AbstractUser): # Additional profile fields phone = models.CharField(max_length=20, 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 created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -126,6 +133,16 @@ class User(AbstractUser): self.Role.PLATFORM_MANAGER, 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): """ @@ -195,3 +212,172 @@ class EmailVerificationToken(models.Model): cls.objects.filter(user=user, used=False).update(used=True) # Create new token 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 {} + )