Add platform email templates, staff invitations, and quota tracking

- Add PlatformEmailTemplate model and API for superuser-managed email templates
- Add PlatformStaffInvitation model with email sending via Celery tasks
- Add platform staff invite page and acceptance flow with auto-login
- Add quota tracking models (DailyAppointmentUsage, DailyAPIUsage, StorageUsage)
- Add quota status API endpoints and frontend banners
- Add storage usage service for tenant media tracking
- Fix platform user deletion with raw SQL to handle multi-tenant FK constraints
- Update EditPlatformUserModal with archive/delete buttons
- Update PlatformSidebar with email templates link for superusers
- Configure console email backend and Celery eager mode for local development

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2026-01-01 10:35:35 -05:00
parent f13a40e4bc
commit fc63cf4fce
66 changed files with 8145 additions and 666 deletions

View File

@@ -66,10 +66,12 @@ const PlatformStaff = React.lazy(() => import('./pages/platform/PlatformStaff'))
const PlatformSettings = React.lazy(() => import('./pages/platform/PlatformSettings'));
const BillingManagement = React.lazy(() => import('./pages/platform/BillingManagement'));
const PlatformStaffEmail = React.lazy(() => import('./pages/platform/PlatformStaffEmail'));
const PlatformEmailTemplates = React.lazy(() => import('./pages/platform/PlatformEmailTemplates'));
const ProfileSettings = React.lazy(() => import('./pages/ProfileSettings'));
const VerifyEmail = React.lazy(() => import('./pages/VerifyEmail'));
const EmailVerificationRequired = React.lazy(() => import('./pages/EmailVerificationRequired'));
const AcceptInvitePage = React.lazy(() => import('./pages/AcceptInvitePage'));
const PlatformStaffInvitePage = React.lazy(() => import('./pages/platform/PlatformStaffInvitePage'));
const TenantOnboardPage = React.lazy(() => import('./pages/TenantOnboardPage'));
const TenantLandingPage = React.lazy(() => import('./pages/TenantLandingPage'));
const Tickets = React.lazy(() => import('./pages/Tickets')); // Import Tickets page
@@ -375,6 +377,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -411,6 +414,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -419,10 +423,10 @@ const AppContent: React.FC = () => {
);
}
// For platform subdomain, only /platform/login exists - everything else renders nothing
// For platform subdomain, only specific paths exist - everything else renders nothing
if (isPlatformSubdomain) {
const path = window.location.pathname;
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email'];
const allowedPaths = ['/platform/login', '/mfa-verify', '/verify-email', '/platform-staff-invite'];
// If not an allowed path, render nothing
if (!allowedPaths.includes(path)) {
@@ -435,6 +439,7 @@ const AppContent: React.FC = () => {
<Route path="/platform/login" element={<PlatformLoginPage />} />
<Route path="/mfa-verify" element={<MFAVerifyPage />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
</Routes>
</Suspense>
);
@@ -460,6 +465,7 @@ const AppContent: React.FC = () => {
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/accept-invite" element={<AcceptInvitePage />} />
<Route path="/accept-invite/:token" element={<AcceptInvitePage />} />
<Route path="/platform-staff-invite" element={<PlatformStaffInvitePage />} />
<Route path="/tenant-onboard" element={<TenantOnboardPage />} />
<Route path="/sign/:token" element={<ContractSigning />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -599,6 +605,7 @@ const AppContent: React.FC = () => {
<>
<Route path="/platform/settings" element={<PlatformSettings />} />
<Route path="/platform/billing" element={<BillingManagement />} />
<Route path="/platform/email-templates" element={<PlatformEmailTemplates />} />
</>
)}
<Route path="/platform/profile" element={<ProfileSettings />} />

View File

@@ -9,6 +9,7 @@
import React, { useState, useMemo } from 'react';
import { Check, Sliders, Search, X } from 'lucide-react';
import type { Feature, PlanFeatureWrite } from '../../hooks/useBillingAdmin';
import { isWipFeature } from '../featureCatalog';
export interface FeaturePickerProps {
/** Available features from the API */
@@ -168,8 +169,13 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">
<span className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
{feature.code}
@@ -219,8 +225,13 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white block">
<span className="text-sm font-medium text-gray-900 dark:text-white flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
{feature.code}

View File

@@ -34,6 +34,8 @@ export interface FeatureCatalogEntry {
description: string;
type: FeatureType;
category: FeatureCategory;
/** Feature is work-in-progress and not yet enforced */
wip?: boolean;
}
export type FeatureCategory =
@@ -66,13 +68,6 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
type: 'boolean',
category: 'communication',
},
{
code: 'proxy_number_enabled',
name: 'Proxy Phone Numbers',
description: 'Use proxy phone numbers for customer communication',
type: 'boolean',
category: 'communication',
},
// Payments & Commerce
{
@@ -88,6 +83,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Use Point of Sale (POS) system',
type: 'boolean',
category: 'access',
wip: true,
},
// Scheduling & Booking
@@ -97,27 +93,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Schedule recurring appointments',
type: 'boolean',
category: 'scheduling',
},
{
code: 'group_bookings',
name: 'Group Bookings',
description: 'Allow multiple customers per appointment',
type: 'boolean',
category: 'scheduling',
},
{
code: 'waitlist',
name: 'Waitlist',
description: 'Enable waitlist for fully booked slots',
type: 'boolean',
category: 'scheduling',
},
{
code: 'can_add_video_conferencing',
name: 'Video Conferencing',
description: 'Add video conferencing to events',
type: 'boolean',
category: 'scheduling',
wip: true,
},
// Access & Features
@@ -127,13 +103,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Access the public API for integrations',
type: 'boolean',
category: 'access',
},
{
code: 'can_use_analytics',
name: 'Analytics Dashboard',
description: 'Access business analytics and reporting',
type: 'boolean',
category: 'access',
wip: true,
},
{
code: 'can_use_tasks',
@@ -149,19 +119,13 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
type: 'boolean',
category: 'access',
},
{
code: 'customer_portal',
name: 'Customer Portal',
description: 'Branded self-service portal for customers',
type: 'boolean',
category: 'access',
},
{
code: 'custom_fields',
name: 'Custom Fields',
description: 'Create custom data fields for resources and events',
description: 'Add custom intake fields to services for customer booking',
type: 'boolean',
category: 'access',
wip: true,
},
{
code: 'can_export_data',
@@ -169,44 +133,26 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Export data (appointments, customers, etc.)',
type: 'boolean',
category: 'access',
wip: true,
},
{
code: 'can_use_mobile_app',
code: 'mobile_app_access',
name: 'Mobile App',
description: 'Access the mobile app for field employees',
type: 'boolean',
category: 'access',
wip: true,
},
{
code: 'proxy_number_enabled',
name: 'Proxy Phone Numbers',
description: 'Assign dedicated phone numbers to staff for customer communication',
type: 'boolean',
category: 'communication',
wip: true,
},
// Integrations
{
code: 'calendar_sync',
name: 'Calendar Sync',
description: 'Sync with Google Calendar, Outlook, etc.',
type: 'boolean',
category: 'integrations',
},
{
code: 'webhooks_enabled',
name: 'Webhooks',
description: 'Send webhook notifications for events',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_use_plugins',
name: 'Plugin Integrations',
description: 'Use third-party plugin integrations',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_create_plugins',
name: 'Create Plugins',
description: 'Create custom plugins for automation',
type: 'boolean',
category: 'integrations',
},
{
code: 'can_manage_oauth_credentials',
name: 'Manage OAuth',
@@ -217,21 +163,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
// Branding
{
code: 'custom_branding',
name: 'Custom Branding',
description: 'Customize branding colors, logo, and styling',
type: 'boolean',
category: 'branding',
},
{
code: 'remove_branding',
name: 'Remove Branding',
description: 'Remove SmoothSchedule branding from customer-facing pages',
type: 'boolean',
category: 'branding',
},
{
code: 'can_use_custom_domain',
code: 'custom_domain',
name: 'Custom Domain',
description: 'Configure a custom domain for your booking page',
type: 'boolean',
@@ -245,6 +177,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Get priority customer support response',
type: 'boolean',
category: 'support',
wip: true,
},
// Security & Compliance
@@ -254,6 +187,7 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Require two-factor authentication for users',
type: 'boolean',
category: 'security',
wip: true,
},
{
code: 'sso_enabled',
@@ -261,20 +195,15 @@ export const BOOLEAN_FEATURES: FeatureCatalogEntry[] = [
description: 'Enable SSO authentication for team members',
type: 'boolean',
category: 'security',
wip: true,
},
{
code: 'can_delete_data',
name: 'Delete Data',
description: 'Permanently delete data',
type: 'boolean',
category: 'security',
},
{
code: 'can_download_logs',
name: 'Download Logs',
description: 'Download system logs',
code: 'audit_logs',
name: 'Audit Logs',
description: 'Track changes and download audit logs',
type: 'boolean',
category: 'security',
wip: true,
},
];
@@ -406,6 +335,14 @@ export const isCanonicalFeature = (code: string): boolean => {
return featureMap.has(code);
};
/**
* Check if a feature is work-in-progress (not yet enforced)
*/
export const isWipFeature = (code: string): boolean => {
const feature = featureMap.get(code);
return feature?.wip ?? false;
};
/**
* Get all features by type
*/

View File

@@ -0,0 +1,150 @@
/**
* AppointmentQuotaBanner Component
*
* Shows a warning banner when the user has reached 90% of their monthly
* appointment quota. Dismissable per-user per-billing-period.
*
* This is different from QuotaWarningBanner which handles grace period
* overages for permanent limits like max_users.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { AlertTriangle, X, TrendingUp, Calendar } from 'lucide-react';
import { useQuotaStatus, useDismissQuotaBanner } from '../hooks/useQuotaStatus';
interface AppointmentQuotaBannerProps {
/** Only show for owners/managers who can take action */
userRole?: string;
}
const AppointmentQuotaBanner: React.FC<AppointmentQuotaBannerProps> = ({ userRole }) => {
const { t } = useTranslation();
const { data: quotaStatus, isLoading } = useQuotaStatus();
const dismissMutation = useDismissQuotaBanner();
// Don't show while loading or if no data
if (isLoading || !quotaStatus) {
return null;
}
// Don't show if banner shouldn't be shown
if (!quotaStatus.warning.show_banner) {
return null;
}
// Only show for owners and managers who can take action
if (userRole && !['owner', 'manager'].includes(userRole)) {
return null;
}
const { appointments, billing_period } = quotaStatus;
// Don't show if unlimited
if (appointments.is_unlimited) {
return null;
}
const isOverQuota = appointments.is_over_quota;
const percentage = Math.round(appointments.usage_percentage);
const handleDismiss = () => {
dismissMutation.mutate();
};
// Format billing period for display
const billingPeriodDisplay = new Date(
billing_period.year,
billing_period.month - 1
).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
return (
<div
className={`border-b ${
isOverQuota
? 'bg-gradient-to-r from-red-500 to-red-600 text-white'
: 'bg-gradient-to-r from-amber-400 to-amber-500 text-amber-950'
}`}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap gap-2">
{/* Left: Warning Info */}
<div className="flex items-center gap-3 flex-1">
<div
className={`p-2 rounded-full ${
isOverQuota ? 'bg-white/20' : 'bg-amber-600/20'
}`}
>
{isOverQuota ? (
<AlertTriangle className="h-5 w-5" />
) : (
<TrendingUp className="h-5 w-5" />
)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<span className="font-semibold text-sm sm:text-base">
{isOverQuota
? t('quota.appointmentBanner.overTitle', 'Appointment Quota Exceeded')
: t('quota.appointmentBanner.warningTitle', 'Approaching Appointment Limit')}
</span>
<span className="text-sm opacity-90 flex items-center gap-1">
<Calendar className="h-4 w-4 hidden sm:inline" />
{t('quota.appointmentBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', {
used: appointments.count,
limit: appointments.limit,
percentage,
})}
{' • '}
{billingPeriodDisplay}
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{isOverQuota && appointments.overage_count > 0 && (
<span className="text-xs sm:text-sm px-2 py-1 bg-white/20 rounded">
{t('quota.appointmentBanner.overage', '+{{count}} @ $0.10 each', {
count: appointments.overage_count,
})}
</span>
)}
<Link
to="/dashboard/settings/billing"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isOverQuota
? 'bg-white text-red-600 hover:bg-red-50'
: 'bg-amber-700 text-white hover:bg-amber-800'
}`}
>
{t('quota.appointmentBanner.upgrade', 'Upgrade Plan')}
</Link>
<button
onClick={handleDismiss}
disabled={dismissMutation.isPending}
className={`p-1.5 rounded-md transition-colors ${
isOverQuota ? 'hover:bg-white/20' : 'hover:bg-amber-600/20'
}`}
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Additional info for over-quota */}
{isOverQuota && (
<div className="mt-2 text-sm opacity-90">
{t(
'quota.appointmentBanner.overageInfo',
'Appointments over your limit will be billed at $0.10 each at the end of your billing cycle.'
)}
</div>
)}
</div>
</div>
);
};
export default AppointmentQuotaBanner;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox } from 'lucide-react';
import { LayoutDashboard, Building2, MessageSquare, Settings, Users, Shield, HelpCircle, Code, Mail, CreditCard, Inbox, FileText } from 'lucide-react';
import { User } from '../types';
import SmoothScheduleLogo from './SmoothScheduleLogo';
@@ -81,6 +81,10 @@ const PlatformSidebar: React.FC<PlatformSidebarProps> = ({ user, isCollapsed, to
<Shield size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.staff')}</span>}
</Link>
<Link to="/platform/email-templates" className={getNavClass('/platform/email-templates')} title={t('nav.emailTemplates', 'Email Templates')}>
<FileText size={18} className="shrink-0" />
{!isCollapsed && <span>{t('nav.emailTemplates', 'Email Templates')}</span>}
</Link>
<Link to="/platform/billing" className={getNavClass('/platform/billing')} title="Billing Management">
<CreditCard size={18} className="shrink-0" />
{!isCollapsed && <span>Billing</span>}

View File

@@ -0,0 +1,179 @@
/**
* StorageQuotaBanner Component
*
* Shows a warning banner when the user has reached 90% of their database
* storage quota. This helps business owners be aware of storage usage
* and potential overage charges.
*
* Storage is measured periodically by a backend task and cached.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { AlertTriangle, X, Database, HardDrive } from 'lucide-react';
import { useQuotaStatus } from '../hooks/useQuotaStatus';
interface StorageQuotaBannerProps {
/** Only show for owners/managers who can take action */
userRole?: string;
/** Callback when banner is dismissed */
onDismiss?: () => void;
}
const StorageQuotaBanner: React.FC<StorageQuotaBannerProps> = ({ userRole, onDismiss }) => {
const { t } = useTranslation();
const { data: quotaStatus, isLoading } = useQuotaStatus();
// Don't show while loading or if no data
if (isLoading || !quotaStatus) {
return null;
}
const { storage, billing_period } = quotaStatus;
// Don't show if unlimited
if (storage.is_unlimited) {
return null;
}
// Don't show if not at warning threshold
if (!storage.is_at_warning_threshold) {
return null;
}
// Only show for owners and managers who can take action
if (userRole && !['owner', 'manager'].includes(userRole)) {
return null;
}
const isOverQuota = storage.is_over_quota;
const percentage = Math.round(storage.usage_percentage);
// Format storage sizes for display
const formatSize = (mb: number): string => {
if (mb >= 1024) {
return `${(mb / 1024).toFixed(1)} GB`;
}
return `${mb.toFixed(1)} MB`;
};
const currentDisplay = formatSize(storage.current_size_mb);
const limitDisplay = formatSize(storage.quota_limit_mb);
const overageDisplay = storage.overage_mb > 0 ? formatSize(storage.overage_mb) : null;
// Format billing period for display
const billingPeriodDisplay = new Date(
billing_period.year,
billing_period.month - 1
).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
// Format last measured time
const lastMeasuredDisplay = storage.last_measured_at
? new Date(storage.last_measured_at).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
: null;
return (
<div
className={`border-b ${
isOverQuota
? 'bg-gradient-to-r from-purple-600 to-purple-700 text-white'
: 'bg-gradient-to-r from-purple-400 to-purple-500 text-purple-950'
}`}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between flex-wrap gap-2">
{/* Left: Warning Info */}
<div className="flex items-center gap-3 flex-1">
<div
className={`p-2 rounded-full ${
isOverQuota ? 'bg-white/20' : 'bg-purple-600/20'
}`}
>
{isOverQuota ? (
<AlertTriangle className="h-5 w-5" />
) : (
<Database className="h-5 w-5" />
)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<span className="font-semibold text-sm sm:text-base">
{isOverQuota
? t('quota.storageBanner.overTitle', 'Storage Quota Exceeded')
: t('quota.storageBanner.warningTitle', 'Approaching Storage Limit')}
</span>
<span className="text-sm opacity-90 flex items-center gap-1">
<HardDrive className="h-4 w-4 hidden sm:inline" />
{t('quota.storageBanner.usage', '{{used}} of {{limit}} ({{percentage}}%)', {
used: currentDisplay,
limit: limitDisplay,
percentage,
})}
{' • '}
{billingPeriodDisplay}
</span>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{isOverQuota && overageDisplay && (
<span className="text-xs sm:text-sm px-2 py-1 bg-white/20 rounded">
{t('quota.storageBanner.overage', '+{{size}} @ $0.50/GB', {
size: overageDisplay,
})}
</span>
)}
<Link
to="/dashboard/settings/billing"
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
isOverQuota
? 'bg-white text-purple-600 hover:bg-purple-50'
: 'bg-purple-700 text-white hover:bg-purple-800'
}`}
>
{t('quota.storageBanner.upgrade', 'Upgrade Plan')}
</Link>
{onDismiss && (
<button
onClick={onDismiss}
className={`p-1.5 rounded-md transition-colors ${
isOverQuota ? 'hover:bg-white/20' : 'hover:bg-purple-600/20'
}`}
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="h-5 w-5" />
</button>
)}
</div>
</div>
{/* Additional info for over-quota */}
{isOverQuota && (
<div className="mt-2 text-sm opacity-90">
{t(
'quota.storageBanner.overageInfo',
'Storage over your limit will be billed at $0.50 per GB at the end of your billing cycle.'
)}
</div>
)}
{/* Last measured timestamp */}
{lastMeasuredDisplay && (
<div className="mt-1 text-xs opacity-70">
{t('quota.storageBanner.lastMeasured', 'Last measured: {{time}}', {
time: lastMeasuredDisplay,
})}
</div>
)}
</div>
</div>
);
};
export default StorageQuotaBanner;

