9 Commits

Author SHA1 Message Date
IGNY8 VPS (Salman)
56c30e4904 schedules page removed 2025-11-16 21:21:07 +00:00
IGNY8 VPS (Salman)
51cd021f85 fixed all phase 0 issues Enhance error handling for ModuleEnableSettings retrieval
- Added a check for the existence of the ModuleEnableSettings table before attempting to retrieve or fixed all phase 0 create settings for an account.
- Implemented logging and a user-friendly error response if the table does not exist, prompting the user to run the necessary migration.
- Updated migration to create the ModuleEnableSettings table using raw SQL to avoid model resolution issues.
2025-11-16 21:16:35 +00:00
IGNY8 VPS (Salman)
fc6dd5623a Add refresh token functionality and improve login response handling
- Introduced RefreshTokenView to allow users to refresh their access tokens using a valid refresh token.
- Enhanced LoginView to ensure correct user/account loading and improved error handling during user serialization.
- Updated API response structure to include access and refresh token expiration times.
- Adjusted frontend API handling to support both new and legacy token response formats.
2025-11-16 21:06:22 +00:00
Desktop
1531f41226 Revert "Fix authentication: Ensure correct user/account is loaded"
This reverts commit a267fc0715.
2025-11-17 01:35:34 +05:00
Desktop
37a64fa1ef Revert "Fix authentication: Use token's account_id as authoritative source"
This reverts commit 46b5b5f1b2.
2025-11-17 01:35:30 +05:00
Desktop
c4daeb1870 Revert "Fix authentication: Follow unified API model - token account_id is authoritative"
This reverts commit 8171014a7e.
2025-11-17 01:35:26 +05:00
Desktop
79aab68acd Revert "Fix credit system: Add developer/system account bypass for credit checks"
This reverts commit 066b81dd2a.
2025-11-17 01:35:23 +05:00
Desktop
11a5a66c8b Revert "Revert to main branch account handling logic"
This reverts commit 219dae83c6.
2025-11-17 01:35:19 +05:00
Desktop
ab292de06c Revert "branch 1st"
This reverts commit 8a9dd44c50.
2025-11-17 01:35:13 +05:00
20 changed files with 252 additions and 163 deletions

View File

@@ -193,7 +193,6 @@ class AIEngine:
self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta()) self.tracker.update("PREP", 25, prep_message, meta=self.step_tracker.get_meta())
# Phase 2.5: CREDIT CHECK - Check credits before AI call (25%) # Phase 2.5: CREDIT CHECK - Check credits before AI call (25%)
# Bypass for system accounts and developers (handled in CreditService)
if self.account: if self.account:
try: try:
from igny8_core.modules.billing.services import CreditService from igny8_core.modules.billing.services import CreditService
@@ -205,9 +204,8 @@ class AIEngine:
# Calculate estimated cost # Calculate estimated cost
estimated_amount = self._get_estimated_amount(function_name, data, payload) estimated_amount = self._get_estimated_amount(function_name, data, payload)
# Check credits BEFORE AI call (CreditService handles developer/system account bypass) # Check credits BEFORE AI call
# Note: user=None for Celery tasks, but CreditService checks account.is_system_account() and developer users CreditService.check_credits(self.account, operation_type, estimated_amount)
CreditService.check_credits(self.account, operation_type, estimated_amount, user=None)
logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}") logger.info(f"[AIEngine] Credit check passed: {operation_type}, estimated amount: {estimated_amount}")
except InsufficientCreditsError as e: except InsufficientCreditsError as e:

View File

