feat: Implement tenant invitation system with onboarding wizard
Backend Implementation: - Add TenantInvitation model with lifecycle management (PENDING/ACCEPTED/EXPIRED/CANCELLED) - Create platform admin API endpoints for invitation CRUD operations - Add public token-based endpoints for invitation retrieval and acceptance - Implement schema_context wrappers to ensure tenant operations run in public schema - Add tenant permissions: can_manage_oauth_credentials, can_accept_payments, can_use_custom_domain, can_white_label, can_api_access - Fix tenant update/create serializers to handle multi-schema environment - Add migrations for tenant permissions and invitation system Frontend Implementation: - Create TenantInviteModal with comprehensive invitation form (350 lines) - Email, business name, subscription tier configuration - Custom user/resource limits - Platform permissions toggles - Future feature flags (video conferencing, event types, calendars, 2FA, logs, data deletion, POS, mobile app) - Build TenantOnboardPage with 4-step wizard for invitation acceptance - Step 1: Account setup (email, password, name) - Step 2: Business details (name, subdomain, contact) - Step 3: Payment setup (conditional based on permissions) - Step 4: Success confirmation with redirect - Extract BusinessCreateModal and BusinessEditModal into separate components - Refactor PlatformBusinesses from 1080 lines to 220 lines (80% reduction) - Add inactive businesses dropdown section (similar to staff page pattern) - Update masquerade button styling to match Users page - Remove deprecated "Add New Tenant" functionality in favor of invitation flow - Add /tenant-onboard route for public access API Integration: - Add platform.ts API functions for tenant invitations - Create React Query hooks in usePlatform.ts for invitation management - Implement proper error handling and success states - Add TypeScript interfaces for invitation types Testing: - Verified end-to-end invitation flow from creation to acceptance - Confirmed tenant, domain, and owner user creation - Validated schema context fixes for multi-tenant environment - Tested active/inactive business filtering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Outlet, useLocation, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import TopBar from '../components/TopBar';
|
||||
@@ -10,6 +10,88 @@ import { useStopMasquerade } from '../hooks/useAuth';
|
||||
import { MasqueradeStackEntry } from '../api/auth';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
|
||||
/**
|
||||
* Convert a hex color to HSL values
|
||||
*/
|
||||
function hexToHSL(hex: string): { h: number; s: number; l: number } {
|
||||
// Remove # if present
|
||||
hex = hex.replace(/^#/, '');
|
||||
|
||||
// Parse hex values
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSL values to hex color
|
||||
*/
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
||||
const m = l - c / 2;
|
||||
|
||||
let r = 0, g = 0, b = 0;
|
||||
|
||||
if (h < 60) { r = c; g = x; b = 0; }
|
||||
else if (h < 120) { r = x; g = c; b = 0; }
|
||||
else if (h < 180) { r = 0; g = c; b = x; }
|
||||
else if (h < 240) { r = 0; g = x; b = c; }
|
||||
else if (h < 300) { r = x; g = 0; b = c; }
|
||||
else { r = c; g = 0; b = x; }
|
||||
|
||||
const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a color palette from a base color
|
||||
*/
|
||||
function generateColorPalette(baseColor: string): Record<string, string> {
|
||||
const { h, s } = hexToHSL(baseColor);
|
||||
|
||||
return {
|
||||
50: hslToHex(h, Math.min(s, 30), 97),
|
||||
100: hslToHex(h, Math.min(s, 40), 94),
|
||||
200: hslToHex(h, Math.min(s, 50), 86),
|
||||
300: hslToHex(h, Math.min(s, 60), 74),
|
||||
400: hslToHex(h, Math.min(s, 70), 60),
|
||||
500: hslToHex(h, s, 50),
|
||||
600: baseColor, // Use the exact primary color for 600
|
||||
700: hslToHex(h, s, 40),
|
||||
800: hslToHex(h, s, 32),
|
||||
900: hslToHex(h, s, 24),
|
||||
};
|
||||
}
|
||||
|
||||
interface BusinessLayoutProps {
|
||||
business: Business;
|
||||
user: User;
|
||||
@@ -30,6 +112,38 @@ const BusinessLayout: React.FC<BusinessLayoutProps> = ({ business, user, darkMod
|
||||
|
||||
useScrollToTop();
|
||||
|
||||
// Generate brand color palette from business primary color
|
||||
const brandPalette = useMemo(() => {
|
||||
return generateColorPalette(business.primaryColor || '#2563eb');
|
||||
}, [business.primaryColor]);
|
||||
|
||||
// Set CSS custom properties for brand colors
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(brandPalette).forEach(([shade, color]) => {
|
||||
root.style.setProperty(`--color-brand-${shade}`, color);
|
||||
});
|
||||
|
||||
// Cleanup: reset to defaults when component unmounts
|
||||
return () => {
|
||||
const defaultColors: Record<string, string> = {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
};
|
||||
Object.entries(defaultColors).forEach(([shade, color]) => {
|
||||
root.style.setProperty(`--color-brand-${shade}`, color);
|
||||
});
|
||||
};
|
||||
}, [brandPalette]);
|
||||
|
||||
// Check for trial expiration and redirect
|
||||
useEffect(() => {
|
||||
// Don't check if already on trial-expired page
|
||||
|
||||
Reference in New Issue
Block a user