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:
@@ -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 />} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
150
frontend/src/components/AppointmentQuotaBanner.tsx
Normal file
150
frontend/src/components/AppointmentQuotaBanner.tsx
Normal 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;
|
||||
@@ -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>}
|
||||
|
||||
179
frontend/src/components/StorageQuotaBanner.tsx
Normal file
179
frontend/src/components/StorageQuotaBanner.tsx
Normal 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;
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
116
frontend/src/hooks/usePlatformEmailTemplates.ts
Normal file
116
frontend/src/hooks/usePlatformEmailTemplates.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
178
frontend/src/hooks/usePlatformStaffInvitations.ts
Normal file
178
frontend/src/hooks/usePlatformStaffInvitations.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
111
frontend/src/hooks/useQuotaStatus.ts
Normal file
111
frontend/src/hooks/useQuotaStatus.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
635
frontend/src/pages/platform/PlatformEmailTemplates.tsx
Normal file
635
frontend/src/pages/platform/PlatformEmailTemplates.tsx
Normal 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;
|
||||
@@ -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} • Invited by {inv.invited_by || 'Unknown'} • 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>
|
||||
);
|
||||
};
|
||||
|
||||
334
frontend/src/pages/platform/PlatformStaffInvitePage.tsx
Normal file
334
frontend/src/pages/platform/PlatformStaffInvitePage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user