Demo Tenant: - Add block_emails field to Tenant model for demo accounts - Add is_email_blocked() and wrapper functions in email_service - Create reseed_demo management command with salon/spa theme - Add Celery beat task for daily reseed at midnight UTC - Create 100 appointments, 20 customers, 13 services, 12 resources Staff Roles: - Add StaffRole model with permission toggles - Create default roles: Full Access, Front Desk, Limited Staff - Add StaffRolesSettings page and hooks - Integrate role assignment in Staff management Bug Fixes: - Fix masquerade redirect using wrong role names (tenant_owner vs owner) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
284 lines
8.9 KiB
TypeScript
284 lines
8.9 KiB
TypeScript
/**
|
|
* Authentication Hooks
|
|
*/
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
login,
|
|
logout,
|
|
getCurrentUser,
|
|
masquerade,
|
|
stopMasquerade,
|
|
forgotPassword,
|
|
LoginCredentials,
|
|
User,
|
|
MasqueradeStackEntry
|
|
} from '../api/auth';
|
|
import { getCookie, setCookie, deleteCookie } from '../utils/cookies';
|
|
import { getBaseDomain, buildSubdomainUrl } from '../utils/domain';
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export const useCurrentUser = () => {
|
|
return useQuery<User | null, Error>({
|
|
queryKey: ['currentUser'],
|
|
queryFn: async () => {
|
|
// Check if token exists before making request (from cookie)
|
|
const token = getCookie('access_token');
|
|
|
|
if (!token) {
|
|
return null; // No token, return null instead of making request
|
|
}
|
|
try {
|
|
return await getCurrentUser();
|
|
} catch (error) {
|
|
// If getCurrentUser fails (e.g., 401), return null
|
|
// The API client interceptor will handle token refresh
|
|
console.error('Failed to get current user:', error);
|
|
return null;
|
|
}
|
|
},
|
|
retry: 1, // Retry once in case of token refresh
|
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
|
refetchOnMount: true, // Always refetch when component mounts
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to login
|
|
*/
|
|
export const useLogin = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: login,
|
|
onSuccess: (data) => {
|
|
// Store tokens in cookies for cross-subdomain access
|
|
setCookie('access_token', data.access, 7);
|
|
setCookie('refresh_token', data.refresh, 7);
|
|
|
|
// Clear any existing masquerade stack - this is a fresh login
|
|
localStorage.removeItem('masquerade_stack');
|
|
|
|
// Set user in cache
|
|
queryClient.setQueryData(['currentUser'], data.user);
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to logout
|
|
*/
|
|
export const useLogout = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: logout,
|
|
onSuccess: () => {
|
|
// Clear tokens (from cookies)
|
|
deleteCookie('access_token');
|
|
deleteCookie('refresh_token');
|
|
|
|
// Clear masquerade stack
|
|
localStorage.removeItem('masquerade_stack');
|
|
|
|
// Clear user cache
|
|
queryClient.removeQueries({ queryKey: ['currentUser'] });
|
|
queryClient.clear();
|
|
|
|
// Redirect to login page on root domain
|
|
const protocol = window.location.protocol;
|
|
const baseDomain = getBaseDomain();
|
|
const port = window.location.port ? `:${window.location.port}` : '';
|
|
window.location.href = `${protocol}//${baseDomain}${port}/login`;
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Check if user is authenticated
|
|
*/
|
|
export const useIsAuthenticated = (): boolean => {
|
|
const { data: user, isLoading } = useCurrentUser();
|
|
return !isLoading && !!user;
|
|
};
|
|
|
|
/**
|
|
* Get the redirect path based on user role
|
|
* Tenant users go to /dashboard/, platform users go to /
|
|
* Note: Backend maps tenant_owner -> owner, tenant_manager -> manager, etc.
|
|
*/
|
|
const getRedirectPathForRole = (role: string): string => {
|
|
// Tenant roles (as returned by backend after role mapping)
|
|
const tenantRoles = ['owner', 'manager', 'staff', 'customer'];
|
|
if (tenantRoles.includes(role)) {
|
|
return '/dashboard/';
|
|
}
|
|
return '/';
|
|
};
|
|
|
|
/**
|
|
* Hook to masquerade as another user
|
|
*/
|
|
export const useMasquerade = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async (user_pk: number) => {
|
|
// Get current masquerading stack from localStorage
|
|
const stackJson = localStorage.getItem('masquerade_stack');
|
|
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
|
|
|
|
// Call masquerade API with current stack
|
|
return masquerade(user_pk, currentStack);
|
|
},
|
|
onSuccess: async (data) => {
|
|
// Store the updated masquerading stack
|
|
if (data.masquerade_stack) {
|
|
localStorage.setItem('masquerade_stack', JSON.stringify(data.masquerade_stack));
|
|
}
|
|
|
|
const user = data.user;
|
|
const currentHostname = window.location.hostname;
|
|
const currentPort = window.location.port;
|
|
const baseDomain = getBaseDomain();
|
|
|
|
let targetSubdomain: string | null = null;
|
|
|
|
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
|
|
targetSubdomain = 'platform';
|
|
} else if (user.business_subdomain) {
|
|
targetSubdomain = user.business_subdomain;
|
|
}
|
|
|
|
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.${baseDomain}`;
|
|
const redirectPath = getRedirectPathForRole(user.role);
|
|
|
|
if (needsRedirect) {
|
|
// CRITICAL: Clear the session cookie BEFORE redirect
|
|
// Call logout API to clear HttpOnly sessionid cookie
|
|
try {
|
|
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${baseDomain}`;
|
|
await fetch(`${apiUrl}/auth/logout/`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
} catch (e) {
|
|
// Continue anyway
|
|
}
|
|
|
|
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
|
|
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
|
|
const redirectUrl = buildSubdomainUrl(targetSubdomain, `${redirectPath}?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`);
|
|
|
|
window.location.href = redirectUrl;
|
|
return;
|
|
}
|
|
|
|
// If no redirect needed (same subdomain), we can just set cookies and navigate
|
|
setCookie('access_token', data.access, 7);
|
|
setCookie('refresh_token', data.refresh, 7);
|
|
queryClient.setQueryData(['currentUser'], data.user);
|
|
window.location.href = redirectPath;
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to stop masquerading and return to previous user
|
|
*/
|
|
export const useStopMasquerade = () => {
|
|
const queryClient = useQueryClient();
|
|
|
|
return useMutation({
|
|
mutationFn: async () => {
|
|
// Get current masquerading stack from localStorage
|
|
const stackJson = localStorage.getItem('masquerade_stack');
|
|
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
|
|
|
|
if (currentStack.length === 0) {
|
|
throw new Error('No masquerading session to stop');
|
|
}
|
|
|
|
// Call stop_masquerade API with current stack
|
|
return stopMasquerade(currentStack);
|
|
},
|
|
onSuccess: async (data) => {
|
|
// Update the masquerading stack
|
|
if (data.masquerade_stack && data.masquerade_stack.length > 0) {
|
|
localStorage.setItem('masquerade_stack', JSON.stringify(data.masquerade_stack));
|
|
} else {
|
|
// Clear the stack if empty
|
|
localStorage.removeItem('masquerade_stack');
|
|
}
|
|
|
|
const user = data.user;
|
|
const currentHostname = window.location.hostname;
|
|
const currentPort = window.location.port;
|
|
const baseDomain = getBaseDomain();
|
|
|
|
let targetSubdomain: string | null = null;
|
|
|
|
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
|
|
targetSubdomain = 'platform';
|
|
} else if (user.business_subdomain) {
|
|
targetSubdomain = user.business_subdomain;
|
|
}
|
|
|
|
const needsRedirect = targetSubdomain && currentHostname !== `${targetSubdomain}.${baseDomain}`;
|
|
const redirectPath = getRedirectPathForRole(user.role);
|
|
|
|
if (needsRedirect) {
|
|
// CRITICAL: Clear the session cookie BEFORE redirect
|
|
try {
|
|
const apiUrl = import.meta.env.VITE_API_URL || `${window.location.protocol}//${baseDomain}`;
|
|
await fetch(`${apiUrl}/auth/logout/`, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
});
|
|
} catch (e) {
|
|
// Continue anyway
|
|
}
|
|
|
|
// Pass tokens AND masquerading stack in URL (for cross-domain transfer)
|
|
const stackEncoded = encodeURIComponent(JSON.stringify(data.masquerade_stack || []));
|
|
const redirectUrl = buildSubdomainUrl(targetSubdomain, `${redirectPath}?access_token=${data.access}&refresh_token=${data.refresh}&masquerade_stack=${stackEncoded}`);
|
|
|
|
window.location.href = redirectUrl;
|
|
return;
|
|
}
|
|
|
|
// If no redirect needed (same subdomain), we can just set cookies and navigate
|
|
setCookie('access_token', data.access, 7);
|
|
setCookie('refresh_token', data.refresh, 7);
|
|
queryClient.setQueryData(['currentUser'], data.user);
|
|
window.location.href = redirectPath;
|
|
},
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Hook to request password reset
|
|
*/
|
|
export const useForgotPassword = () => {
|
|
return useMutation({
|
|
mutationFn: (data: { email: string }) => forgotPassword(data.email),
|
|
});
|
|
};
|