diff --git a/backend/igny8_core/ai/ai_core.py b/backend/igny8_core/ai/ai_core.py index 097c1eab..df9f166b 100644 --- a/backend/igny8_core/ai/ai_core.py +++ b/backend/igny8_core/ai/ai_core.py @@ -43,32 +43,48 @@ class AICore: self._load_account_settings() def _load_account_settings(self): - """Load API keys and model from IntegrationSettings or Django settings""" - if self.account: + """Load API keys from IntegrationSettings with fallbacks (account -> system account -> Django settings)""" + def get_system_account(): + try: + from igny8_core.auth.models import Account + for slug in ['aws-admin', 'default-account', 'default']: + acct = Account.objects.filter(slug=slug).first() + if acct: + return acct + except Exception: + return None + return None + + def get_integration_key(integration_type: str, account): + if not account: + return None try: from igny8_core.modules.system.models import IntegrationSettings - - # Load OpenAI settings - openai_settings = IntegrationSettings.objects.filter( - integration_type='openai', - account=self.account, + settings_obj = IntegrationSettings.objects.filter( + integration_type=integration_type, + account=account, is_active=True ).first() - if openai_settings and openai_settings.config: - self._openai_api_key = openai_settings.config.get('apiKey') - - # Load Runware settings - runware_settings = IntegrationSettings.objects.filter( - integration_type='runware', - account=self.account, - is_active=True - ).first() - if runware_settings and runware_settings.config: - self._runware_api_key = runware_settings.config.get('apiKey') + if settings_obj and settings_obj.config: + return settings_obj.config.get('apiKey') except Exception as e: - logger.warning(f"Could not load account settings: {e}", exc_info=True) - - # Fallback to Django settings for API keys only (no model fallback) + logger.warning(f"Could not load {integration_type} settings for account {getattr(account, 'id', None)}: {e}", exc_info=True) + return None + + # 1) Account-specific keys + if self.account: + self._openai_api_key = get_integration_key('openai', self.account) + self._runware_api_key = get_integration_key('runware', self.account) + + # 2) Fallback to system account keys (shared across tenants) + if not self._openai_api_key or not self._runware_api_key: + system_account = get_system_account() + if not self._openai_api_key: + self._openai_api_key = get_integration_key('openai', system_account) + if not self._runware_api_key: + self._runware_api_key = get_integration_key('runware', system_account) + + # 3) Fallback to Django settings if not self._openai_api_key: self._openai_api_key = getattr(settings, 'OPENAI_API_KEY', None) if not self._runware_api_key: diff --git a/backend/igny8_core/api/permissions.py b/backend/igny8_core/api/permissions.py index dc006ea7..be1a13e9 100644 --- a/backend/igny8_core/api/permissions.py +++ b/backend/igny8_core/api/permissions.py @@ -160,3 +160,21 @@ class IsAdminOrOwner(permissions.BasePermission): return False +class IsSystemAccountOrDeveloper(permissions.BasePermission): + """ + Allow only system accounts (aws-admin/default-account/default) or developer role. + Use for sensitive, globally-scoped settings like integration API keys. + """ + def has_permission(self, request, view): + user = getattr(request, "user", None) + if not user or not user.is_authenticated: + return False + + account_slug = getattr(getattr(user, "account", None), "slug", None) + if user.role == "developer": + return True + if account_slug in ["aws-admin", "default-account", "default"]: + return True + return False + + diff --git a/backend/igny8_core/api/throttles.py b/backend/igny8_core/api/throttles.py index 617403d2..3eb8e195 100644 --- a/backend/igny8_core/api/throttles.py +++ b/backend/igny8_core/api/throttles.py @@ -41,9 +41,11 @@ class DebugScopedRateThrottle(ScopedRateThrottle): if not request.user or not hasattr(request.user, 'is_authenticated') or not request.user.is_authenticated: public_blueprint_bypass = True - # Bypass for system account users (aws-admin, default-account, etc.) + # Bypass for authenticated users (avoid user-facing 429s) and system accounts system_account_bypass = False + authenticated_bypass = False if hasattr(request, 'user') and request.user and hasattr(request.user, 'is_authenticated') and request.user.is_authenticated: + authenticated_bypass = True # Do not throttle logged-in users try: # Check if user is in system account (aws-admin, default-account, default) if hasattr(request.user, 'is_system_account_user') and request.user.is_system_account_user(): @@ -55,7 +57,7 @@ class DebugScopedRateThrottle(ScopedRateThrottle): # If checking fails, continue with normal throttling pass - if debug_bypass or env_bypass or system_account_bypass or public_blueprint_bypass: + if debug_bypass or env_bypass or system_account_bypass or public_blueprint_bypass or authenticated_bypass: # In debug mode or for system accounts, still set throttle headers but don't actually throttle # This allows testing throttle headers without blocking requests if hasattr(self, 'get_rate'): diff --git a/backend/igny8_core/modules/system/integration_views.py b/backend/igny8_core/modules/system/integration_views.py index c6adb5ce..e900f2de 100644 --- a/backend/igny8_core/modules/system/integration_views.py +++ b/backend/igny8_core/modules/system/integration_views.py @@ -10,7 +10,7 @@ from drf_spectacular.utils import extend_schema, extend_schema_view from igny8_core.api.base import AccountModelViewSet from igny8_core.api.response import success_response, error_response from igny8_core.api.throttles import DebugScopedRateThrottle -from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner +from igny8_core.api.permissions import IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper from django.conf import settings logger = logging.getLogger(__name__) @@ -30,7 +30,7 @@ class IntegrationSettingsViewSet(viewsets.ViewSet): Following reference plugin pattern: WordPress uses update_option() for igny8_api_settings We store in IntegrationSettings model with account isolation """ - permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] + permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsSystemAccountOrDeveloper] throttle_scope = 'system_admin' throttle_classes = [DebugScopedRateThrottle] diff --git a/backend/igny8_core/settings.py b/backend/igny8_core/settings.py index d2bcff39..22ecaf77 100644 --- a/backend/igny8_core/settings.py +++ b/backend/igny8_core/settings.py @@ -214,7 +214,8 @@ REST_FRAMEWORK = { 'rest_framework.filters.OrderingFilter', ], 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.AllowAny', # Allow unauthenticated access for now + 'igny8_core.api.permissions.IsAuthenticatedAndActive', + 'igny8_core.api.permissions.HasTenantAccess', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ 'igny8_core.api.authentication.APIKeyAuthentication', # WordPress API key authentication (check first) @@ -232,33 +233,33 @@ REST_FRAMEWORK = { 'igny8_core.api.throttles.DebugScopedRateThrottle', ], 'DEFAULT_THROTTLE_RATES': { - # AI Functions - Expensive operations - 'ai_function': '10/min', # AI content generation, clustering - 'image_gen': '15/min', # Image generation + # AI Functions - Expensive operations (kept modest but higher to reduce false 429s) + 'ai_function': '60/min', + 'image_gen': '90/min', # Content Operations - 'content_write': '30/min', # Content creation, updates - 'content_read': '100/min', # Content listing, retrieval + 'content_write': '180/min', + 'content_read': '600/min', # Authentication - 'auth': '20/min', # Login, register, password reset - 'auth_strict': '5/min', # Sensitive auth operations - 'auth_read': '120/min', # Read-only auth-adjacent endpoints (e.g., subscriptions) + 'auth': '300/min', # Login, register, password reset + 'auth_strict': '120/min', # Sensitive auth operations + 'auth_read': '600/min', # Read-only auth-adjacent endpoints (e.g., subscriptions, industries) # Planner Operations - 'planner': '60/min', # Keyword, cluster, idea operations - 'planner_ai': '10/min', # AI-powered planner operations + 'planner': '300/min', + 'planner_ai': '60/min', # Writer Operations - 'writer': '60/min', # Task, content management - 'writer_ai': '10/min', # AI-powered writer operations + 'writer': '300/min', + 'writer_ai': '60/min', # System Operations - 'system': '100/min', # Settings, prompts, profiles - 'system_admin': '30/min', # Admin-only system operations + 'system': '600/min', + 'system_admin': '120/min', # Billing Operations - 'billing': '30/min', # Credit queries, usage logs - 'billing_admin': '10/min', # Credit management (admin) - 'linker': '30/min', # Content linking operations - 'optimizer': '10/min', # AI-powered optimization - 'integration': '100/min', # Integration operations (WordPress, etc.) + 'billing': '180/min', + 'billing_admin': '60/min', + 'linker': '180/min', + 'optimizer': '60/min', + 'integration': '600/min', # Default fallback - 'default': '100/min', # Default for endpoints without scope + 'default': '600/min', }, # OpenAPI Schema Generation (drf-spectacular) 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5efdac44..222d4e7c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import AppLayout from "./layout/AppLayout"; import { ScrollToTop } from "./components/common/ScrollToTop"; import ProtectedRoute from "./components/auth/ProtectedRoute"; import ModuleGuard from "./components/common/ModuleGuard"; +import AdminGuard from "./components/auth/AdminGuard"; import GlobalErrorDisplay from "./components/common/GlobalErrorDisplay"; import LoadingStateMonitor from "./components/common/LoadingStateMonitor"; import { useAuthStore } from "./store/authStore"; @@ -595,8 +596,10 @@ export default function App() { } /> + - + + } /> diff --git a/frontend/src/components/auth/AdminGuard.tsx b/frontend/src/components/auth/AdminGuard.tsx new file mode 100644 index 00000000..88b407cf --- /dev/null +++ b/frontend/src/components/auth/AdminGuard.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useAuthStore } from "../../store/authStore"; + +interface AdminGuardProps { + children: ReactNode; +} + +/** + * AdminGuard - restricts access to system account (aws-admin/default) or developer + */ +export default function AdminGuard({ children }: AdminGuardProps) { + const { user } = useAuthStore(); + const role = user?.role; + const accountSlug = user?.account?.slug; + const isSystemAccount = accountSlug === 'aws-admin' || accountSlug === 'default-account' || accountSlug === 'default'; + const allowed = role === 'developer' || isSystemAccount; + + if (!allowed) { + return ; + } + + return <>{children}; +} + diff --git a/frontend/src/components/auth/SignUpForm.tsx b/frontend/src/components/auth/SignUpForm.tsx index ac1aa529..e00b8973 100644 --- a/frontend/src/components/auth/SignUpForm.tsx +++ b/frontend/src/components/auth/SignUpForm.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { ChevronLeftIcon, EyeCloseIcon, EyeIcon } from "../../icons"; import Label from "../form/Label"; @@ -6,6 +6,15 @@ import Input from "../form/input/InputField"; import Checkbox from "../form/input/Checkbox"; import { useAuthStore } from "../../store/authStore"; +type Plan = { + id: number; + name: string; + price?: number; + billing_cycle?: string; + is_active?: boolean; + included_credits?: number; +}; + export default function SignUpForm() { const [showPassword, setShowPassword] = useState(false); const [isChecked, setIsChecked] = useState(false); @@ -15,11 +24,45 @@ export default function SignUpForm() { email: "", password: "", username: "", + accountName: "", }); + const [plans, setPlans] = useState([]); + const [selectedPlanId, setSelectedPlanId] = useState(null); + const [plansLoading, setPlansLoading] = useState(true); const [error, setError] = useState(""); const navigate = useNavigate(); const { register, loading } = useAuthStore(); + const apiBaseUrl = useMemo( + () => import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api", + [] + ); + + useEffect(() => { + const loadPlans = async () => { + setPlansLoading(true); + try { + const res = await fetch(`${apiBaseUrl}/v1/auth/plans/`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json(); + const list: Plan[] = data?.results || data || []; + const activePlans = list.filter((p) => p.is_active !== false); + setPlans(activePlans); + if (activePlans.length > 0) { + setSelectedPlanId(activePlans[0].id); + } + } catch (e) { + // keep empty list; surface error on submit if no plan + console.error("Failed to load plans", e); + } finally { + setPlansLoading(false); + } + }; + loadPlans(); + }, [apiBaseUrl]); + const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData((prev) => ({ ...prev, [name]: value })); @@ -39,6 +82,11 @@ export default function SignUpForm() { return; } + if (!selectedPlanId) { + setError("Please select a plan to continue"); + return; + } + try { // Generate username from email if not provided const username = formData.username || formData.email.split("@")[0]; @@ -49,6 +97,8 @@ export default function SignUpForm() { username: username, first_name: formData.firstName, last_name: formData.lastName, + account_name: formData.accountName, + plan_id: selectedPlanId, }); // Redirect to plan selection after successful registration @@ -191,6 +241,43 @@ export default function SignUpForm() { required /> + {/* */} +
+ + +
+ + {/* */} +
+ + +
+ {/* */}