@@ -14,8 +14,10 @@ from .views import (
SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet, SiteUserAccessViewSet, PlanViewSet, SiteViewSet, SectorViewSet,
IndustryViewSet, SeedKeywordViewSet IndustryViewSet, SeedKeywordViewSet
) )
from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer from .serializers import RegisterSerializer, LoginSerializer, ChangePasswordSerializer, UserSerializer, RefreshTokenSerializer
from .models import User from .models import User
from .utils import generate_access_token, get_token_expiry, decode_token
import jwt
router = DefaultRouter() router = DefaultRouter()
# Main structure: Groups, Users, Accounts, Subscriptions, Site User Access # Main structure: Groups, Users, Accounts, Subscriptions, Site User Access
@@ -78,7 +80,7 @@ class LoginView(APIView):
password = serializer.validated_data['password'] password = serializer.validated_data['password']
try: try:
user = User.objects.get(email=email) user = User.objects.select_related('account', 'account__plan').get(email=email)
except User.DoesNotExist: except User.DoesNotExist:
return error_response( return error_response(
error='Invalid credentials', error='Invalid credentials',
@@ -107,9 +109,17 @@ class LoginView(APIView):
user_data = user_serializer.data user_data = user_serializer.data
except Exception as e: except Exception as e:
# Fallback if serializer fails (e.g., missing account_id column) # Fallback if serializer fails (e.g., missing account_id column)
# Log the error for debugging but don't fail the login
import logging
logger = logging.getLogger(__name__)
logger.warning(f"UserSerializer failed for user {user.id}: {e}", exc_info=True)
# Ensure username is properly set (use email prefix if username is empty/default)
username = user.username if user.username and user.username != 'user' else user.email.split('@')[0]
user_data = { user_data = {
'id': user.id, 'id': user.id,
'username': user.username, 'username': username,
'email': user.email, 'email': user.email,
'role': user.role, 'role': user.role,
'account': None, 'account': None,
@@ -119,12 +129,10 @@ class LoginView(APIView):
return success_response( return success_response(
data={ data={
'user': user_data, 'user': user_data,
'tokens': { 'access': access_token,
'access': access_token, 'refresh': refresh_token,
'refresh': refresh_token, 'access_expires_at': access_expires_at.isoformat(),
'access_expires_at': access_expires_at.isoformat(), 'refresh_expires_at': refresh_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
}
}, },
message='Login successful', message='Login successful',
request=request request=request
@@ -180,6 +188,84 @@ class ChangePasswordView(APIView):
) )
@extend_schema(
tags=['Authentication'],
summary='Refresh Token',
description='Refresh access token using refresh token'
)
class RefreshTokenView(APIView):
"""Refresh access token endpoint."""
permission_classes = [permissions.AllowAny]
def post(self, request):
serializer = RefreshTokenSerializer(data=request.data)
if not serializer.is_valid():
return error_response(
error='Validation failed',
errors=serializer.errors,
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
refresh_token = serializer.validated_data['refresh']
try:
# Decode and validate refresh token
payload = decode_token(refresh_token)
# Verify it's a refresh token
if payload.get('type') != 'refresh':
return error_response(
error='Invalid token type',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Get user
user_id = payload.get('user_id')
account_id = payload.get('account_id')
try:
user = User.objects.select_related('account', 'account__plan').get(id=user_id)
except User.DoesNotExist:
return error_response(
error='User not found',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
# Get account
account = None
if account_id:
try:
from .models import Account
account = Account.objects.get(id=account_id)
except Exception:
pass
if not account:
account = getattr(user, 'account', None)
# Generate new access token
access_token = generate_access_token(user, account)
access_expires_at = get_token_expiry('access')
return success_response(
data={
'access': access_token,
'access_expires_at': access_expires_at.isoformat()
},
request=request
)
except jwt.InvalidTokenError:
return error_response(
error='Invalid or expired refresh token',
status_code=status.HTTP_401_UNAUTHORIZED,
request=request
)
@extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint @extend_schema(exclude=True) # Exclude from public API documentation - internal authenticated endpoint
class MeView(APIView): class MeView(APIView):
"""Get current user information.""" """Get current user information."""
@@ -201,6 +287,7 @@ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'), path('register/', csrf_exempt(RegisterView.as_view()), name='auth-register'),
path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'), path('login/', csrf_exempt(LoginView.as_view()), name='auth-login'),
path('refresh/', csrf_exempt(RefreshTokenView.as_view()), name='auth-refresh'),
path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'), path('change-password/', ChangePasswordView.as_view(), name='auth-change-password'),
path('me/', MeView.as_view(), name='auth-me'), path('me/', MeView.as_view(), name='auth-me'),
] ]

View File

@@ -933,12 +933,10 @@ class AuthViewSet(viewsets.GenericViewSet):
return success_response( return success_response(
data={ data={
'user': user_serializer.data, 'user': user_serializer.data,
'tokens': { 'access': access_token,
'access': access_token, 'refresh': refresh_token,
'refresh': refresh_token, 'access_expires_at': access_expires_at.isoformat(),
'access_expires_at': access_expires_at.isoformat(), 'refresh_expires_at': refresh_expires_at.isoformat(),
'refresh_expires_at': refresh_expires_at.isoformat(),
}
}, },
message='Login successful', message='Login successful',
request=request request=request

View File

@@ -49,7 +49,7 @@ class CreditService:
return base_cost return base_cost
@staticmethod @staticmethod
def check_credits(account, operation_type, amount=None, user=None): def check_credits(account, operation_type, amount=None):
""" """
Check if account has sufficient credits for an operation. Check if account has sufficient credits for an operation.
@@ -57,35 +57,10 @@ class CreditService:
account: Account instance account: Account instance
operation_type: Type of operation operation_type: Type of operation
amount: Optional amount (word count, image count, etc.) amount: Optional amount (word count, image count, etc.)
user: Optional user instance (for developer/admin bypass)
Raises: Raises:
InsufficientCreditsError: If account doesn't have enough credits InsufficientCreditsError: If account doesn't have enough credits
""" """
# Bypass credit check for:
# 1. System accounts (aws-admin, default-account, default)
# 2. Developer/admin users (if user provided)
if account and account.is_system_account():
return True
if user:
try:
if hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer():
return True
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
return True
except (AttributeError, Exception):
pass
# Check if account has any developer users (fallback for Celery tasks without user context)
if account:
try:
from igny8_core.auth.models import User
if User.objects.filter(account=account, role='developer').exists():
return True
except (AttributeError, Exception):
pass
required = CreditService.get_credit_cost(operation_type, amount) required = CreditService.get_credit_cost(operation_type, amount)
if account.credits < required: if account.credits < required:
raise InsufficientCreditsError( raise InsufficientCreditsError(
@@ -94,40 +69,17 @@ class CreditService:
return True return True
@staticmethod @staticmethod
def check_credits_legacy(account, required_credits, user=None): def check_credits_legacy(account, required_credits):
""" """
Legacy method: Check if account has enough credits (for backward compatibility). Legacy method: Check if account has enough credits (for backward compatibility).
Args: Args:
account: Account instance account: Account instance
required_credits: Number of credits required required_credits: Number of credits required
user: Optional user instance (for developer/admin bypass)
Raises: Raises:
InsufficientCreditsError: If account doesn't have enough credits InsufficientCreditsError: If account doesn't have enough credits
""" """
# Bypass credit check for system accounts and developers
if account and account.is_system_account():
return
if user:
try:
if hasattr(user, 'is_admin_or_developer') and user.is_admin_or_developer():
return
if hasattr(user, 'is_system_account_user') and user.is_system_account_user():
return
except (AttributeError, Exception):
pass
# Check if account has any developer users (fallback for Celery tasks)
if account:
try:
from igny8_core.auth.models import User
if User.objects.filter(account=account, role='developer').exists():
return
except (AttributeError, Exception):
pass
if account.credits < required_credits: if account.credits < required_credits:
raise InsufficientCreditsError( raise InsufficientCreditsError(
f"Insufficient credits. Required: {required_credits}, Available: {account.credits}" f"Insufficient credits. Required: {required_credits}, Available: {account.credits}"

View File

@@ -54,8 +54,8 @@ class CreditBalanceViewSet(viewsets.ViewSet):
request=request request=request
) )
# Get plan credits per month # Get plan credits per month (use get_effective_credits_per_month for Phase 0 compatibility)
plan_credits_per_month = account.plan.credits_per_month if account.plan else 0 plan_credits_per_month = account.plan.get_effective_credits_per_month() if account.plan else 0
# Calculate credits used this month # Calculate credits used this month
now = timezone.now() now = timezone.now()

View File

@@ -1,37 +1,39 @@
# Generated manually for Phase 0: Module Enable Settings # Generated manually for Phase 0: Module Enable Settings
# Using RunSQL to create table directly to avoid model resolution issues with new unified API model
from django.db import migrations, models from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('igny8_core_modules_system', '0006_alter_systemstatus_unique_together_and_more'), ('system', '0006_alter_systemstatus_unique_together_and_more'),
('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'), ('igny8_core_auth', '0008_passwordresettoken_alter_industry_options_and_more'),
] ]
operations = [ operations = [
migrations.CreateModel( # Create table using raw SQL to avoid model resolution issues
name='ModuleEnableSettings', # The model state is automatically discovered from models.py
fields=[ migrations.RunSQL(
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), sql="""
('planner_enabled', models.BooleanField(default=True, help_text='Enable Planner module')), CREATE TABLE IF NOT EXISTS igny8_module_enable_settings (
('writer_enabled', models.BooleanField(default=True, help_text='Enable Writer module')), id BIGSERIAL PRIMARY KEY,
('thinker_enabled', models.BooleanField(default=True, help_text='Enable Thinker module')), planner_enabled BOOLEAN NOT NULL DEFAULT TRUE,
('automation_enabled', models.BooleanField(default=True, help_text='Enable Automation module')), writer_enabled BOOLEAN NOT NULL DEFAULT TRUE,
('site_builder_enabled', models.BooleanField(default=True, help_text='Enable Site Builder module')), thinker_enabled BOOLEAN NOT NULL DEFAULT TRUE,
('linker_enabled', models.BooleanField(default=True, help_text='Enable Linker module')), automation_enabled BOOLEAN NOT NULL DEFAULT TRUE,
('optimizer_enabled', models.BooleanField(default=True, help_text='Enable Optimizer module')), site_builder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
('publisher_enabled', models.BooleanField(default=True, help_text='Enable Publisher module')), linker_enabled BOOLEAN NOT NULL DEFAULT TRUE,
('account', models.ForeignKey(on_delete=models.CASCADE, to='igny8_core_auth.account', db_column='tenant_id')), optimizer_enabled BOOLEAN NOT NULL DEFAULT TRUE,
], publisher_enabled BOOLEAN NOT NULL DEFAULT TRUE,
options={ tenant_id BIGINT NOT NULL REFERENCES igny8_tenants(id) ON DELETE CASCADE,
'db_table': 'igny8_module_enable_settings', created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
}, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
), );
migrations.AddConstraint( CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_tenant_id_idx ON igny8_module_enable_settings(tenant_id);
model_name='moduleenablesettings', CREATE INDEX IF NOT EXISTS igny8_module_enable_settings_account_created_idx ON igny8_module_enable_settings(tenant_id, created_at);
constraint=models.UniqueConstraint(fields=('account',), name='unique_account_module_enable_settings'), CREATE UNIQUE INDEX IF NOT EXISTS unique_account_module_enable_settings ON igny8_module_enable_settings(tenant_id);
""",
reverse_sql="DROP TABLE IF EXISTS igny8_module_enable_settings CASCADE;",
), ),
] ]

View File

@@ -235,6 +235,15 @@ class ModuleSettingsViewSet(AccountModelViewSet):
def retrieve(self, request, pk=None): def retrieve(self, request, pk=None):
"""Get setting by key (pk can be key string)""" """Get setting by key (pk can be key string)"""
# Special case: if pk is "enable", this is likely a routing conflict
# The correct endpoint is /settings/modules/enable/ which should go to ModuleEnableSettingsViewSet
if pk == 'enable':
return error_response(
error='Use /api/v1/system/settings/modules/enable/ endpoint for module enable settings',
status_code=status.HTTP_404_NOT_FOUND,
request=request
)
queryset = self.get_queryset() queryset = self.get_queryset()
try: try:
# Try to get by ID first # Try to get by ID first
@@ -301,7 +310,7 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet):
Allow read access to all authenticated users, Allow read access to all authenticated users,
but restrict write access to admins/owners but restrict write access to admins/owners
""" """
if self.action in ['list', 'retrieve']: if self.action in ['list', 'retrieve', 'get_current']:
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess] permission_classes = [IsAuthenticatedAndActive, HasTenantAccess]
else: else:
permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner] permission_classes = [IsAuthenticatedAndActive, HasTenantAccess, IsAdminOrOwner]
@@ -309,30 +318,76 @@ class ModuleEnableSettingsViewSet(AccountModelViewSet):
def get_queryset(self): def get_queryset(self):
"""Get module enable settings for current account""" """Get module enable settings for current account"""
# Don't filter here - list() and retrieve() handle get_or_create # Return queryset filtered by account - but list() will handle get_or_create
# This prevents empty queryset from causing 404 errors queryset = super().get_queryset()
return ModuleEnableSettings.objects.all() # Filter by account if available
account = getattr(self.request, 'account', None)
if not account:
user = getattr(self.request, 'user', None)
if user:
account = getattr(user, 'account', None)
if account:
queryset = queryset.filter(account=account)
return queryset
@action(detail=False, methods=['get', 'put'], url_path='current', url_name='current')
def get_current(self, request):
"""Get or update current account's module enable settings"""
if request.method == 'GET':
return self.list(request)
else:
return self.update(request, pk=None)
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
"""Get or create module enable settings for current account""" """Get or create module enable settings for current account"""
account = getattr(request, 'account', None) try:
if not account: account = getattr(request, 'account', None)
user = getattr(request, 'user', None) if not account:
if user and hasattr(user, 'account'): user = getattr(request, 'user', None)
account = getattr(user, 'account', None) if user and hasattr(user, 'account'):
account = user.account
if not account: if not account:
return error_response(
error='Account not found',
status_code=status.HTTP_400_BAD_REQUEST,
request=request
)
# Check if table exists (migration might not have been run)
try:
# Get or create settings for account (one per account)
try:
settings = ModuleEnableSettings.objects.get(account=account)
except ModuleEnableSettings.DoesNotExist:
# Create default settings for account
settings = ModuleEnableSettings.objects.create(account=account)
serializer = self.get_serializer(settings)
return success_response(data=serializer.data, request=request)
except Exception as db_error:
# Check if it's a "table does not exist" error
error_str = str(db_error)
if 'does not exist' in error_str.lower() or 'relation' in error_str.lower():
import logging
logger = logging.getLogger(__name__)
logger.error(f"ModuleEnableSettings table does not exist. Migration 0007_add_module_enable_settings needs to be run: {error_str}")
return error_response(
error='Module enable settings table not found. Please run migration: python manage.py migrate igny8_core_modules_system 0007',
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
request=request
)
# Re-raise other database errors
raise
except Exception as e:
import traceback
error_trace = traceback.format_exc()
return error_response( return error_response(
error='Account not found', error=f'Failed to load module enable settings: {str(e)}',
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
request=request request=request
) )
# Get or create settings for account (one per account)
settings, created = ModuleEnableSettings.objects.get_or_create(account=account)
serializer = self.get_serializer(settings)
return success_response(data=serializer.data, request=request)
def retrieve(self, request, pk=None, *args, **kwargs): def retrieve(self, request, pk=None, *args, **kwargs):
"""Get module enable settings for current account""" """Get module enable settings for current account"""
try: try:

View File

@@ -16,8 +16,8 @@ router.register(r'strategies', StrategyViewSet, basename='strategy')
router.register(r'settings/system', SystemSettingsViewSet, basename='system-settings') router.register(r'settings/system', SystemSettingsViewSet, basename='system-settings')
router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings') router.register(r'settings/account', AccountSettingsViewSet, basename='account-settings')
router.register(r'settings/user', UserSettingsViewSet, basename='user-settings') router.register(r'settings/user', UserSettingsViewSet, basename='user-settings')
# Register ModuleSettingsViewSet first
router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings') router.register(r'settings/modules', ModuleSettingsViewSet, basename='module-settings')
router.register(r'settings/modules/enable', ModuleEnableSettingsViewSet, basename='module-enable-settings')
router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings') router.register(r'settings/ai', AISettingsViewSet, basename='ai-settings')
# Custom URL patterns for integration settings - matching reference plugin structure # Custom URL patterns for integration settings - matching reference plugin structure
@@ -50,7 +50,20 @@ integration_image_gen_settings_viewset = IntegrationSettingsViewSet.as_view({
'get': 'get_image_generation_settings', 'get': 'get_image_generation_settings',
}) })
# Custom view for module enable settings to avoid URL routing conflict with ModuleSettingsViewSet
# This must be defined as a custom path BEFORE router.urls to ensure it matches first
# The update method handles pk=None correctly, so we can use as_view
module_enable_viewset = ModuleEnableSettingsViewSet.as_view({
'get': 'list',
'put': 'update',
'patch': 'partial_update',
})
urlpatterns = [ urlpatterns = [
# Module enable settings endpoint - MUST come before router.urls to avoid conflict
# When /settings/modules/enable/ is called, it would match ModuleSettingsViewSet with pk='enable'
# So we define it as a custom path first
path('settings/modules/enable/', module_enable_viewset, name='module-enable-settings'),
path('', include(router.urls)), path('', include(router.urls)),
# Public health check endpoint (API Standard v1.0 requirement) # Public health check endpoint (API Standard v1.0 requirement)
path('ping/', ping, name='system-ping'), path('ping/', ping, name='system-ping'),

View File

@@ -411,9 +411,9 @@ frontend/
<Route path="/reference/seed-keywords" element={<SeedKeywords />} /> <Route path="/reference/seed-keywords" element={<SeedKeywords />} />
<Route path="/reference/industries" element={<ReferenceIndustries />} /> <Route path="/reference/industries" element={<ReferenceIndustries />} />
{/* Automation & Schedules */} {/* Automation */}
<Route path="/automation" element={<AutomationDashboard />} /> <Route path="/automation" element={<AutomationDashboard />} />
<Route path="/schedules" element={<Schedules />} /> {/* Note: Schedules functionality is integrated into Automation Dashboard */}
{/* Settings */} {/* Settings */}
<Route path="/settings" element={<GeneralSettings />} /> <Route path="/settings" element={<GeneralSettings />} />

View File

@@ -644,9 +644,12 @@ class KeywordViewSet(SiteSectorModelViewSet):
"data": { "data": {
"user": { ... }, "user": { ... },
"access": "eyJ0eXAiOiJKV1QiLCJhbGc...", "access": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..." "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"access_expires_at": "2025-01-XXT...",
"refresh_expires_at": "2025-01-XXT..."
}, },
"message": "Login successful" "message": "Login successful",
"request_id": "550e8400-e29b-41d4-a716-446655440000"
} }
``` ```

View File

@@ -278,11 +278,10 @@ frontend/src/
│ ├── Billing/ # Existing │ ├── Billing/ # Existing
│ ├── Settings/ # Existing │ ├── Settings/ # Existing
│ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT │ ├── Automation/ # EXISTING (placeholder) - IMPLEMENT
│ │ ├── Dashboard.tsx # Automation overview │ │ ├── Dashboard.tsx # Automation overview (includes schedules functionality)
│ │ ├── Rules.tsx # Automation rules management │ │ ├── Rules.tsx # Automation rules management
│ │ ├── Workflows.tsx # Workflow templates │ │ ├── Workflows.tsx # Workflow templates
│ │ └── History.tsx # Automation execution history │ │ └── History.tsx # Automation execution history
│ ├── Schedules.tsx # EXISTING (placeholder) - IMPLEMENT
│ ├── Linker/ # NEW │ ├── Linker/ # NEW
│ │ ├── Dashboard.tsx │ │ ├── Dashboard.tsx
│ │ ├── Candidates.tsx │ │ ├── Candidates.tsx
@@ -653,7 +652,7 @@ docker-data/
| **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH | | **Implement Automation Service** | `domain/automation/services/` | TODO | HIGH |
| **Implement Automation API** | `modules/automation/` | TODO | HIGH | | **Implement Automation API** | `modules/automation/` | TODO | HIGH |
| **Implement Automation UI** | `frontend/src/pages/Automation/` | TODO | HIGH | | **Implement Automation UI** | `frontend/src/pages/Automation/` | TODO | HIGH |
| **Implement Schedules UI** | `frontend/src/pages/Schedules.tsx` | TODO | HIGH | | **Note**: Schedules functionality will be integrated into Automation UI, not as a separate page | - | - | - |
### 9.2 Phase 1: Site Builder ### 9.2 Phase 1: Site Builder

View File

@@ -234,7 +234,7 @@ CREDIT_COSTS = {
|------|-------|--------------| |------|-------|--------------|
| **Automation Dashboard** | `frontend/src/pages/Automation/Dashboard.tsx` | EXISTING (placeholder) | | **Automation Dashboard** | `frontend/src/pages/Automation/Dashboard.tsx` | EXISTING (placeholder) |
| **Rules Management** | `frontend/src/pages/Automation/Rules.tsx` | NEW | | **Rules Management** | `frontend/src/pages/Automation/Rules.tsx` | NEW |
| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) | | **Schedules (within Automation)** | Integrated into Automation Dashboard | Part of automation menu |
| **Automation API Client** | `frontend/src/services/automation.api.ts` | NEW | | **Automation API Client** | `frontend/src/services/automation.api.ts` | NEW |
### 2.6 Testing ### 2.6 Testing

View File

@@ -462,13 +462,11 @@ urlpatterns = router.urls
- Test rule - Test rule
- Manual execution - Manual execution
#### Schedules Page #### Schedules (Part of Automation Menu)
| Task | File | Dependencies | Implementation | **Note**: Schedules functionality will be integrated into the Automation menu group, not as a separate page.
|------|------|--------------|----------------|
| **Schedules Page** | `frontend/src/pages/Schedules.tsx` | EXISTING (placeholder) | View scheduled task history |
**Schedules Page Features**: **Schedules Features** (within Automation Dashboard):
- List scheduled tasks - List scheduled tasks
- Filter by status, rule, date - Filter by status, rule, date
- View execution results - View execution results
@@ -553,11 +551,11 @@ export const automationApi = {
- [ ] Implement `frontend/src/pages/Automation/Dashboard.tsx` - [ ] Implement `frontend/src/pages/Automation/Dashboard.tsx`
- [ ] Create `frontend/src/pages/Automation/Rules.tsx` - [ ] Create `frontend/src/pages/Automation/Rules.tsx`
- [ ] Implement `frontend/src/pages/Schedules.tsx` - [ ] Integrate schedules functionality into Automation Dashboard (not as separate page)
- [ ] Create `frontend/src/services/automation.api.ts` - [ ] Create `frontend/src/services/automation.api.ts`
- [ ] Create rule creation wizard - [ ] Create rule creation wizard
- [ ] Create rule editor - [ ] Create rule editor
- [ ] Create schedule history table - [ ] Create schedule history table (within Automation Dashboard)
### Testing Tasks ### Testing Tasks

View File

@@ -50,7 +50,6 @@ const SeedKeywords = lazy(() => import("./pages/Reference/SeedKeywords"));
const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries")); const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
// Other Pages - Lazy loaded // Other Pages - Lazy loaded
const Schedules = lazy(() => import("./pages/Schedules"));
const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard")); const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard"));
// Settings - Lazy loaded // Settings - Lazy loaded
@@ -294,11 +293,6 @@ export default function App() {
</ModuleGuard> </ModuleGuard>
</Suspense> </Suspense>
} /> } />
<Route path="/schedules" element={
<Suspense fallback={null}>
<Schedules />
</Suspense>
} />
{/* Settings */} {/* Settings */}
<Route path="/settings" element={ <Route path="/settings" element={

View File

@@ -21,7 +21,6 @@ import { useAuthStore } from "../../store/authStore";
* - /settings (including /settings/sites) * - /settings (including /settings/sites)
* - /dashboard * - /dashboard
* - /analytics * - /analytics
* - /schedules
* - /thinker * - /thinker
* - /signin, /signup * - /signin, /signup
*/ */
@@ -37,7 +36,6 @@ const SITE_SWITCHER_HIDDEN_PATHS = [
'/settings', '/settings',
'/dashboard', '/dashboard',
'/analytics', '/analytics',
'/schedules',
'/thinker', '/thinker',
]; ];

View File

@@ -51,11 +51,6 @@ export const routes: RouteConfig[] = [
{ path: '/thinker/profile', label: 'Profile', breadcrumb: 'Profile' }, { path: '/thinker/profile', label: 'Profile', breadcrumb: 'Profile' },
], ],
}, },
{
path: '/schedules',
label: 'Schedules',
icon: 'Schedules',
},
]; ];
export const getBreadcrumbs = (pathname: string): Array<{ label: string; path: string }> => { export const getBreadcrumbs = (pathname: string): Array<{ label: string; path: string }> => {

View File

@@ -11,7 +11,6 @@ import {
PlugInIcon, PlugInIcon,
TaskIcon, TaskIcon,
BoltIcon, BoltIcon,
TimeIcon,
DocsIcon, DocsIcon,
PageIcon, PageIcon,
DollarLineIcon, DollarLineIcon,
@@ -144,12 +143,6 @@ const AppSidebar: React.FC = () => {
}); });
} }
workflowItems.push({
icon: <TimeIcon />,
name: "Schedules",
path: "/schedules",
});
return [ return [
{ {
label: "OVERVIEW", label: "OVERVIEW",

View File

@@ -76,7 +76,7 @@ export default function Help() {
}, },
{ {
question: "How do I set up automation?", question: "How do I set up automation?",
answer: "Go to Dashboard &gt; Automation Setup section. Enable automation for each step (Keywords, Ideas, Content, Images) and configure settings like how many keywords to process per cycle. Advanced settings are available in Schedules page." answer: "Go to Dashboard &gt; Automation Setup section. Enable automation for each step (Keywords, Ideas, Content, Images) and configure settings like how many keywords to process per cycle. Advanced scheduling settings are available in the Automation menu."
}, },
{ {
question: "Can I edit AI-generated content?", question: "Can I edit AI-generated content?",
@@ -539,7 +539,7 @@ export default function Help() {
<div className="mt-6 p-4 bg-brand-50 dark:bg-brand-900/10 rounded-lg border border-brand-200 dark:border-brand-800"> <div className="mt-6 p-4 bg-brand-50 dark:bg-brand-900/10 rounded-lg border border-brand-200 dark:border-brand-800">
<p className="text-sm text-brand-800 dark:text-brand-300"> <p className="text-sm text-brand-800 dark:text-brand-300">
<strong>Note:</strong> Configure automation in Dashboard &gt; Automation Setup. For advanced scheduling, go to Schedules page. <strong>Note:</strong> Configure automation in Dashboard &gt; Automation Setup. For advanced scheduling, go to the Automation menu.
</p> </p>
</div> </div>
</Card> </Card>

View File

@@ -194,13 +194,14 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo
if (refreshResponse.ok) { if (refreshResponse.ok) {
const refreshData = await refreshResponse.json(); const refreshData = await refreshResponse.json();
if (refreshData.success && refreshData.access) { const accessToken = refreshData.data?.access || refreshData.access;
if (refreshData.success && accessToken) {
// Update token in store // Update token in store
try { try {
const authStorage = localStorage.getItem('auth-storage'); const authStorage = localStorage.getItem('auth-storage');
if (authStorage) { if (authStorage) {
const parsed = JSON.parse(authStorage); const parsed = JSON.parse(authStorage);
parsed.state.token = refreshData.access; parsed.state.token = accessToken;
localStorage.setItem('auth-storage', JSON.stringify(parsed)); localStorage.setItem('auth-storage', JSON.stringify(parsed));
} }
} catch (e) { } catch (e) {
@@ -210,7 +211,7 @@ export async function fetchAPI(endpoint: string, options?: RequestInit & { timeo
// Retry original request with new token // Retry original request with new token
const newHeaders = { const newHeaders = {
...headers, ...headers,
'Authorization': `Bearer ${refreshData.access}`, 'Authorization': `Bearer ${accessToken}`,
}; };
const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, { const retryResponse = await fetch(`${API_BASE_URL}${endpoint}`, {

View File

@@ -60,14 +60,17 @@ export const useAuthStore = create<AuthState>()(
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.message || 'Login failed'); throw new Error(data.error || data.message || 'Login failed');
} }
// Store user and JWT tokens // Store user and JWT tokens (handle both old and new API formats)
const responseData = data.data || data;
// Support both formats: new (access/refresh at top level) and old (tokens.access/refresh)
const tokens = responseData.tokens || {};
set({ set({
user: data.user, user: responseData.user || data.user,
token: data.tokens?.access || null, token: responseData.access || tokens.access || data.access || null,
refreshToken: data.tokens?.refresh || null, refreshToken: responseData.refresh || tokens.refresh || data.refresh || null,
isAuthenticated: true, isAuthenticated: true,
loading: false loading: false
}); });
@@ -119,8 +122,8 @@ export const useAuthStore = create<AuthState>()(
// Store user and JWT tokens // Store user and JWT tokens
set({ set({
user: data.user, user: data.user,
token: data.tokens?.access || null, token: data.data?.access || data.access || null,
refreshToken: data.tokens?.refresh || null, refreshToken: data.data?.refresh || data.refresh || null,
isAuthenticated: true, isAuthenticated: true,
loading: false loading: false
}); });
@@ -168,8 +171,8 @@ export const useAuthStore = create<AuthState>()(
throw new Error(data.message || 'Token refresh failed'); throw new Error(data.message || 'Token refresh failed');
} }
// Update access token // Update access token (API returns access at top level of data)
set({ token: data.access }); set({ token: data.data?.access || data.access });
// Also refresh user data to get latest account/plan information // Also refresh user data to get latest account/plan information
// This ensures account/plan changes are reflected immediately // This ensures account/plan changes are reflected immediately