feat: Plan-based feature permissions and quota enforcement

Backend:
- Add HasQuota() permission factory for quota limits (resources, users, services, appointments, email templates, automated tasks)
- Add HasFeaturePermission() factory for feature-based permissions (SMS, masked calling, custom domains, white label, plugins, webhooks, calendar sync, analytics)
- Add has_feature() method to Tenant model for flexible permission checking
- Add new tenant permission fields: can_create_plugins, can_use_webhooks, can_use_calendar_sync, can_export_data
- Create Data Export API with CSV/JSON support for appointments, customers, resources, services
- Create Analytics API with dashboard, appointments, revenue endpoints
- Add calendar sync views and URL configuration

Frontend:
- Add usePlanFeatures hook for checking feature availability
- Add UpgradePrompt components (inline, banner, overlay variants)
- Add LockedSection wrapper and LockedButton for feature gating
- Update settings pages with permission checks

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-02 11:21:11 -05:00
parent 05ebd0f2bb
commit e4ad7fca87
46 changed files with 6582 additions and 21 deletions

View File

@@ -10,6 +10,8 @@ import { useOutletContext } from 'react-router-dom';
import { Key } from 'lucide-react';
import { Business, User } from '../../types';
import ApiTokensSection from '../../components/ApiTokensSection';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
import { LockedSection } from '../../components/UpgradePrompt';
const ApiSettings: React.FC = () => {
const { t } = useTranslation();
@@ -19,6 +21,7 @@ const ApiSettings: React.FC = () => {
}>();
const isOwner = user.role === 'owner';
const { canUse } = usePlanFeatures();
if (!isOwner) {
return (
@@ -44,7 +47,9 @@ const ApiSettings: React.FC = () => {
</div>
{/* API Tokens Section */}
<ApiTokensSection />
<LockedSection feature="api_access" isLocked={!canUse('api_access')}>
<ApiTokensSection />
</LockedSection>
</div>
);
};

View File

@@ -11,6 +11,8 @@ import { Lock, Users, Key, Save, Check, AlertCircle, Eye, EyeOff } from 'lucide-
import { Business, User } from '../../types';
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../../hooks/useBusinessOAuth';
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../../hooks/useBusinessOAuthCredentials';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
import { LockedSection } from '../../components/UpgradePrompt';
// Provider display names and icons
const providerInfo: Record<string, { name: string; icon: string }> = {
@@ -57,6 +59,7 @@ const AuthenticationSettings: React.FC = () => {
const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner';
const { canUse } = usePlanFeatures();
// Update OAuth settings when data loads
useEffect(() => {
@@ -167,10 +170,11 @@ const AuthenticationSettings: React.FC = () => {
</p>
</div>
{/* OAuth & Social Login */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<LockedSection feature="custom_oauth" isLocked={!canUse('custom_oauth')}>
{/* OAuth & Social Login */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Users size={20} className="text-indigo-500" /> Social Login
</h3>
@@ -420,6 +424,7 @@ const AuthenticationSettings: React.FC = () => {
Changes saved successfully
</div>
)}
</LockedSection>
</div>
);
};

View File

@@ -18,6 +18,8 @@ import {
useUpdateCreditsSettings,
} from '../../hooks/useCommunicationCredits';
import { CreditPaymentModal } from '../../components/CreditPaymentForm';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
import { LockedSection } from '../../components/UpgradePrompt';
const CommunicationSettings: React.FC = () => {
const { t } = useTranslation();
@@ -59,6 +61,7 @@ const CommunicationSettings: React.FC = () => {
const [topUpAmount, setTopUpAmount] = useState(2500);
const isOwner = user.role === 'owner';
const { canUse } = usePlanFeatures();
// Update settings form when credits data loads
useEffect(() => {
@@ -178,6 +181,8 @@ const CommunicationSettings: React.FC = () => {
)}
</div>
<LockedSection feature="sms_reminders" isLocked={!canUse('sms_reminders')}>
{/* Setup Wizard or Main Content */}
{needsSetup || showWizard ? (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6">
@@ -720,6 +725,7 @@ const CommunicationSettings: React.FC = () => {
defaultAmount={topUpAmount}
onSuccess={handlePaymentSuccess}
/>
</LockedSection>
</div>
);
};

View File

@@ -20,6 +20,8 @@ import {
useSetPrimaryDomain
} from '../../hooks/useCustomDomains';
import DomainPurchase from '../../components/DomainPurchase';
import { usePlanFeatures } from '../../hooks/usePlanFeatures';
import { LockedSection } from '../../components/UpgradePrompt';
const DomainsSettings: React.FC = () => {
const { t } = useTranslation();
@@ -42,6 +44,7 @@ const DomainsSettings: React.FC = () => {
const [showToast, setShowToast] = useState(false);
const isOwner = user.role === 'owner';
const { canUse } = usePlanFeatures();
const handleAddDomain = () => {
if (!newDomain.trim()) return;
@@ -125,9 +128,10 @@ const DomainsSettings: React.FC = () => {
</p>
</div>
{/* Quick Domain Setup - Booking URL */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<LockedSection feature="custom_domain" isLocked={!canUse('custom_domain')}>
{/* Quick Domain Setup - Booking URL */}
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Link2 size={20} className="text-brand-500" /> Your Booking URL
</h3>
<div className="flex items-center gap-3 p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg">
@@ -326,6 +330,7 @@ const DomainsSettings: React.FC = () => {
Changes saved successfully
</div>
)}
</LockedSection>
</div>
);
};