View File

@@ -52,8 +52,7 @@ const FEATURE_CATEGORIES = [
key: 'branding',
features: [
{ code: 'custom_domain', label: 'Custom domain' },
{ code: 'custom_branding', label: 'Custom branding' },
{ code: 'remove_branding', label: 'Remove branding' },
{ code: 'can_white_label', label: 'White label branding' },
],
},
{

View File

@@ -12,6 +12,7 @@
import React, { useMemo } from 'react';
import { Key, AlertCircle } from 'lucide-react';
import { useBillingFeatures, BillingFeature, FEATURE_CATEGORY_META } from '../../hooks/useBillingPlans';
import { isWipFeature } from '../../billing/featureCatalog';
export interface DynamicFeaturesEditorProps {
/**
@@ -62,6 +63,11 @@ export interface DynamicFeaturesEditorProps {
* Number of columns (default: 3)
*/
columns?: 2 | 3 | 4;
/**
* Disable all inputs (for read-only mode)
*/
disabled?: boolean;
}
const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
@@ -74,6 +80,7 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
headerTitle = 'Features & Permissions',
showDescriptions = false,
columns = 3,
disabled = false,
}) => {
const { data: features, isLoading, error } = useBillingFeatures();
@@ -223,12 +230,13 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
if (feature.feature_type === 'boolean') {
const isChecked = currentValue === true;
const isInputDisabled = isDisabled || disabled;
return (
<label
key={feature.code}
className={`flex items-start gap-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${
isDisabled
isInputDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
@@ -237,12 +245,17 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
type="checkbox"
checked={isChecked}
onChange={(e) => handleChange(feature, e.target.checked)}
disabled={isDisabled}
disabled={isInputDisabled}
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
<span className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
{showDescriptions && feature.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
@@ -261,10 +274,15 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
return (
<div
key={feature.code}
className="p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
className={`p-2 border border-gray-200 dark:border-gray-700 rounded-lg ${disabled ? 'opacity-50' : ''}`}
>
<label className="text-sm text-gray-700 dark:text-gray-300 block mb-1">
<label className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5 mb-1">
{feature.name}
{isWipFeature(feature.code) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</label>
<div className="flex items-center gap-2">
<input
@@ -275,7 +293,8 @@ const DynamicFeaturesEditor: React.FC<DynamicFeaturesEditorProps> = ({
const val = parseInt(e.target.value);
handleChange(feature, val === -1 ? null : val);
}}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500"
disabled={disabled}
className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-1 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
{showDescriptions && (

View File

@@ -12,6 +12,7 @@
import React from 'react';
import { Key } from 'lucide-react';
import { isWipFeature } from '../../billing/featureCatalog';
/**
* Permission definition with metadata
@@ -117,21 +118,21 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
category: 'customization',
},
{
key: 'remove_branding',
key: 'white_label',
planKey: 'can_white_label',
businessKey: 'can_white_label',
label: 'Remove Branding',
description: 'Remove SmoothSchedule branding from customer-facing pages',
label: 'White Label',
description: 'Customize branding and remove SmoothSchedule branding',
category: 'customization',
},
// Plugins & Automation
// Automations
{
key: 'plugins',
planKey: 'can_use_plugins',
businessKey: 'can_use_plugins',
label: 'Use Plugins',
description: 'Install and use marketplace plugins',
key: 'automations',
planKey: 'can_use_automations',
businessKey: 'can_use_automations',
label: 'Automations',
description: 'Install and use marketplace automations',
category: 'plugins',
},
{
@@ -141,16 +142,16 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
label: 'Scheduled Tasks',
description: 'Create automated scheduled tasks',
category: 'plugins',
dependsOn: 'plugins',
dependsOn: 'automations',
},
{
key: 'create_plugins',
planKey: 'can_create_plugins',
businessKey: 'can_create_plugins',
label: 'Create Plugins',
description: 'Build custom plugins',
key: 'create_automations',
planKey: 'can_create_automations',
businessKey: 'can_create_automations',
label: 'Create Automations',
description: 'Build custom automations',
category: 'plugins',
dependsOn: 'plugins',
dependsOn: 'automations',
},
// Advanced Features
@@ -172,7 +173,7 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
},
{
key: 'calendar_sync',
planKey: 'calendar_sync',
planKey: 'can_use_calendar_sync',
businessKey: 'can_use_calendar_sync',
label: 'Calendar Sync',
description: 'Sync with Google Calendar, etc.',
@@ -186,14 +187,6 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
description: 'Export data to CSV/Excel',
category: 'advanced',
},
{
key: 'video_conferencing',
planKey: 'video_conferencing',
businessKey: 'can_add_video_conferencing',
label: 'Video Conferencing',
description: 'Add video links to appointments',
category: 'advanced',
},
{
key: 'advanced_reporting',
planKey: 'advanced_reporting',
@@ -240,8 +233,8 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
key: 'sso_enabled',
planKey: 'sso_enabled',
businessKey: 'sso_enabled',
label: 'SSO / SAML',
description: 'Single sign-on integration',
label: 'Single Sign-On (SSO)',
description: 'Enable SSO/SAML authentication for team members',
category: 'enterprise',
},
{
@@ -499,8 +492,13 @@ const FeaturesPermissionsEditor: React.FC<FeaturesPermissionsEditorProps> = ({
className="mt-0.5 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 disabled:opacity-50"
/>
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 block">
<span className="text-sm text-gray-700 dark:text-gray-300 flex items-center gap-1.5">
{def.label}
{isWipFeature(def.planKey || def.key) && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
WIP
</span>
)}
</span>
{showDescriptions && def.description && (
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">

View File

@@ -280,7 +280,7 @@ export const PERMISSION_TO_FEATURE_CODE: Record<string, string> = {
// Platform
can_api_access: 'api_access',
can_use_custom_domain: 'custom_domain',
can_white_label: 'remove_branding',
can_white_label: 'can_white_label',
// Features
can_accept_payments: 'payment_processing',
@@ -328,8 +328,9 @@ export function planFeaturesToLegacyPermissions(
case 'custom_domain':
permissions.can_use_custom_domain = value as boolean;
break;
case 'remove_branding':
case 'can_white_label':
permissions.can_white_label = value as boolean;
permissions.can_customize_booking_page = value as boolean;
break;
case 'payment_processing':
permissions.can_accept_payments = value as boolean;
@@ -356,9 +357,6 @@ export function planFeaturesToLegacyPermissions(
case 'audit_logs':
permissions.can_download_logs = value as boolean;
break;
case 'custom_branding':
permissions.can_customize_booking_page = value as boolean;
break;
case 'recurring_appointments':
permissions.can_book_repeated_events = value as boolean;
break;

View File

@@ -60,13 +60,12 @@ export const useCurrentBusiness = () => {
webhooks: false,
api_access: false,
custom_domain: false,
remove_branding: false,
white_label: false,
custom_oauth: false,
automations: false,
can_create_automations: false,
tasks: false,
export_data: false,
video_conferencing: false,
two_factor_auth: false,
masked_calling: false,
pos_system: false,

View File

@@ -81,14 +81,12 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
webhooks: 'Webhooks',
api_access: 'API Access',
custom_domain: 'Custom Domain',
custom_branding: 'Custom Branding',
remove_branding: 'Remove Branding',
white_label: 'White Label',
custom_oauth: 'Custom OAuth',
automations: 'Automations',
can_create_automations: 'Custom Automation Creation',
tasks: 'Scheduled Tasks',
export_data: 'Data Export',
video_conferencing: 'Video Conferencing',
two_factor_auth: 'Two-Factor Authentication',
masked_calling: 'Masked Calling',
pos_system: 'POS System',
@@ -105,14 +103,12 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
webhooks: 'Integrate with external services using webhooks',
api_access: 'Access the SmoothSchedule API for custom integrations',
custom_domain: 'Use your own custom domain for your booking site',
custom_branding: 'Customize branding colors, logo, and styling',
remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
white_label: 'Customize branding and remove SmoothSchedule branding',
custom_oauth: 'Configure your own OAuth credentials for social login',
automations: 'Automate repetitive tasks with custom workflows',
can_create_automations: 'Create custom automations tailored to your business needs',
tasks: 'Create scheduled tasks to automate execution',
export_data: 'Export your data to CSV or other formats',
video_conferencing: 'Add video conferencing links to appointments',
two_factor_auth: 'Require two-factor authentication for enhanced security',
masked_calling: 'Use masked phone numbers to protect privacy',
pos_system: 'Process in-person payments with Point of Sale',

View File

@@ -0,0 +1,116 @@
/**
* Platform Email Templates Hooks
*
* React Query hooks for managing platform-level email templates.
* These templates are used for platform communications like tenant invitations,
* trial notifications, billing alerts, etc.
*
* Access: Superusers only
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
import {
PlatformEmailTemplate,
PlatformEmailTemplateDetail,
PlatformEmailTemplatePreview,
PlatformEmailTemplateUpdate,
PlatformEmailType,
} from '../types';
// Query keys
const QUERY_KEYS = {
all: ['platform-email-templates'] as const,
detail: (type: PlatformEmailType) => ['platform-email-templates', type] as const,
};
/**
* Fetch all platform email templates
*/
export function usePlatformEmailTemplates() {
return useQuery<PlatformEmailTemplate[]>({
queryKey: QUERY_KEYS.all,
queryFn: async () => {
const { data } = await api.get('/platform/email-templates/');
return data;
},
});
}
/**
* Fetch a single platform email template by type
*/
export function usePlatformEmailTemplate(emailType: PlatformEmailType | null) {
return useQuery<PlatformEmailTemplateDetail>({
queryKey: emailType ? QUERY_KEYS.detail(emailType) : ['platform-email-templates', 'none'],
queryFn: async () => {
if (!emailType) throw new Error('No email type specified');
const { data } = await api.get(`/platform/email-templates/${emailType}/`);
return data;
},
enabled: !!emailType,
});
}
/**
* Update a platform email template
*/
export function useUpdatePlatformEmailTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
emailType,
data,
}: {
emailType: PlatformEmailType;
data: PlatformEmailTemplateUpdate;
}) => {
const response = await api.put(`/platform/email-templates/${emailType}/`, data);
return response.data as PlatformEmailTemplateDetail;
},
onSuccess: (_, variables) => {
// Invalidate both the list and the specific template
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.detail(variables.emailType) });
},
});
}
/**
* Reset a platform email template to its default
*/
export function useResetPlatformEmailTemplate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (emailType: PlatformEmailType) => {
const response = await api.post(`/platform/email-templates/${emailType}/reset/`);
return response.data;
},
onSuccess: (_, emailType) => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.detail(emailType) });
},
});
}
/**
* Preview a rendered platform email template
*/
export function usePreviewPlatformEmailTemplate() {
return useMutation({
mutationFn: async ({
emailType,
context = {},
}: {
emailType: PlatformEmailType;
context?: Record<string, string>;
}) => {
const response = await api.post(`/platform/email-templates/${emailType}/preview/`, {
context,
});
return response.data as PlatformEmailTemplatePreview;
},
});
}

View File

@@ -0,0 +1,178 @@
/**
* Platform Staff Invitations Hooks
*
* React Query hooks for managing platform staff invitations.
* These are used to invite new platform_manager and platform_support users.
*
* Access: Superusers only
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
// Types
export type PlatformStaffRole = 'platform_manager' | 'platform_support';
export interface PlatformStaffInvitation {
id: number;
email: string;
role: PlatformStaffRole;
role_display: string;
status: 'PENDING' | 'ACCEPTED' | 'EXPIRED' | 'CANCELLED';
status_display: string;
invited_by: string | null;
invited_by_email: string | null;
created_at: string;
expires_at: string | null;
accepted_at: string | null;
is_valid: boolean;
}
export interface PlatformStaffInvitationDetail extends PlatformStaffInvitation {
role_description: string;
personal_message: string;
permissions: Record<string, boolean>;
}
export interface CreatePlatformStaffInvitationData {
email: string;
role: PlatformStaffRole;
personal_message?: string;
}
export interface AcceptPlatformStaffInvitationData {
password: string;
first_name?: string;
last_name?: string;
}
export interface PlatformStaffInvitationTokenData {
email: string;
role: PlatformStaffRole;
role_display: string;
role_description: string;
invited_by: string | null;
personal_message: string;
expires_at: string | null;
}
// Query keys
const QUERY_KEYS = {
all: ['platform-staff-invitations'] as const,
detail: (id: number) => ['platform-staff-invitations', id] as const,
token: (token: string) => ['platform-staff-invitations', 'token', token] as const,
};
/**
* Fetch all platform staff invitations
*/
export function usePlatformStaffInvitations(statusFilter?: string) {
return useQuery<PlatformStaffInvitation[]>({
queryKey: [...QUERY_KEYS.all, { status: statusFilter }],
queryFn: async () => {
const params = statusFilter ? { status: statusFilter } : {};
const { data } = await api.get('/platform/staff-invitations/', { params });
return data;
},
});
}
/**
* Fetch a single platform staff invitation by ID
*/
export function usePlatformStaffInvitation(id: number | null) {
return useQuery<PlatformStaffInvitationDetail>({
queryKey: id ? QUERY_KEYS.detail(id) : ['platform-staff-invitations', 'none'],
queryFn: async () => {
if (!id) throw new Error('No invitation ID specified');
const { data } = await api.get(`/platform/staff-invitations/${id}/`);
return data;
},
enabled: !!id,
});
}
/**
* Create a new platform staff invitation
*/
export function useCreatePlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (invitationData: CreatePlatformStaffInvitationData) => {
const response = await api.post('/platform/staff-invitations/', invitationData);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Resend a platform staff invitation email
*/
export function useResendPlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await api.post(`/platform/staff-invitations/${id}/resend/`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Cancel a platform staff invitation
*/
export function useCancelPlatformStaffInvitation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number) => {
const response = await api.post(`/platform/staff-invitations/${id}/cancel/`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.all });
},
});
}
/**
* Fetch invitation details by token (public endpoint)
*/
export function usePlatformStaffInvitationByToken(token: string | null) {
return useQuery<PlatformStaffInvitationTokenData>({
queryKey: token ? QUERY_KEYS.token(token) : ['platform-staff-invitations', 'token', 'none'],
queryFn: async () => {
if (!token) throw new Error('No token specified');
const { data } = await api.get(`/platform/staff-invitations/token/${token}/`);
return data;
},
enabled: !!token,
retry: false, // Don't retry on failure (invalid token)
});
}
/**
* Accept a platform staff invitation (public endpoint)
*/
export function useAcceptPlatformStaffInvitation() {
return useMutation({
mutationFn: async ({
token,
data,
}: {
token: string;
data: AcceptPlatformStaffInvitationData;
}) => {
const response = await api.post(`/platform/staff-invitations/token/${token}/accept/`, data);
return response.data;
},
});
}

View File

@@ -0,0 +1,111 @@
/**
* useQuotaStatus Hook
*
* Fetches monthly quota status from the billing API.
* Used to show warning banners when approaching quota limits.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
export interface QuotaStatus {
billing_period: {
year: number;
month: number;
};
appointments: {
count: number;
limit: number;
is_unlimited: boolean;
usage_percentage: number;
remaining: number | null;
is_at_warning_threshold: boolean;
is_over_quota: boolean;
overage_count: number;
overage_amount_cents: number;
};
flow_executions: {
count: number;
amount_cents: number;
};
api_requests: {
date: string;
request_count: number;
quota_limit: number;
is_unlimited: boolean;
is_over_quota: boolean;
remaining_requests: number | null;
usage_percentage: number;
};
storage: {
current_size_mb: number;
current_size_gb: number;
peak_size_mb: number;
quota_limit_mb: number;
quota_limit_gb: number;
is_unlimited: boolean;
usage_percentage: number;
remaining_mb: number | null;
is_at_warning_threshold: boolean;
is_over_quota: boolean;
overage_mb: number;
overage_amount_cents: number;
warning_email_sent: boolean;
last_measured_at: string | null;
};
warning: {
show_banner: boolean;
email_sent: boolean;
};
}
/**
* Fetch current quota status
*/
export const fetchQuotaStatus = async (): Promise<QuotaStatus> => {
const response = await apiClient.get('/me/quota/');
return response.data;
};
/**
* Dismiss the quota warning banner for the current billing period
*/
export const dismissQuotaBanner = async (): Promise<void> => {
await apiClient.post('/me/quota/dismiss-banner/');
};
/**
* Hook to fetch quota status
*/
export const useQuotaStatus = () => {
return useQuery({
queryKey: ['quotaStatus'],
queryFn: fetchQuotaStatus,
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
};
/**
* Hook to dismiss the quota warning banner
*/
export const useDismissQuotaBanner = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: dismissQuotaBanner,
onSuccess: () => {
// Update the cached quota status to hide the banner
queryClient.setQueryData<QuotaStatus>(['quotaStatus'], (old) => {
if (!old) return old;
return {
...old,
warning: {
...old.warning,
show_banner: false,
},
};
});
},
});
};

View File

@@ -2368,6 +2368,74 @@
"staff": "Staff",
"customer": "Customer"
},
"emailTemplates": {
"title": "Platform Email Templates",
"description": "Customize platform-level automated emails for tenant invitations, trial notifications, billing alerts, and more.",
"aboutTags": "About Template Tags",
"tagsDescription": "Use template tags like {{ tenant_name }} to insert dynamic content. Available tags vary by email type and are shown when editing each template.",
"categories": {
"invitation": "Invitations",
"trial": "Trial & Onboarding",
"subscription": "Subscription Changes",
"billing": "Billing & Payments"
},
"types": {
"tenant_invitation": {
"name": "Tenant Invitation",
"description": "Sent when inviting a new business to join the platform"
},
"trial_expiration_warning": {
"name": "Trial Expiration Warning",
"description": "Sent a few days before a business trial expires"
},
"trial_expired": {
"name": "Trial Expired",
"description": "Sent when a business trial has expired"
},
"plan_upgrade": {
"name": "Plan Upgrade Confirmation",
"description": "Sent when a business upgrades their subscription plan"
},
"plan_downgrade": {
"name": "Plan Downgrade Confirmation",
"description": "Sent when a business downgrades their subscription plan"
},
"subscription_cancelled": {
"name": "Subscription Cancelled",
"description": "Sent when a business cancels their subscription"
},
"payment_failed": {
"name": "Payment Failed",
"description": "Sent when a recurring payment fails"
},
"payment_succeeded": {
"name": "Payment Succeeded",
"description": "Sent after a successful subscription payment"
}
},
"customized": "Customized",
"disabled": "Disabled",
"subject": "Subject",
"edit": "Edit",
"resetToDefault": "Reset to Default",
"resetConfirmTitle": "Reset to Default?",
"resetConfirmMessage": "This will reset the email template to its default content. Any customizations you've made will be lost.",
"cancel": "Cancel",
"save": "Save",
"preview": "Preview",
"unsavedChanges": "Unsaved changes",
"emailSubject": "Email Subject",
"subjectPlaceholder": "Enter email subject...",
"subjectHint": "Use tags like {{ tenant_name }} for dynamic content",
"availableTags": "Available Template Tags",
"clickToCopy": "Click a tag to copy it. Hover for description.",
"emailPreview": "Email Preview",
"close": "Close",
"failedToLoad": "Failed to load template",
"failedToSave": "Failed to save template",
"failedToReset": "Failed to reset template",
"failedToPreview": "Failed to generate preview"
},
"settings": {
"title": "Platform Settings",
"description": "Configure platform-wide settings and integrations",
@@ -2777,8 +2845,7 @@
"api_access": "API access",
"max_api_calls_per_day": "API calls/day",
"custom_domain": "Custom domain",
"custom_branding": "Custom branding",
"remove_branding": "Remove branding",
"can_white_label": "White label branding",
"multi_location": "Multi-location management",
"team_permissions": "Team permissions",
"audit_logs": "Audit logs",
@@ -3423,6 +3490,23 @@
"autoArchiveWarning": "After the grace period, the oldest {{count}} {{type}} will be automatically archived.",
"noOverages": "You are within your plan limits.",
"resolved": "Resolved! Your usage is now within limits."
},
"appointmentBanner": {
"warningTitle": "Approaching Appointment Limit",
"overTitle": "Appointment Quota Exceeded",
"usage": "{{used}} of {{limit}} ({{percentage}}%)",
"overage": "+{{count}} @ $0.10 each",
"upgrade": "Upgrade Plan",
"overageInfo": "Appointments over your limit will be billed at $0.10 each at the end of your billing cycle."
},
"storageBanner": {
"warningTitle": "Approaching Storage Limit",
"overTitle": "Storage Quota Exceeded",
"usage": "{{used}} of {{limit}} ({{percentage}}%)",
"overage": "+{{size}} @ $0.50/GB",
"upgrade": "Upgrade Plan",
"overageInfo": "Storage over your limit will be billed at $0.50 per GB at the end of your billing cycle.",
"lastMeasured": "Last measured: {{time}}"
}
},
"upgrade": {

View File

@@ -6,6 +6,8 @@ import TrialBanner from '../components/TrialBanner';
import SandboxBanner from '../components/SandboxBanner';
import QuotaWarningBanner from '../components/QuotaWarningBanner';
import QuotaOverageModal, { resetQuotaOverageModalDismissal } from '../components/QuotaOverageModal';
import AppointmentQuotaBanner from '../components/AppointmentQuotaBanner';
import StorageQuotaBanner from '../components/StorageQuotaBanner';
import { Business, User } from '../types';
import MasqueradeBanner from '../components/MasqueradeBanner';
import OnboardingWizard from '../components/OnboardingWizard';
@@ -200,7 +202,7 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
onStop={handleStopMasquerade}
/>
)}
{/* Quota overage warning banner - show for owners and managers */}
{/* Quota overage warning banner - show for owners and managers (grace period system) */}
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaWarningBanner overages={user.quota_overages} />
)}
@@ -208,6 +210,10 @@ const BusinessLayoutContent: React.FC<BusinessLayoutProps> = ({ business, user,
{user.quota_overages && user.quota_overages.length > 0 && (
<QuotaOverageModal overages={user.quota_overages} onDismiss={() => {}} />
)}
{/* Appointment quota warning banner - 90% threshold warning (billing cycle system) */}
<AppointmentQuotaBanner userRole={user.role} />
{/* Storage quota warning banner - 90% threshold warning */}
<StorageQuotaBanner userRole={user.role} />
{/* Sandbox mode banner */}
<SandboxBannerWrapper />
{/* Show trial banner if trial is active and payments not yet enabled */}

View File

@@ -44,7 +44,7 @@ interface ParentContext {
// Map settings pages to their required plan features
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
'/dashboard/settings/branding': 'custom_branding',
'/dashboard/settings/branding': 'white_label',
'/dashboard/settings/custom-domains': 'custom_domain',
'/dashboard/settings/api': 'api_access',
'/dashboard/settings/authentication': 'custom_oauth',

View File

@@ -61,7 +61,7 @@ const transformPuckDataForEditor = (puckData: any): any => {
...item,
props: {
...item.props,
id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}

View File

@@ -0,0 +1,635 @@
/**
* Platform Email Templates Page
*
* Allows superusers to customize platform-level automated emails
* (tenant invitations, trial notifications, billing alerts, etc.) using a Puck editor.
*
* Access: Superusers only
*/
import React, { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Puck } from '@measured/puck';
import '@measured/puck/puck.css';
import {
Mail,
Edit2,
RotateCcw,
Eye,
X,
AlertTriangle,
UserPlus,
Clock,
CreditCard,
Package,
ChevronDown,
ChevronRight,
Info,
Save,
Loader2,
Code,
MonitorPlay,
} from 'lucide-react';
import { getEmailEditorConfig } from '../../puck/emailConfig';
import {
PlatformEmailTemplate,
PlatformEmailTemplateDetail,
PlatformEmailTag,
PlatformEmailCategory,
PlatformEmailType,
} from '../../types';
import {
usePlatformEmailTemplates,
useUpdatePlatformEmailTemplate,
useResetPlatformEmailTemplate,
usePreviewPlatformEmailTemplate,
} from '../../hooks/usePlatformEmailTemplates';
import api from '../../api/client';
// Category metadata
const CATEGORY_CONFIG: Record<PlatformEmailCategory, {
label: string;
icon: React.ReactNode;
color: string;
}> = {
invitation: {
label: 'Invitations',
icon: <UserPlus className="h-5 w-5" />,
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
trial: {
label: 'Trial & Onboarding',
icon: <Clock className="h-5 w-5" />,
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
},
subscription: {
label: 'Subscription Changes',
icon: <Package className="h-5 w-5" />,
color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
},
billing: {
label: 'Billing & Payments',
icon: <CreditCard className="h-5 w-5" />,
color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
},
};
// Category order for display
const CATEGORY_ORDER: PlatformEmailCategory[] = [
'invitation',
'trial',
'subscription',
'billing',
];
const PlatformEmailTemplates: React.FC = () => {
const { t } = useTranslation();
const [expandedCategories, setExpandedCategories] = useState<Set<PlatformEmailCategory>>(
new Set(CATEGORY_ORDER)
);
const [editingTemplate, setEditingTemplate] = useState<PlatformEmailTemplateDetail | null>(null);
const [showPreviewModal, setShowPreviewModal] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewSubject, setPreviewSubject] = useState<string>('');
const [showResetConfirm, setShowResetConfirm] = useState<string | null>(null);
const [editorData, setEditorData] = useState<any>(null);
const [editorSubject, setEditorSubject] = useState<string>('');
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
// Get email editor config
const editorConfig = getEmailEditorConfig();
// Fetch all email templates
const { data: templates = [], isLoading } = usePlatformEmailTemplates();
// Mutations
const updateMutation = useUpdatePlatformEmailTemplate();
const resetMutation = useResetPlatformEmailTemplate();
const previewMutation = usePreviewPlatformEmailTemplate();
// Fix Puck sidebar scrolling when editor is open
useEffect(() => {
if (!editingTemplate) return;
const fixSidebars = () => {
const leftSidebar = document.querySelector('[class*="Sidebar--left"]') as HTMLElement;
const rightSidebar = document.querySelector('[class*="Sidebar--right"]') as HTMLElement;
[leftSidebar, rightSidebar].forEach((sidebar) => {
if (!sidebar) return;
sidebar.style.maxHeight = 'calc(100vh - 300px)';
sidebar.style.overflowY = 'auto';
const innerSections = sidebar.querySelectorAll('[class*="SidebarSection"]');
innerSections.forEach((section) => {
(section as HTMLElement).style.overflow = 'visible';
(section as HTMLElement).style.maxHeight = 'none';
});
});
};
const timer = setTimeout(fixSidebars, 200);
return () => clearTimeout(timer);
}, [editingTemplate]);
// Fetch single template with tags
const fetchTemplateDetail = async (emailType: PlatformEmailType): Promise<PlatformEmailTemplateDetail> => {
const { data } = await api.get(`/platform/email-templates/${emailType}/`);
return data;
};
// Group templates by category
const templatesByCategory = React.useMemo(() => {
const grouped: Record<PlatformEmailCategory, PlatformEmailTemplate[]> = {
invitation: [],
trial: [],
subscription: [],
billing: [],
};
templates.forEach((template) => {
if (grouped[template.category]) {
grouped[template.category].push(template);
}
});
return grouped;
}, [templates]);
// Toggle category expansion
const toggleCategory = (category: PlatformEmailCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
/**
* Transform puck_data to ensure id is inside props (Puck requirement).
*/
const transformPuckDataForEditor = (puckData: any): any => {
if (!puckData?.content) return puckData;
return {
...puckData,
content: puckData.content.map((item: any, index: number) => {
const rootId = item.id;
const hasPropsId = item.props?.id;
if (rootId && !hasPropsId) {
const { id, ...rest } = item;
return {
...rest,
props: {
...rest.props,
id: rootId,
},
};
} else if (!hasPropsId) {
return {
...item,
props: {
...item.props,
id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}
return item;
}),
};
};
// Open editor for a template
const handleEdit = async (template: PlatformEmailTemplate) => {
try {
const detail = await fetchTemplateDetail(template.email_type);
const transformedData = transformPuckDataForEditor(detail.puck_data);
setEditingTemplate(detail);
setEditorData(transformedData);
setEditorSubject(detail.subject_template);
setHasUnsavedChanges(false);
} catch (error) {
console.error('Failed to load template:', error);
}
};
// Handle data changes in editor
const handleEditorChange = useCallback((newData: any) => {
setEditorData(newData);
setHasUnsavedChanges(true);
}, []);
// Save template
const handleSave = async () => {
if (!editingTemplate) return;
try {
await updateMutation.mutateAsync({
emailType: editingTemplate.email_type,
data: {
subject_template: editorSubject,
puck_data: editorData,
},
});
setHasUnsavedChanges(false);
setEditingTemplate(null);
} catch (error: any) {
console.error('Failed to save template:', error);
const errorMsg = error?.response?.data?.subject_template?.[0] ||
error?.response?.data?.error ||
'Failed to save template';
alert(errorMsg);
}
};
// Preview template
const handlePreview = async () => {
if (!editingTemplate) return;
try {
const preview = await previewMutation.mutateAsync({
emailType: editingTemplate.email_type,
context: {},
});
setPreviewSubject(preview.subject);
setPreviewHtml(preview.html);
setShowPreviewModal(true);
} catch (error) {
console.error('Failed to generate preview:', error);
}
};
// Reset template to default
const handleReset = async (emailType: PlatformEmailType) => {
try {
await resetMutation.mutateAsync(emailType);
setShowResetConfirm(null);
} catch (error) {
console.error('Failed to reset template:', error);
}
};
// Close editor
const handleCloseEditor = () => {
if (hasUnsavedChanges) {
if (!confirm('You have unsaved changes. Are you sure you want to close?')) {
return;
}
}
setEditingTemplate(null);
setEditorData(null);
setEditorSubject('');
setHasUnsavedChanges(false);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-brand-600" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<Mail className="h-7 w-7 text-brand-600" />
{t('platform.emailTemplates.title', 'Platform Email Templates')}
</h1>
<p className="mt-1 text-gray-500 dark:text-gray-400">
{t(
'platform.emailTemplates.description',
'Customize platform-level automated emails for tenant invitations, trial notifications, billing alerts, and more.'
)}
</p>
</div>
{/* Info Banner */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex gap-3">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-300">
<p className="font-medium mb-1">About Template Tags</p>
<p>
Use template tags like <code className="bg-blue-100 dark:bg-blue-800 px-1 rounded">{'{{ tenant_name }}'}</code> to
insert dynamic content. Available tags vary by email type and are shown when editing each template.
</p>
</div>
</div>
</div>
{/* Template Categories */}
<div className="space-y-4">
{CATEGORY_ORDER.map((category) => {
const categoryTemplates = templatesByCategory[category];
const config = CATEGORY_CONFIG[category];
const isExpanded = expandedCategories.has(category);
if (categoryTemplates.length === 0) return null;
return (
<div
key={category}
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
>
{/* Category Header */}
<button
onClick={() => toggleCategory(category)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div className="flex items-center gap-3">
<span className={`p-2 rounded-lg ${config.color}`}>{config.icon}</span>
<div className="text-left">
<h3 className="font-semibold text-gray-900 dark:text-white">{config.label}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{categoryTemplates.length} template{categoryTemplates.length !== 1 ? 's' : ''}
</p>
</div>
</div>
{isExpanded ? (
<ChevronDown className="h-5 w-5 text-gray-400" />
) : (
<ChevronRight className="h-5 w-5 text-gray-400" />
)}
</button>
{/* Template List */}
{isExpanded && (
<div className="border-t border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
{categoryTemplates.map((template) => (
<div
key={template.email_type}
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-900 dark:text-white">
{template.display_name}
</h4>
{template.is_customized && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-100 text-brand-700 dark:bg-brand-900/30 dark:text-brand-400">
Customized
</span>
)}
{!template.is_active && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Disabled
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{template.description}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Subject: <span className="font-mono">{template.subject_template}</span>
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
{template.is_customized && (
<button
onClick={() => setShowResetConfirm(template.email_type)}
className="p-2 text-gray-500 hover:text-amber-600 dark:hover:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors"
title="Reset to default"
>
<RotateCcw className="h-4 w-4" />
</button>
)}
<button
onClick={() => handleEdit(template)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors text-sm font-medium"
>
<Edit2 className="h-4 w-4" />
Edit
</button>
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
{/* Reset Confirmation Modal */}
{showResetConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Reset to Default?
</h3>
</div>
<div className="p-6">
<p className="text-gray-600 dark:text-gray-400">
This will reset the email template to its default content. Any customizations you've made will be lost.
</p>
</div>
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
<button
onClick={() => setShowResetConfirm(null)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors font-medium"
>
Cancel
</button>
<button
onClick={() => handleReset(showResetConfirm as PlatformEmailType)}
disabled={resetMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors font-medium"
>
{resetMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RotateCcw className="h-4 w-4" />
)}
Reset to Default
</button>
</div>
</div>
</div>
)}
{/* Editor Modal */}
{editingTemplate && (
<div className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-gray-900">
{/* Editor Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={handleCloseEditor}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="h-5 w-5" />
</button>
<div>
<h2 className="font-semibold text-gray-900 dark:text-white">
{editingTemplate.display_name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{editingTemplate.description}
</p>
</div>
</div>
<div className="flex items-center gap-3">
{hasUnsavedChanges && (
<span className="flex items-center gap-1.5 px-2 py-1 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded text-xs font-medium">
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
Unsaved changes
</span>
)}
<button
onClick={handlePreview}
disabled={previewMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 text-sm font-medium transition-colors"
>
<Eye className="h-4 w-4" />
Preview
</button>
<button
onClick={handleSave}
disabled={updateMutation.isPending || !hasUnsavedChanges}
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 text-sm font-medium transition-colors"
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save
</button>
</div>
</div>
{/* Subject Line Editor */}
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Subject
</label>
<input
type="text"
value={editorSubject}
onChange={(e) => {
setEditorSubject(e.target.value);
setHasUnsavedChanges(true);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
placeholder="Enter email subject..."
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Use tags like <code className="bg-gray-200 dark:bg-gray-600 px-1 rounded">{'{{ tenant_name }}'}</code> for dynamic content
</p>
</div>
{/* Available Tags Panel */}
<div className="bg-gray-100 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700 px-6 py-3">
<details className="group" open>
<summary className="flex items-center gap-2 cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
<Code className="h-4 w-4" />
Available Template Tags ({editingTemplate.available_tags?.length || 0})
<ChevronRight className="h-4 w-4 group-open:rotate-90 transition-transform" />
</summary>
<div className="mt-3">
<div className="flex flex-wrap gap-1.5">
{(editingTemplate.available_tags || []).map((tag: PlatformEmailTag) => (
<span
key={tag.name}
className="inline-flex items-center px-2 py-0.5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded text-xs font-mono cursor-help hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
title={tag.description}
onClick={() => {
navigator.clipboard.writeText(tag.syntax);
}}
>
{tag.name}
</span>
))}
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Click a tag to copy it. Hover for description.
</p>
</div>
</details>
</div>
{/* Puck Editor */}
<div className="flex-1 overflow-hidden">
{editorData && (
<Puck
key={`platform-email-puck-${editingTemplate?.email_type}`}
config={editorConfig}
data={editorData}
onChange={handleEditorChange}
onPublish={handleSave}
/>
)}
</div>
</div>
)}
{/* Preview Modal */}
{showPreviewModal && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Preview Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<MonitorPlay className="h-5 w-5 text-brand-600" />
<div>
<h3 className="font-semibold text-gray-900 dark:text-white">Email Preview</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
Subject: {previewSubject}
</p>
</div>
</div>
<button
onClick={() => setShowPreviewModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Preview Content */}
<div className="flex-1 overflow-auto p-6 bg-gray-50 dark:bg-gray-900">
<div className="bg-white rounded-lg shadow-sm overflow-hidden max-w-2xl mx-auto">
<iframe
srcDoc={previewHtml}
className="w-full h-[500px]"
title="Email Preview"
/>
</div>
</div>
{/* Preview Footer */}
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
onClick={() => setShowPreviewModal(false)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium"
>
Close
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PlatformEmailTemplates;

View File

@@ -17,10 +17,23 @@ import {
CheckCircle,
XCircle,
Loader2,
Send,
Clock,
RefreshCw,
X,
} from 'lucide-react';
import toast from 'react-hot-toast';
import { usePlatformUsers } from '../../hooks/usePlatform';
import { useCurrentUser } from '../../hooks/useAuth';
import EditPlatformUserModal from './components/EditPlatformUserModal';
import {
usePlatformStaffInvitations,
useCreatePlatformStaffInvitation,
useResendPlatformStaffInvitation,
useCancelPlatformStaffInvitation,
type PlatformStaffRole,
type PlatformStaffInvitation,
} from '../../hooks/usePlatformStaffInvitations';
interface PlatformUser {
id: number;
@@ -32,11 +45,6 @@ interface PlatformUser {
role: string;
is_active: boolean;
is_superuser: boolean;
permissions: {
can_approve_plugins?: boolean;
can_whitelist_urls?: boolean;
[key: string]: any;
};
created_at: string;
last_login?: string;
}
@@ -47,8 +55,23 @@ const PlatformStaff: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const [selectedUser, setSelectedUser] = useState<PlatformUser | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [showInvitations, setShowInvitations] = useState(false);
// Invitation form state
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<PlatformStaffRole>('platform_support');
const [inviteMessage, setInviteMessage] = useState('');
const { data: allUsers, isLoading, error } = usePlatformUsers();
const { data: invitations, isLoading: invitationsLoading } = usePlatformStaffInvitations();
const createInvitation = useCreatePlatformStaffInvitation();
const resendInvitation = useResendPlatformStaffInvitation();
const cancelInvitation = useCancelPlatformStaffInvitation();
const pendingInvitations = (invitations || []).filter(
(inv: PlatformStaffInvitation) => inv.status === 'PENDING'
);
// Filter to only show platform staff (not superusers, not business users)
const platformStaff = (allUsers || []).filter(
@@ -92,6 +115,51 @@ const PlatformStaff: React.FC = () => {
setIsEditModalOpen(true);
};
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim()) {
toast.error('Email is required');
return;
}
try {
await createInvitation.mutateAsync({
email: inviteEmail.trim(),
role: inviteRole,
personal_message: inviteMessage.trim(),
});
toast.success('Invitation sent successfully!');
setIsInviteModalOpen(false);
resetInviteForm();
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to send invitation');
}
};
const handleResendInvitation = async (id: number) => {
try {
await resendInvitation.mutateAsync(id);
toast.success('Invitation resent');
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to resend invitation');
}
};
const handleCancelInvitation = async (id: number) => {
try {
await cancelInvitation.mutateAsync(id);
toast.success('Invitation cancelled');
} catch (err: any) {
toast.error(err.response?.data?.detail || 'Failed to cancel invitation');
}
};
const resetInviteForm = () => {
setInviteEmail('');
setInviteRole('platform_support');
setInviteMessage('');
};
const getRoleBadge = (role: string) => {
if (role === 'platform_manager') {
return (
@@ -156,13 +224,10 @@ const PlatformStaff: React.FC = () => {
</div>
<button
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors"
onClick={() => {
// TODO: Implement create new staff member
alert('Create new staff member - coming soon');
}}
onClick={() => setIsInviteModalOpen(true)}
>
<Plus className="w-4 h-4" />
Add Staff Member
Invite Staff Member
</button>
</div>
</div>
@@ -182,7 +247,7 @@ const PlatformStaff: React.FC = () => {
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">Total Staff</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
@@ -201,8 +266,75 @@ const PlatformStaff: React.FC = () => {
{platformStaff.filter((u: any) => u.role === 'platform_support').length}
</div>
</div>
<button
onClick={() => setShowInvitations(!showInvitations)}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-left hover:border-indigo-300 dark:hover:border-indigo-700 transition-colors"
>
<div className="flex items-center justify-between">
<div className="text-sm text-gray-500 dark:text-gray-400">Pending Invitations</div>
{pendingInvitations.length > 0 && (
<span className="w-2 h-2 bg-amber-500 rounded-full animate-pulse"></span>
)}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
{pendingInvitations.length}
</div>
</button>
</div>
{/* Pending Invitations */}
{showInvitations && pendingInvitations.length > 0 && (
<div className="mb-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
<h3 className="text-lg font-medium text-amber-800 dark:text-amber-200 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5" />
Pending Invitations
</h3>
<div className="space-y-3">
{pendingInvitations.map((inv: PlatformStaffInvitation) => (
<div
key={inv.id}
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-4 border border-amber-200 dark:border-amber-800"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-white font-semibold">
<Mail className="w-5 h-5" />
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{inv.email}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{inv.role_display} &bull; Invited by {inv.invited_by || 'Unknown'} &bull; Expires{' '}
{inv.expires_at
? new Date(inv.expires_at).toLocaleDateString()
: 'in 7 days'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleResendInvitation(inv.id)}
disabled={resendInvitation.isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors disabled:opacity-50"
>
<RefreshCw className="w-3.5 h-3.5" />
Resend
</button>
<button
onClick={() => handleCancelInvitation(inv.id)}
disabled={cancelInvitation.isPending}
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 dark:border-red-800 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
>
<X className="w-3.5 h-3.5" />
Cancel
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Staff List */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<table className="w-full text-sm">
@@ -214,9 +346,6 @@ const PlatformStaff: React.FC = () => {
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Permissions
</th>
<th className="px-6 py-4 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Status
</th>
@@ -254,27 +383,6 @@ const PlatformStaff: React.FC = () => {
{/* Role */}
<td className="px-6 py-4">{getRoleBadge(user.role)}</td>
{/* Permissions */}
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{user.permissions?.can_approve_plugins && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
Plugin Approver
</span>
)}
{user.permissions?.can_whitelist_urls && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
URL Whitelister
</span>
)}
{!user.permissions?.can_approve_plugins && !user.permissions?.can_whitelist_urls && (
<span className="text-xs text-gray-400 dark:text-gray-500">
No special permissions
</span>
)}
</div>
</td>
{/* Status */}
<td className="px-6 py-4">
{user.is_active ? (
@@ -341,6 +449,108 @@ const PlatformStaff: React.FC = () => {
user={selectedUser}
/>
)}
{/* Invite Modal */}
{isInviteModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Send className="w-5 h-5" />
Invite Staff Member
</h2>
<button
onClick={() => {
setIsInviteModalOpen(false);
resetInviteForm();
}}
className="p-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<form onSubmit={handleInvite} className="p-6 space-y-4">
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Email Address <span className="text-red-500">*</span>
</label>
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="email@example.com"
required
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
{/* Role */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Role <span className="text-red-500">*</span>
</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as PlatformStaffRole)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
>
<option value="platform_support">Platform Support</option>
<option value="platform_manager">Platform Manager</option>
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{inviteRole === 'platform_manager'
? 'Managers can oversee platform operations and manage tenants.'
: 'Support staff can help users and respond to tickets.'}
</p>
</div>
{/* Personal Message */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Personal Message (Optional)
</label>
<textarea
value={inviteMessage}
onChange={(e) => setInviteMessage(e.target.value)}
placeholder="Add a personal note to the invitation email..."
rows={3}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 resize-none"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => {
setIsInviteModalOpen(false);
resetInviteForm();
}}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={createInvitation.isPending || !inviteEmail.trim()}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{createInvitation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Send Invitation
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,334 @@
import React, { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
usePlatformStaffInvitationByToken,
useAcceptPlatformStaffInvitation,
} from '../../hooks/usePlatformStaffInvitations';
import { useAuth } from '../../hooks/useAuth';
import {
Loader2,
CheckCircle,
XCircle,
Shield,
Mail,
User,
Lock,
AlertCircle,
Eye,
EyeOff,
} from 'lucide-react';
const PlatformStaffInvitePage: React.FC = () => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const { data: invitation, isLoading, error } = usePlatformStaffInvitationByToken(token);
const acceptMutation = useAcceptPlatformStaffInvitation();
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 handleAccept = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
if (!firstName.trim()) {
setFormError(t('platformStaffInvite.firstNameRequired', 'First name is required'));
return;
}
if (!password || password.length < 10) {
setFormError(t('platformStaffInvite.passwordMinLength', 'Password must be at least 10 characters'));
return;
}
if (password !== confirmPassword) {
setFormError(t('platformStaffInvite.passwordsMustMatch', 'Passwords do not match'));
return;
}
try {
const result = await acceptMutation.mutateAsync({
token: token!,
data: {
password,
first_name: firstName.trim(),
last_name: lastName.trim(),
},
});
// Set auth tokens and redirect to platform dashboard
if (result.access && result.refresh) {
setTokens(result.access, result.refresh);
}
setAccepted(true);
// Redirect after a short delay
setTimeout(() => {
navigate('/platform/dashboard');
}, 2000);
} catch (err: any) {
setFormError(
err.response?.data?.detail ||
err.response?.data?.error ||
t('platformStaffInvite.acceptFailed', 'Failed to accept 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('platformStaffInvite.invalidLink', 'Invalid Invitation Link')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t(
'platformStaffInvite.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-indigo-600 animate-spin mb-4" />
<p className="text-gray-500 dark:text-gray-400">
{t('platformStaffInvite.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('platformStaffInvite.expiredTitle', 'Invitation Expired or Invalid')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t(
'platformStaffInvite.expiredDescription',
'This invitation has expired or is no longer valid. Please contact the administrator to request a new invitation.'
)}
</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('platformStaffInvite.welcomeTitle', 'Welcome to the Platform Team!')}
</h1>
<p className="text-gray-500 dark:text-gray-400">
{t('platformStaffInvite.redirecting', 'Your account has been created. Redirecting to dashboard...')}
</p>
<Loader2 size={24} className="mx-auto text-indigo-600 animate-spin mt-4" />
</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-gradient-to-r from-indigo-600 to-purple-600 p-6 text-center">
<Shield size={40} className="mx-auto text-white/90 mb-2" />
<h1 className="text-2xl font-bold text-white mb-2">
{t('platformStaffInvite.title', 'Platform Staff Invitation')}
</h1>
<p className="text-indigo-100">
{t('platformStaffInvite.subtitle', 'Join the SmoothSchedule platform team')}
</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-indigo-100 dark:bg-indigo-900/50 flex items-center justify-center text-indigo-600 dark:text-indigo-400">
<Shield size={20} />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t('platformStaffInvite.role', 'Role')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">{invitation.role_display}</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('platformStaffInvite.email', 'Email')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">{invitation.email}</p>
</div>
</div>
{invitation.role_description && (
<div className="mt-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">{invitation.role_description}</p>
</div>
)}
{invitation.personal_message && (
<div className="mt-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
<p className="text-xs text-indigo-600 dark:text-indigo-400 uppercase tracking-wide mb-1">
{t('platformStaffInvite.personalMessage', 'Personal Message')}
</p>
<p className="text-sm text-gray-700 dark:text-gray-300 italic">"{invitation.personal_message}"</p>
</div>
)}
{invitation.invited_by && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center pt-2">
{t('platformStaffInvite.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('platformStaffInvite.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('platformStaffInvite.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-indigo-500 focus:border-indigo-500"
placeholder="John"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.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-indigo-500 focus:border-indigo-500"
placeholder="Doe"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('platformStaffInvite.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={10}
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-indigo-500 focus:border-indigo-500"
placeholder="Min. 10 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('platformStaffInvite.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-indigo-500 focus:border-indigo-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>
)}
{/* Submit Button */}
<div className="pt-4">
<button
type="submit"
disabled={acceptMutation.isPending}
className="w-full px-4 py-3 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{acceptMutation.isPending ? (
<Loader2 size={18} className="animate-spin" />
) : (
<CheckCircle size={18} />
)}
{t('platformStaffInvite.acceptButton', 'Accept Invitation & Create Account')}
</button>
</div>
</form>
</div>
</div>
);
};
export default PlatformStaffInvitePage;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { X, Save, RefreshCw, AlertCircle } from 'lucide-react';
import { X, Save, AlertCircle } from 'lucide-react';
import { useUpdateBusiness, useChangeBusinessPlan } from '../../../hooks/usePlatform';
import {
useBillingPlans,
@@ -33,6 +33,9 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
const [loadingCustomTier, setLoadingCustomTier] = useState(false);
const [deletingCustomTier, setDeletingCustomTier] = useState(false);
// Toggle for custom features - when true, features are editable and saved to custom tier
const [useCustomFeatures, setUseCustomFeatures] = useState(false);
// Core form fields (non-feature fields only)
const [editForm, setEditForm] = useState({
name: '',
@@ -118,27 +121,33 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
setFeatureValues(featureDefaults);
};
// Reset to plan defaults button handler
const handleResetToPlanDefaults = async () => {
// Handle toggling custom features on/off
const handleCustomFeaturesToggle = async (enabled: boolean) => {
if (!business) return;
// If custom tier exists, delete it
if (customTier) {
setDeletingCustomTier(true);
try {
await deleteCustomTier(business.id);
setCustomTier(null);
} catch (error) {
console.error('Failed to delete custom tier:', error);
if (enabled) {
// Enable custom features - just toggle the state
// Custom tier will be created when saving
setUseCustomFeatures(true);
} else {
// Disable custom features - delete custom tier and reset to plan defaults
if (customTier) {
setDeletingCustomTier(true);
try {
await deleteCustomTier(business.id);
setCustomTier(null);
} catch (error) {
console.error('Failed to delete custom tier:', error);
setDeletingCustomTier(false);
return;
}
setDeletingCustomTier(false);
return;
}
setDeletingCustomTier(false);
setUseCustomFeatures(false);
// Reset to plan defaults
const featureDefaults = getPlanDefaults(editForm.plan_code);
setFeatureValues(featureDefaults);
}
// Reset all feature values to plan defaults (includes limits)
const featureDefaults = getPlanDefaults(editForm.plan_code);
setFeatureValues(featureDefaults);
};
// Map tier name/code to plan code
@@ -191,6 +200,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
const tier = await getCustomTier(businessId);
console.log('[loadCustomTier] Got tier:', tier ? 'exists' : 'null');
setCustomTier(tier);
setUseCustomFeatures(!!tier); // Enable custom features if tier exists
if (tier && billingFeatures) {
// Custom tier exists - load features from custom tier
@@ -227,6 +237,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
// 404 means no custom tier exists - this is expected, load plan defaults
console.log('[loadCustomTier] Error (likely 404):', error);
setCustomTier(null);
setUseCustomFeatures(false);
if (business) {
const planCode = tierToPlanCode(business.tier);
console.log('[loadCustomTier] Loading plan defaults for:', planCode);
@@ -269,19 +280,25 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
if (!business || !billingFeatures) return;
try {
// Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
const featuresForBackend: Record<string, boolean | number | null> = {};
for (const feature of billingFeatures) {
if (!feature.tenant_field_name) continue;
const value = featureValues[feature.tenant_field_name];
if (value !== undefined) {
// Use feature.code as the key for the backend
featuresForBackend[feature.code] = value;
// Only save custom tier if custom features are enabled
if (useCustomFeatures) {
// Convert featureValues (keyed by tenant_field_name) to feature codes for the backend
const featuresForBackend: Record<string, boolean | number | null> = {};
for (const feature of billingFeatures) {
if (!feature.tenant_field_name) continue;
const value = featureValues[feature.tenant_field_name];
if (value !== undefined) {
// Use feature.code as the key for the backend
featuresForBackend[feature.code] = value;
}
}
}
// Save feature values to custom tier
await updateCustomTier(business.id, featuresForBackend);
// Save feature values to custom tier
await updateCustomTier(business.id, featuresForBackend);
} else if (customTier) {
// Custom features disabled but tier exists - delete it
await deleteCustomTier(business.id);
}
// Extract only the fields that the update endpoint accepts (exclude plan_code and feature values)
const { plan_code, ...coreFields } = editForm;
@@ -318,14 +335,14 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
<span className="px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
Loading...
</span>
) : customTier ? (
) : useCustomFeatures ? (
<span className="flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300">
<AlertCircle size={12} />
Custom Tier
Custom Features
</span>
) : (
<span className="px-2 py-1 text-xs rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
Plan Defaults
Plan Features
</span>
)}
</div>
@@ -374,24 +391,9 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
{/* Subscription Plan */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Subscription Plan
</label>
<button
type="button"
onClick={handleResetToPlanDefaults}
disabled={plansLoading || deletingCustomTier}
className="flex items-center gap-1 text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-800 dark:hover:text-indigo-300 disabled:opacity-50"
>
<RefreshCw size={12} className={deletingCustomTier ? 'animate-spin' : ''} />
{deletingCustomTier
? 'Deleting...'
: customTier
? 'Delete custom tier & reset to plan defaults'
: 'Reset to plan defaults'}
</button>
</div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Subscription Plan
</label>
<select
value={editForm.plan_code}
onChange={(e) => handlePlanChange(e.target.value)}
@@ -417,12 +419,34 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
)}
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{customTier
? 'This business has a custom tier. Feature changes will be saved to the custom tier.'
: 'Changing plan will auto-update limits and permissions to plan defaults'}
Changing plan will auto-update limits and permissions to plan defaults
</p>
</div>
{/* Custom Features Toggle */}
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div>
<label className="text-sm font-medium text-gray-900 dark:text-white">
Enable Custom Features
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{useCustomFeatures
? 'Features below will be saved as a custom tier (overrides plan)'
: 'Features below are from the subscription plan (read-only)'}
</p>
</div>
<button
type="button"
onClick={() => handleCustomFeaturesToggle(!useCustomFeatures)}
disabled={loadingCustomTier || deletingCustomTier}
className={`${useCustomFeatures ? 'bg-purple-600' : 'bg-gray-300 dark:bg-gray-600'} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50`}
role="switch"
aria-checked={useCustomFeatures}
>
<span className={`${useCustomFeatures ? 'translate-x-5' : 'translate-x-0'} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`} />
</button>
</div>
{/* Limits & Quotas - Dynamic from billing system */}
<div className="space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<DynamicFeaturesEditor
@@ -434,6 +458,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
headerTitle="Limits & Quotas"
showDescriptions
columns={4}
disabled={!useCustomFeatures}
/>
</div>
@@ -468,6 +493,7 @@ const BusinessEditModal: React.FC<BusinessEditModalProps> = ({ business, isOpen,
featureType="boolean"
headerTitle="Features & Permissions"
showDescriptions
disabled={!useCustomFeatures}
/>
</div>

View File

@@ -4,7 +4,6 @@
* - Basic info (name, email, username)
* - Password reset
* - Role assignment
* - Permissions (can_approve_plugins, etc.)
* - Account status (active/inactive)
*/
@@ -22,6 +21,9 @@ import {
Eye,
EyeOff,
AlertCircle,
Trash2,
Archive,
Loader2,
} from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../../../api/client';
@@ -38,10 +40,6 @@ interface EditPlatformUserModalProps {
last_name: string;
role: string;
is_active: boolean;
permissions: {
can_approve_plugins?: boolean;
[key: string]: any;
};
};
}
@@ -61,13 +59,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
const canEditRole = currentRole === 'superuser' ||
(currentRole === 'platform_manager' && targetRole === 'platform_support');
// Get available permissions for current user
// Superusers always have all permissions, others check the permissions field
const availablePermissions = {
can_approve_plugins: currentRole === 'superuser' || !!currentUser?.permissions?.can_approve_plugins,
can_whitelist_urls: currentRole === 'superuser' || !!currentUser?.permissions?.can_whitelist_urls,
};
// Form state
const [formData, setFormData] = useState({
username: user.username,
@@ -78,15 +69,15 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
is_active: user.is_active,
});
const [permissions, setPermissions] = useState({
can_approve_plugins: user.permissions?.can_approve_plugins || false,
can_whitelist_urls: user.permissions?.can_whitelist_urls || false,
});
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [passwordError, setPasswordError] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
// Check if current user can delete/archive (only superuser, and not themselves)
const canDelete = currentRole === 'superuser' && currentUser?.id !== user.id;
// Update mutation
const updateMutation = useMutation({
@@ -100,6 +91,31 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async () => {
await apiClient.delete(`/platform/users/${user.id}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
onClose();
},
});
// Archive mutation (deactivate user)
const archiveMutation = useMutation({
mutationFn: async () => {
const response = await apiClient.patch(`/platform/users/${user.id}/`, {
is_active: false,
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platform', 'users'] });
onClose();
},
});
// Reset form when user changes
useEffect(() => {
setFormData({
@@ -110,13 +126,11 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
role: user.role,
is_active: user.is_active,
});
setPermissions({
can_approve_plugins: user.permissions?.can_approve_plugins || false,
can_whitelist_urls: user.permissions?.can_whitelist_urls || false,
});
setPassword('');
setConfirmPassword('');
setPasswordError('');
setShowDeleteConfirm(false);
setShowArchiveConfirm(false);
}, [user]);
const handleSubmit = (e: React.FormEvent) => {
@@ -137,7 +151,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
// Prepare update data
const updateData: any = {
...formData,
permissions: permissions,
};
// Only include password if changed
@@ -148,13 +161,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
updateMutation.mutate(updateData);
};
const handlePermissionToggle = (permission: string) => {
setPermissions((prev) => ({
...prev,
[permission]: !prev[permission as keyof typeof prev],
}));
};
if (!isOpen) return null;
return (
@@ -293,56 +299,6 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
</div>
</div>
{/* Permissions */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
Special Permissions
</h4>
<div className="space-y-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
{availablePermissions.can_approve_plugins && (
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={permissions.can_approve_plugins}
onChange={() => handlePermissionToggle('can_approve_plugins')}
className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
Can Approve Plugins
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Allow this user to review and approve community plugins for the marketplace
</div>
</div>
</label>
)}
{availablePermissions.can_whitelist_urls && (
<label className="flex items-start gap-3 cursor-pointer group">
<input
type="checkbox"
checked={permissions.can_whitelist_urls}
onChange={() => handlePermissionToggle('can_whitelist_urls')}
className="mt-0.5 w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-white group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
Can Whitelist URLs
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
Allow this user to whitelist external URLs for plugin API calls (per-user and platform-wide)
</div>
</div>
</label>
)}
{!availablePermissions.can_approve_plugins && !availablePermissions.can_whitelist_urls && (
<p className="text-sm text-gray-500 dark:text-gray-400 italic">
You don't have any special permissions to grant.
</p>
)}
</div>
</div>
{/* Password Reset */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
@@ -445,22 +401,95 @@ const EditPlatformUserModal: React.FC<EditPlatformUserModalProps> = ({
)}
{/* Actions */}
<div className="mt-6 flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm font-medium rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
<div className="mt-6 flex items-center justify-between pt-6 border-t border-gray-200 dark:border-gray-700">
{/* Left side - Destructive actions */}
<div className="flex items-center gap-2">
{canDelete && !showDeleteConfirm && !showArchiveConfirm && (
<>
<button
type="button"
onClick={() => setShowArchiveConfirm(true)}
disabled={!user.is_active}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title={!user.is_active ? 'User is already archived' : 'Archive user'}
>
<Archive className="w-4 h-4" />
Archive
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</>
)}
{/* Archive confirmation */}
{showArchiveConfirm && (
<div className="flex items-center gap-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
<span className="text-sm text-amber-700 dark:text-amber-300">Archive this user?</span>
<button
type="button"
onClick={() => archiveMutation.mutate()}
disabled={archiveMutation.isPending}
className="px-2 py-1 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors disabled:opacity-50"
>
{archiveMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Yes, Archive'}
</button>
<button
type="button"
onClick={() => setShowArchiveConfirm(false)}
className="px-2 py-1 text-xs font-medium text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-900/40 rounded transition-colors"
>
Cancel
</button>
</div>
)}
{/* Delete confirmation */}
{showDeleteConfirm && (
<div className="flex items-center gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg px-3 py-2">
<span className="text-sm text-red-700 dark:text-red-300">Permanently delete?</span>
<button
type="button"
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="px-2 py-1 text-xs font-medium text-white bg-red-600 hover:bg-red-700 rounded transition-colors disabled:opacity-50"
>
{deleteMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Yes, Delete'}
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/40 rounded transition-colors"
>
Cancel
</button>
</div>
)}
</div>
{/* Right side - Save/Cancel */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={updateMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white text-sm font-medium rounded-lg transition-colors"
>
<Save className="w-4 h-4" />
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</form>
</div>

View File

@@ -186,17 +186,16 @@ const TenantInviteModal: React.FC<TenantInviteModalProps> = ({ isOpen, onClose }
return {
max_users: getIntegerFeature(features, 'max_users') ?? TIER_DEFAULTS[planCode]?.max_users ?? 5,
max_resources: getIntegerFeature(features, 'max_resources') ?? TIER_DEFAULTS[planCode]?.max_resources ?? 10,
can_manage_oauth_credentials: getBooleanFeature(features, 'remove_branding') && getBooleanFeature(features, 'api_access'),
can_manage_oauth_credentials: getBooleanFeature(features, 'can_white_label') && getBooleanFeature(features, 'api_access'),
can_accept_payments: getBooleanFeature(features, 'payment_processing'),
can_use_custom_domain: getBooleanFeature(features, 'custom_domain'),
can_white_label: getBooleanFeature(features, 'remove_branding'),
can_white_label: getBooleanFeature(features, 'can_white_label'),
can_api_access: getBooleanFeature(features, 'api_access'),
can_add_video_conferencing: getBooleanFeature(features, 'integrations_enabled'),
can_use_sms_reminders: getBooleanFeature(features, 'sms_enabled'),
can_use_masked_phone_numbers: getBooleanFeature(features, 'masked_calling_enabled'),
can_use_plugins: true, // Always enabled
can_use_tasks: true, // Always enabled
can_create_plugins: getBooleanFeature(features, 'api_access'),
can_use_automations: getBooleanFeature(features, 'can_use_automations') ?? true,
can_use_tasks: getBooleanFeature(features, 'can_use_tasks') ?? true,
can_create_automations: getBooleanFeature(features, 'can_create_automations'),
can_use_webhooks: getBooleanFeature(features, 'integrations_enabled'),
can_use_calendar_sync: getBooleanFeature(features, 'integrations_enabled'),
can_export_data: getBooleanFeature(features, 'advanced_reporting'),

View File

@@ -32,20 +32,12 @@ const mockUser = {
last_name: 'User',
role: 'platform_support',
is_active: true,
permissions: {
can_approve_plugins: false,
can_whitelist_urls: false,
},
};
const mockSuperuser = {
id: 2,
email: 'admin@example.com',
role: 'superuser',
permissions: {
can_approve_plugins: true,
can_whitelist_urls: true,
},
};
const createWrapper = () => {
@@ -331,32 +323,6 @@ describe('EditPlatformUserModal', () => {
});
});
describe('Permissions Section', () => {
it('shows permissions section for superuser', () => {
render(
React.createElement(EditPlatformUserModal, {
isOpen: true,
onClose: vi.fn(),
user: mockUser,
}),
{ wrapper: createWrapper() }
);
expect(screen.getByText('Special Permissions')).toBeInTheDocument();
});
it('shows can approve plugins permission', () => {
render(
React.createElement(EditPlatformUserModal, {
isOpen: true,
onClose: vi.fn(),
user: mockUser,
}),
{ wrapper: createWrapper() }
);
expect(screen.getByText(/Approve Plugins/i)).toBeInTheDocument();
});
});
describe('Account Status', () => {
it('shows status toggle', () => {
render(

View File

@@ -253,7 +253,7 @@ const SystemEmailTemplates: React.FC = () => {
...item,
props: {
...item.props,
id: `${item.type}-${index}-${crypto.randomUUID().substring(0, 8)}`,
id: `${item.type}-${index}-${Math.random().toString(36).substring(2, 10)}`,
},
};
}

View File

@@ -36,14 +36,12 @@ export interface PlanPermissions {
webhooks: boolean;
api_access: boolean;
custom_domain: boolean;
custom_branding: boolean;
remove_branding: boolean;
white_label: boolean;
custom_oauth: boolean;
automations: boolean;
can_create_automations: boolean;
tasks: boolean;
export_data: boolean;
video_conferencing: boolean;
two_factor_auth: boolean;
masked_calling: boolean;
pos_system: boolean;
@@ -1130,4 +1128,58 @@ export interface StaffEmailStats {
count: number;
unread: number;
}[];
}
// --- Platform Email Template Types (Puck-based, superuser only) ---
export type PlatformEmailType =
| 'tenant_invitation'
| 'trial_expiration_warning'
| 'trial_expired'
| 'plan_upgrade'
| 'plan_downgrade'
| 'subscription_cancelled'
| 'payment_failed'
| 'payment_succeeded';
export type PlatformEmailCategory =
| 'invitation'
| 'trial'
| 'subscription'
| 'billing';
export interface PlatformEmailTemplate {
email_type: PlatformEmailType;
subject_template: string;
puck_data: Record<string, any>;
is_active: boolean;
is_customized: boolean;
display_name: string;
description: string;
category: PlatformEmailCategory;
created_at?: string;
updated_at?: string;
}
export interface PlatformEmailTemplateDetail extends PlatformEmailTemplate {
available_tags: PlatformEmailTag[];
created_by?: number | null;
created_by_email?: string | null;
}
export interface PlatformEmailTag {
name: string;
description: string;
syntax: string;
}
export interface PlatformEmailTemplatePreview {
subject: string;
html: string;
}
export interface PlatformEmailTemplateUpdate {
subject_template: string;
puck_data: Record<string, any>;
is_active?: boolean;
}