diff --git a/backend/environments_app/migrations/0005_add_order_to_environmentvariable.py b/backend/environments_app/migrations/0005_add_order_to_environmentvariable.py new file mode 100644 index 0000000..f0211bc --- /dev/null +++ b/backend/environments_app/migrations/0005_add_order_to_environmentvariable.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.9 on 2026-01-29 22:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('environments_app', '0004_environment_collection'), + ] + + operations = [ + migrations.AlterModelOptions( + name='environmentvariable', + options={'ordering': ['order', 'id']}, + ), + migrations.AddField( + model_name='environmentvariable', + name='order', + field=models.IntegerField(default=0), + ), + ] diff --git a/backend/environments_app/migrations/0006_set_initial_variable_order.py b/backend/environments_app/migrations/0006_set_initial_variable_order.py new file mode 100644 index 0000000..4b96493 --- /dev/null +++ b/backend/environments_app/migrations/0006_set_initial_variable_order.py @@ -0,0 +1,28 @@ +"""Data migration to set initial order for existing environment variables.""" +from django.db import migrations + + +def set_initial_order(apps, schema_editor): + EnvironmentVariable = apps.get_model('environments_app', 'EnvironmentVariable') + # Group by environment and order alphabetically by key + env_ids = EnvironmentVariable.objects.values_list( + 'environment_id', flat=True + ).distinct() + for env_id in env_ids: + variables = EnvironmentVariable.objects.filter( + environment_id=env_id + ).order_by('key') + for index, var in enumerate(variables): + var.order = index + 1 + var.save(update_fields=['order']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('environments_app', '0005_add_order_to_environmentvariable'), + ] + + operations = [ + migrations.RunPython(set_initial_order, migrations.RunPython.noop), + ] diff --git a/backend/environments_app/models.py b/backend/environments_app/models.py index 1d2af9d..998cfd2 100644 --- a/backend/environments_app/models.py +++ b/backend/environments_app/models.py @@ -74,14 +74,23 @@ class EnvironmentVariable(BaseModel): enabled = models.BooleanField(default=True) # Link group: variables with same link_group sync their selected_value_index link_group = models.CharField(max_length=255, blank=True, null=True) + order = models.IntegerField(default=0) class Meta: - ordering = ['key'] + ordering = ['order', 'id'] unique_together = ['environment', 'key'] def __str__(self): return f"{self.environment.name}.{self.key}" + def save(self, *args, **kwargs): + if self._state.adding and self.order == 0: + max_order = EnvironmentVariable.objects.filter( + environment=self.environment + ).order_by('-order').values_list('order', flat=True).first() + self.order = (max_order or 0) + 1 + super().save(*args, **kwargs) + @property def current_value(self): """Get the currently selected value.""" diff --git a/backend/environments_app/serializers.py b/backend/environments_app/serializers.py index f91d195..e391549 100644 --- a/backend/environments_app/serializers.py +++ b/backend/environments_app/serializers.py @@ -12,7 +12,7 @@ class Meta: fields = [ 'id', 'environment', 'key', 'values', 'selected_value_index', 'current_value', 'description', 'is_secret', 'enabled', 'link_group', - 'created_at', 'updated_at' + 'order', 'created_at', 'updated_at' ] read_only_fields = ['id', 'environment', 'created_at', 'updated_at', 'current_value'] diff --git a/backend/environments_app/views.py b/backend/environments_app/views.py index d70e8a7..9f6e5f3 100644 --- a/backend/environments_app/views.py +++ b/backend/environments_app/views.py @@ -282,3 +282,20 @@ def remove_value(self, request, environment_pk=None, pk=None): variable.remove_value(index) return Response(EnvironmentVariableSerializer(variable).data) + + @action(detail=False, methods=['post'], url_path='reorder') + def reorder(self, request, environment_pk=None): + """Reorder variables by updating their order field.""" + variable_ids = request.data.get('variable_ids', []) + if not variable_ids: + return Response( + {'error': 'variable_ids is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + for index, var_id in enumerate(variable_ids): + EnvironmentVariable.objects.filter( + id=var_id, environment_id=environment_pk + ).update(order=index) + + return Response({'status': 'ok'}) diff --git a/backend/licensing_app/__init__.py b/backend/licensing_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/licensing_app/management/__init__.py b/backend/licensing_app/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/licensing_app/management/commands/__init__.py b/backend/licensing_app/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/licensing_app/management/commands/generate_license_key.py b/backend/licensing_app/management/commands/generate_license_key.py new file mode 100644 index 0000000..5e88dbc --- /dev/null +++ b/backend/licensing_app/management/commands/generate_license_key.py @@ -0,0 +1,19 @@ +from django.core.management.base import BaseCommand + +from licensing_app.services import generate_license_key + + +class Command(BaseCommand): + help = 'Generate a valid PostAI license key' + + def add_arguments(self, parser): + parser.add_argument( + '-n', '--count', + type=int, + default=1, + help='Number of keys to generate (default: 1)', + ) + + def handle(self, *args, **options): + for _ in range(options['count']): + self.stdout.write(generate_license_key()) diff --git a/backend/licensing_app/migrations/0001_initial.py b/backend/licensing_app/migrations/0001_initial.py new file mode 100644 index 0000000..f3a171a --- /dev/null +++ b/backend/licensing_app/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.9 on 2026-01-28 22:57 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='License', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('trial_started_at', models.DateTimeField(auto_now_add=True)), + ('license_key', models.CharField(blank=True, default='', max_length=100)), + ('activated_at', models.DateTimeField(blank=True, null=True)), + ], + options={ + 'ordering': ['created_at'], + }, + ), + ] diff --git a/backend/licensing_app/migrations/0002_license_row_signature.py b/backend/licensing_app/migrations/0002_license_row_signature.py new file mode 100644 index 0000000..3bb3e9e --- /dev/null +++ b/backend/licensing_app/migrations/0002_license_row_signature.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2026-01-29 16:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('licensing_app', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='license', + name='row_signature', + field=models.CharField(blank=True, default='', max_length=64), + ), + ] diff --git a/backend/licensing_app/migrations/0003_alter_license_trial_started_at.py b/backend/licensing_app/migrations/0003_alter_license_trial_started_at.py new file mode 100644 index 0000000..c06d1d5 --- /dev/null +++ b/backend/licensing_app/migrations/0003_alter_license_trial_started_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.9 on 2026-01-29 16:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('licensing_app', '0002_license_row_signature'), + ] + + operations = [ + migrations.AlterField( + model_name='license', + name='trial_started_at', + field=models.DateTimeField(), + ), + ] diff --git a/backend/licensing_app/migrations/__init__.py b/backend/licensing_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/licensing_app/models.py b/backend/licensing_app/models.py new file mode 100644 index 0000000..c826968 --- /dev/null +++ b/backend/licensing_app/models.py @@ -0,0 +1,32 @@ +from django.db import models +from core.models import BaseModel + + +class License(BaseModel): + trial_started_at = models.DateTimeField() + license_key = models.CharField(max_length=100, blank=True, default='') + activated_at = models.DateTimeField(null=True, blank=True) + row_signature = models.CharField(max_length=64, blank=True, default='') + + class Meta: + ordering = ['created_at'] + + def __str__(self): + if self.license_key: + return f'License (activated)' + return f'License (trial)' + + def save(self, **kwargs): + from django.utils import timezone + from .services import sign_license + if not self.trial_started_at: + self.trial_started_at = timezone.now() + sign_license(self) + super().save(**kwargs) + + @classmethod + def get_instance(cls): + instance = cls.objects.first() + if not instance: + instance = cls.objects.create() + return instance diff --git a/backend/licensing_app/serializers.py b/backend/licensing_app/serializers.py new file mode 100644 index 0000000..0e8cecd --- /dev/null +++ b/backend/licensing_app/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + + +class LicenseStatusSerializer(serializers.Serializer): + trial_started_at = serializers.DateTimeField() + days_remaining = serializers.IntegerField() + is_trial = serializers.BooleanField() + is_activated = serializers.BooleanField() + is_expired = serializers.BooleanField() + + +class LicenseActivateSerializer(serializers.Serializer): + license_key = serializers.CharField(max_length=100) diff --git a/backend/licensing_app/services.py b/backend/licensing_app/services.py new file mode 100644 index 0000000..45e5dec --- /dev/null +++ b/backend/licensing_app/services.py @@ -0,0 +1,58 @@ +import hashlib +import hmac +import re + +HMAC_SECRET = b'postai-license-key-secret-2024' +ROW_SIGN_SECRET = b'postai-row-integrity-8f3k2m9x' +KEY_PATTERN = re.compile(r'^POSTAI-([0-9A-Fa-f]{5})-([0-9A-Fa-f]{5})-([0-9A-Fa-f]{5})-([0-9A-Fa-f]{5})$') + + +def validate_license_key(key: str) -> bool: + match = KEY_PATTERN.match(key.strip()) + if not match: + return False + + groups = match.groups() + hex_chars = ''.join(groups).lower() + payload = hex_chars[:16] + check = hex_chars[16:20] + + digest = hmac.new(HMAC_SECRET, payload.encode(), hashlib.sha256).hexdigest() + return digest[:4].lower() == check.lower() + + +def generate_license_key() -> str: + """Generate a valid license key (for testing/admin use).""" + import secrets + payload = secrets.token_hex(8) # 16 hex chars + digest = hmac.new(HMAC_SECRET, payload.encode(), hashlib.sha256).hexdigest() + check = digest[:4] + raw = payload + check + return f'POSTAI-{raw[0:5]}-{raw[5:10]}-{raw[10:15]}-{raw[15:20]}'.upper() + + +def compute_row_signature(trial_started_at: str, license_key: str, activated_at: str) -> str: + """Compute HMAC signature over the license row's critical fields.""" + message = f'{trial_started_at}|{license_key}|{activated_at}' + return hmac.new(ROW_SIGN_SECRET, message.encode(), hashlib.sha256).hexdigest() + + +def sign_license(instance) -> None: + """Compute and store the row signature on a License instance.""" + instance.row_signature = compute_row_signature( + str(instance.trial_started_at.isoformat()) if instance.trial_started_at else '', + instance.license_key or '', + str(instance.activated_at.isoformat()) if instance.activated_at else '', + ) + + +def verify_license(instance) -> bool: + """Verify that a License row has not been tampered with.""" + if not instance.row_signature: + return False + expected = compute_row_signature( + str(instance.trial_started_at.isoformat()) if instance.trial_started_at else '', + instance.license_key or '', + str(instance.activated_at.isoformat()) if instance.activated_at else '', + ) + return hmac.compare_digest(instance.row_signature, expected) diff --git a/backend/licensing_app/tests.py b/backend/licensing_app/tests.py new file mode 100644 index 0000000..95e5ad6 --- /dev/null +++ b/backend/licensing_app/tests.py @@ -0,0 +1,100 @@ +from datetime import timedelta +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from .models import License +from .services import validate_license_key, generate_license_key + + +class LicenseKeyValidationTest(TestCase): + def test_valid_generated_key(self): + key = generate_license_key() + self.assertTrue(validate_license_key(key)) + + def test_invalid_key_bad_checksum(self): + self.assertFalse(validate_license_key('POSTAI-AAAAA-BBBBB-CCCCC-DDDDD')) + + def test_invalid_key_bad_format(self): + self.assertFalse(validate_license_key('INVALID-KEY')) + + def test_invalid_key_empty(self): + self.assertFalse(validate_license_key('')) + + +class LicenseModelTest(TestCase): + def test_singleton(self): + a = License.get_instance() + b = License.get_instance() + self.assertEqual(a.pk, b.pk) + + +class LicenseAPITest(TestCase): + def setUp(self): + self.client = APIClient() + + def test_status_fresh_trial(self): + resp = self.client.get('/api/v1/license/status/') + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertTrue(data['is_trial']) + self.assertFalse(data['is_activated']) + self.assertFalse(data['is_expired']) + self.assertEqual(data['days_remaining'], 30) + + def test_status_expired_trial(self): + instance = License.get_instance() + instance.trial_started_at = timezone.now() - timedelta(days=31) + instance.save() + + resp = self.client.get('/api/v1/license/status/') + data = resp.json() + self.assertTrue(data['is_expired']) + self.assertEqual(data['days_remaining'], 0) + + def test_activate_valid_key(self): + key = generate_license_key() + resp = self.client.post('/api/v1/license/activate/', {'license_key': key}, format='json') + self.assertEqual(resp.status_code, 200) + + resp = self.client.get('/api/v1/license/status/') + data = resp.json() + self.assertTrue(data['is_activated']) + self.assertFalse(data['is_expired']) + + def test_activate_invalid_key(self): + resp = self.client.post('/api/v1/license/activate/', {'license_key': 'POSTAI-AAAAA-BBBBB-CCCCC-DDDDD'}, format='json') + self.assertEqual(resp.status_code, 400) + + def test_tampered_trial_start(self): + """Direct DB edit to trial_started_at should be detected as tampering.""" + instance = License.get_instance() + # Tamper directly in DB without going through model.save() + License.objects.filter(pk=instance.pk).update( + trial_started_at=timezone.now() + timedelta(days=365) + ) + + resp = self.client.get('/api/v1/license/status/') + data = resp.json() + self.assertTrue(data['is_expired']) + self.assertTrue(data.get('tampered', False)) + + def test_tampered_license_key(self): + """Direct DB edit to license_key should be detected as tampering.""" + instance = License.get_instance() + License.objects.filter(pk=instance.pk).update(license_key='POSTAI-FAKE0-FAKE0-FAKE0-FAKE0') + + resp = self.client.get('/api/v1/license/status/') + data = resp.json() + self.assertTrue(data['is_expired']) + self.assertTrue(data.get('tampered', False)) + + def test_tampered_signature_cleared(self): + """Clearing the row_signature should be detected.""" + instance = License.get_instance() + License.objects.filter(pk=instance.pk).update(row_signature='') + + resp = self.client.get('/api/v1/license/status/') + data = resp.json() + self.assertTrue(data['is_expired']) + self.assertTrue(data.get('tampered', False)) diff --git a/backend/licensing_app/urls.py b/backend/licensing_app/urls.py new file mode 100644 index 0000000..41a1d0c --- /dev/null +++ b/backend/licensing_app/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import LicenseStatusView, LicenseActivateView + +urlpatterns = [ + path('status/', LicenseStatusView.as_view(), name='license-status'), + path('activate/', LicenseActivateView.as_view(), name='license-activate'), +] diff --git a/backend/licensing_app/views.py b/backend/licensing_app/views.py new file mode 100644 index 0000000..ed4e85d --- /dev/null +++ b/backend/licensing_app/views.py @@ -0,0 +1,70 @@ +from django.utils import timezone +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .models import License +from .serializers import LicenseActivateSerializer +from .services import validate_license_key, verify_license + +TRIAL_DAYS = 30 + + +class LicenseStatusView(APIView): + def get(self, request): + instance = License.get_instance() + + # If signature verification fails, the DB was tampered with + if not verify_license(instance): + return Response({ + 'trial_started_at': instance.trial_started_at, + 'days_remaining': 0, + 'is_trial': False, + 'is_activated': False, + 'is_expired': True, + 'tampered': True, + }) + + is_activated = bool(instance.license_key) + + if is_activated: + data = { + 'trial_started_at': instance.trial_started_at, + 'days_remaining': 0, + 'is_trial': False, + 'is_activated': True, + 'is_expired': False, + } + else: + elapsed = (timezone.now() - instance.trial_started_at).days + days_remaining = max(TRIAL_DAYS - elapsed, 0) + data = { + 'trial_started_at': instance.trial_started_at, + 'days_remaining': days_remaining, + 'is_trial': True, + 'is_activated': False, + 'is_expired': days_remaining <= 0, + } + + return Response(data) + + +class LicenseActivateView(APIView): + def post(self, request): + serializer = LicenseActivateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + key = serializer.validated_data['license_key'] + + if not validate_license_key(key): + return Response( + {'detail': 'Invalid license key.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + instance = License.get_instance() + instance.license_key = key + instance.activated_at = timezone.now() + instance.save() + + return Response({'detail': 'License activated successfully.'}) diff --git a/backend/postai/settings.py b/backend/postai/settings.py index 14ab94b..cca91e8 100644 --- a/backend/postai/settings.py +++ b/backend/postai/settings.py @@ -32,6 +32,7 @@ 'ai_app', 'proxy_app', 'sync_app', + 'licensing_app', ] MIDDLEWARE = [ diff --git a/backend/postai/urls.py b/backend/postai/urls.py index a1db0f6..b70d6b8 100644 --- a/backend/postai/urls.py +++ b/backend/postai/urls.py @@ -14,5 +14,6 @@ path('ai/', include('ai_app.urls')), path('proxy/', include('proxy_app.urls')), path('sync/', include('sync_app.urls')), + path('license/', include('licensing_app.urls')), ])), ] diff --git a/backend/postai_server.spec b/backend/postai_server.spec index a28cc42..1de6e3a 100644 --- a/backend/postai_server.spec +++ b/backend/postai_server.spec @@ -32,6 +32,7 @@ django_apps = [ 'mcp_app', 'proxy_app', 'sync_app', + 'licensing_app', 'postai', ] diff --git a/package-lock.json b/package-lock.json index c75b64b..33f3a36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,17 @@ { "name": "postai", - "version": "1.2.6", + "version": "1.3.0-beta.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "postai", - "version": "1.2.6", + "version": "1.3.0-beta.4", "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.0", @@ -616,6 +619,59 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", diff --git a/package.json b/package.json index d5f84ad..e1b84ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postai", - "version": "1.2.6", + "version": "1.3.0-beta.4", "description": "PostAI - Advanced API Testing Tool with AI Integration", "main": "dist-electron/main.js", "author": "PostAI Team", @@ -25,6 +25,9 @@ "backend:setup": "cd backend && python -m venv .venv && .venv/bin/pip install -r requirements.txt && .venv/bin/python manage.py migrate" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.0", diff --git a/src/App.tsx b/src/App.tsx index 68ec903..e357876 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react' import { AppLayout } from './components/layout/AppLayout' import { AiChatSidebar } from './components/ai/AiChatSidebar' +import { LicenseGate } from './components/licensing/LicenseGate' import { useBackendStore } from './stores/backend.store' +import { useLicenseStore } from './stores/license.store' import { Loader2 } from 'lucide-react' function App() { const { checkConnection } = useBackendStore() + const { status: licenseStatus, fetchStatus: fetchLicenseStatus } = useLicenseStore() const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) @@ -26,6 +29,7 @@ function App() { while (attempts < maxAttempts) { const connected = await checkConnection() if (connected) { + await fetchLicenseStatus() setIsLoading(false) return } @@ -44,7 +48,7 @@ function App() { } initApp() - }, [checkConnection]) + }, [checkConnection, fetchLicenseStatus]) if (isLoading) { return ( @@ -73,6 +77,10 @@ function App() { ) } + if (licenseStatus?.is_expired && !licenseStatus?.is_activated) { + return + } + return ( <> diff --git a/src/components/environments/EnvironmentManager.tsx b/src/components/environments/EnvironmentManager.tsx index 58fde3f..e0ee13a 100644 --- a/src/components/environments/EnvironmentManager.tsx +++ b/src/components/environments/EnvironmentManager.tsx @@ -9,9 +9,27 @@ import { EyeOff, Link, Unlink, + GripVertical, } from 'lucide-react' import { clsx } from 'clsx' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + DragEndEvent, +} from '@dnd-kit/core' +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { useEnvironmentsStore } from '@/stores/environments.store' +import { useTabsStore } from '@/stores/tabs.store' import { Environment, EnvironmentVariable } from '@/types' import { LinkedValueDropdown } from './LinkedValueDropdown' import toast from 'react-hot-toast' @@ -33,7 +51,9 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) { deleteVariable, selectVariableValue, addVariableValue, + reorderVariables, } = useEnvironmentsStore() + const { openTab } = useTabsStore() // When showing a single environment, auto-expand it const [expandedEnvs, setExpandedEnvs] = useState>( @@ -42,10 +62,35 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) { const [newVarKey, setNewVarKey] = useState('') const [newVarValue, setNewVarValue] = useState('') const [newVarDescription, setNewVarDescription] = useState('') + const [newVarLinkTo, setNewVarLinkTo] = useState(null) + const [newVarLinkedValues, setNewVarLinkedValues] = useState([]) const [showNewEnvForm, setShowNewEnvForm] = useState(false) const [newEnvName, setNewEnvName] = useState('') const [addingValueTo, setAddingValueTo] = useState<{ envId: string; varId: string } | null>(null) const [newValueInput, setNewValueInput] = useState('') + const [addingLinkedValues, setAddingLinkedValues] = useState>({}) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + const handleDragEnd = async (event: DragEndEvent, env: Environment) => { + const { active, over } = event + if (!over || active.id === over.id) return + const variables = env.variables || [] + const oldIndex = variables.findIndex(v => v.id === active.id) + const newIndex = variables.findIndex(v => v.id === over.id) + if (oldIndex === -1 || newIndex === -1) return + const reordered = [...variables] + const [moved] = reordered.splice(oldIndex, 1) + reordered.splice(newIndex, 0, moved) + try { + await reorderVariables(env.id, reordered.map(v => v.id)) + } catch { + toast.error('Failed to reorder variables') + } + } useEffect(() => { fetchEnvironments() @@ -100,17 +145,42 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) { } try { - await createVariable(env.id, { - key: newVarKey.trim(), - values: [newVarValue], - selected_value_index: 0, - enabled: true, - is_secret: false, - description: newVarDescription.trim(), - }) + if (newVarLinkTo) { + // Creating a linked variable: use multiple values and set link_group + const targetVar = (env.variables || []).find(v => v.id === newVarLinkTo) + if (!targetVar) { + toast.error('Target variable not found') + return + } + const linkGroup = targetVar.link_group || targetVar.key + // Ensure target has the link_group set + if (!targetVar.link_group) { + await updateVariable(env.id, targetVar.id, { link_group: linkGroup }) + } + await createVariable(env.id, { + key: newVarKey.trim(), + values: newVarLinkedValues.length > 0 ? newVarLinkedValues : targetVar.values.map(() => ''), + selected_value_index: targetVar.selected_value_index, + enabled: true, + is_secret: false, + description: newVarDescription.trim(), + link_group: linkGroup, + }) + } else { + await createVariable(env.id, { + key: newVarKey.trim(), + values: [newVarValue], + selected_value_index: 0, + enabled: true, + is_secret: false, + description: newVarDescription.trim(), + }) + } setNewVarKey('') setNewVarValue('') setNewVarDescription('') + setNewVarLinkTo(null) + setNewVarLinkedValues([]) toast.success('Variable added') } catch (err) { console.error('Failed to add variable:', err) @@ -171,15 +241,37 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) { const handleStartAddValue = (env: Environment, variable: EnvironmentVariable) => { setAddingValueTo({ envId: env.id, varId: variable.id }) setNewValueInput('') + // If variable is linked, initialize inputs for all linked vars + if (variable.link_group) { + const linkedVars = (env.variables || []).filter(v => v.link_group === variable.link_group) + const inputs: Record = {} + linkedVars.forEach(v => { inputs[v.id] = '' }) + setAddingLinkedValues(inputs) + } else { + setAddingLinkedValues({}) + } } const handleConfirmAddValue = async () => { - if (!addingValueTo || !newValueInput.trim()) return + if (!addingValueTo) return + const env = environments.find(e => e.id === addingValueTo.envId) + const variable = env?.variables?.find(v => v.id === addingValueTo.varId) + try { - await addVariableValue(addingValueTo.envId, addingValueTo.varId, newValueInput.trim()) - toast.success('Value added') + if (variable?.link_group && Object.keys(addingLinkedValues).length > 0) { + // Add value to all linked variables + for (const [varId, value] of Object.entries(addingLinkedValues)) { + await addVariableValue(addingValueTo.envId, varId, value.trim() || '') + } + toast.success('Values added to all linked variables') + } else { + if (!newValueInput.trim()) return + await addVariableValue(addingValueTo.envId, addingValueTo.varId, newValueInput.trim()) + toast.success('Value added') + } setAddingValueTo(null) setNewValueInput('') + setAddingLinkedValues({}) } catch (err) { toast.error('Failed to add value') } @@ -188,6 +280,7 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) { const handleCancelAddValue = () => { setAddingValueTo(null) setNewValueInput('') + setAddingLinkedValues({}) } return ( @@ -224,6 +317,19 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) { > + {(displayedEnvironments[0].variables || []).some(v => v.link_group) && ( + + )} )} @@ -344,7 +450,7 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) {
{/* Variables table header */}
-
Variable
+
Variable
Value
Description
Type
@@ -352,65 +458,163 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) {
{/* Variables */} - {(env.variables || []).map((variable) => ( - - handleUpdateVariable(env, variable, updates) - } - onDelete={() => handleDeleteVariable(env, variable)} - onAddValue={() => handleStartAddValue(env, variable)} - onSelectValue={(index) => handleSelectValue(env, variable, index)} - onLinkTo={(targetVar) => handleLinkVariables(env, variable, targetVar)} - isAddingValue={addingValueTo?.varId === variable.id} - newValueInput={newValueInput} - onNewValueChange={setNewValueInput} - onConfirmAddValue={handleConfirmAddValue} - onCancelAddValue={handleCancelAddValue} - /> - ))} + handleDragEnd(event, env)} + > + v.id)} + strategy={verticalListSortingStrategy} + > + {(env.variables || []).map((variable) => ( + + handleUpdateVariable(env, variable, updates) + } + onDelete={() => handleDeleteVariable(env, variable)} + onAddValue={() => handleStartAddValue(env, variable)} + onSelectValue={(index) => handleSelectValue(env, variable, index)} + onLinkTo={(targetVar) => handleLinkVariables(env, variable, targetVar)} + isAddingValue={addingValueTo?.varId === variable.id} + newValueInput={newValueInput} + onNewValueChange={setNewValueInput} + onConfirmAddValue={handleConfirmAddValue} + onCancelAddValue={handleCancelAddValue} + addingLinkedValues={addingLinkedValues} + onLinkedValueChange={(varId, value) => setAddingLinkedValues(prev => ({ ...prev, [varId]: value }))} + /> + ))} + + {/* Add variable row */} -
-
- setNewVarKey(e.target.value)} - placeholder="Variable name" - className="w-full px-2 py-1 text-sm bg-panel border border-border rounded focus:border-primary-500" - /> -
-
- setNewVarValue(e.target.value)} - placeholder="Initial value" - className="w-full px-2 py-1 text-sm bg-panel border border-border rounded focus:border-primary-500" - /> -
-
- setNewVarDescription(e.target.value)} - placeholder="Description (optional)" - className="w-full px-2 py-1 text-sm bg-panel border border-border rounded focus:border-primary-500" - /> -
-
-
- +
+
+
+ setNewVarKey(e.target.value)} + placeholder="Variable name" + className="w-full px-2 py-1 text-sm bg-panel border border-border rounded focus:border-primary-500" + /> +
+
+ {newVarLinkTo ? ( + + Linked — {(() => { + const t = (env.variables || []).find(v => v.id === newVarLinkTo) + return t ? `${t.values.length} values` : '' + })()} + + ) : ( + setNewVarValue(e.target.value)} + placeholder="Initial value" + className="w-full px-2 py-1 text-sm bg-panel border border-border rounded focus:border-primary-500" + /> + )} +
+
+ setNewVarDescription(e.target.value)} + placeholder="Description (optional)" + className="flex-1 px-2 py-1 text-sm bg-panel border border-border rounded focus:border-primary-500" + /> + {/* Link-to dropdown for new variable */} + {(() => { + const linkableVars = (env.variables || []).filter(v => v.values.length > 1) + if (linkableVars.length === 0) return null + return ( +
+ + {!newVarLinkTo && ( + + )} +
+ ) + })()} +
+
+
+ +
+ {/* Multi-value inputs when linking */} + {newVarLinkTo && (() => { + const target = (env.variables || []).find(v => v.id === newVarLinkTo) + if (!target) return null + return ( +
+
Values (matching {target.key}):
+ {target.values.map((tv, i) => ( +
+ + Value {i + 1} ({tv}) + + { + const updated = [...newVarLinkedValues] + updated[i] = e.target.value + setNewVarLinkedValues(updated) + }} + placeholder={`Value for "${tv}"`} + className="flex-1 px-2 py-1 text-sm bg-panel border border-border rounded font-mono focus:border-primary-500" + /> +
+ ))} +
+ ) + })()}
)} @@ -423,6 +627,32 @@ export function EnvironmentManager({ environmentId }: EnvironmentManagerProps) { ) } +function SortableVariableRow(props: React.ComponentProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: props.variable.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : undefined, + } + + return ( + + ) +} + function VariableRow({ variable, allVariables, @@ -436,6 +666,11 @@ function VariableRow({ onNewValueChange, onConfirmAddValue, onCancelAddValue, + addingLinkedValues, + onLinkedValueChange, + sortableRef, + sortableStyle, + dragHandleProps, }: { variable: EnvironmentVariable allVariables: EnvironmentVariable[] @@ -449,6 +684,11 @@ function VariableRow({ onNewValueChange?: (value: string) => void onConfirmAddValue?: () => void onCancelAddValue?: () => void + addingLinkedValues?: Record + onLinkedValueChange?: (varId: string, value: string) => void + sortableRef?: (node: HTMLElement | null) => void + sortableStyle?: React.CSSProperties + dragHandleProps?: Record }) { const [showSecret, setShowSecret] = useState(false) const [showLinkMenu, setShowLinkMenu] = useState(false) @@ -465,6 +705,14 @@ function VariableRow({ ? allVariables.filter(v => v.link_group === variable.link_group && v.id !== variable.id) : [] + // Compute group letter (A, B, C...) from unique link_groups + const groupLetter = (() => { + if (!variable.link_group) return null + const uniqueGroups = [...new Set(allVariables.filter(v => v.link_group).map(v => v.link_group))].sort() + const index = uniqueGroups.indexOf(variable.link_group) + return index >= 0 ? String.fromCharCode(65 + index) : null + })() + const handleLinkToVar = (targetVar: EnvironmentVariable) => { onLinkTo(targetVar) setShowLinkMenu(false) @@ -476,9 +724,15 @@ function VariableRow({ } return ( - <> +
+ setShowLinkMenu(!showLinkMenu)} className={clsx( - 'p-1 rounded transition-colors', + 'p-1 rounded transition-colors flex items-center gap-1', variable.link_group ? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/30' : 'hover:bg-white/10 text-text-secondary hover:text-primary-400' @@ -561,7 +815,12 @@ function VariableRow({ title={variable.link_group ? `Linked to: ${linkedVariables.map(v => v.key).join(', ')}` : 'Link to another variable'} > {variable.link_group ? ( - + <> + + {groupLetter && ( + {groupLetter} + )} + ) : ( )} @@ -627,40 +886,83 @@ function VariableRow({
{/* Inline add value row */} {isAddingValue && ( -
-
- New value for {variable.key}: -
-
- onNewValueChange?.(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') onConfirmAddValue?.() - if (e.key === 'Escape') onCancelAddValue?.() - }} - placeholder="Enter new value..." - autoFocus - className="flex-1 px-2 py-1 text-sm bg-panel border border-primary-500 rounded font-mono" - /> - - -
-
+
+ {variable.link_group && addingLinkedValues && Object.keys(addingLinkedValues).length > 0 ? ( + // Linked: show inputs for all linked variables +
+
Add values to all linked variables:
+ {allVariables + .filter(v => v.link_group === variable.link_group) + .map(v => ( +
+ {v.key}: + onLinkedValueChange?.(v.id, e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onConfirmAddValue?.() + if (e.key === 'Escape') onCancelAddValue?.() + }} + placeholder={`New value for ${v.key}...`} + autoFocus={v.id === variable.id} + className="flex-1 px-2 py-1 text-sm bg-panel border border-primary-500 rounded font-mono" + /> +
+ ))} +
+ + +
+
+ ) : ( + // Single variable: original single input +
+
+ New value for {variable.key}: +
+
+ onNewValueChange?.(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') onConfirmAddValue?.() + if (e.key === 'Escape') onCancelAddValue?.() + }} + placeholder="Enter new value..." + autoFocus + className="flex-1 px-2 py-1 text-sm bg-panel border border-primary-500 rounded font-mono" + /> + + +
+
+
+ )}
)} - +
) } diff --git a/src/components/environments/LinkGroupsViewer.tsx b/src/components/environments/LinkGroupsViewer.tsx new file mode 100644 index 0000000..396a35f --- /dev/null +++ b/src/components/environments/LinkGroupsViewer.tsx @@ -0,0 +1,316 @@ +import { useState, useEffect } from 'react' +import { Link, Trash2, Plus } from 'lucide-react' +import { clsx } from 'clsx' +import { useEnvironmentsStore } from '@/stores/environments.store' +import toast from 'react-hot-toast' + +interface LinkGroupsViewerProps { + environmentId: string +} + +export function LinkGroupsViewer({ environmentId }: LinkGroupsViewerProps) { + const { environments, fetchEnvironments, updateVariable, removeVariableValue, addVariableValue } = useEnvironmentsStore() + + useEffect(() => { + fetchEnvironments() + }, []) + + const environment = environments.find(e => e.id === environmentId) + + if (!environment) { + return ( +
+

Environment not found

+
+ ) + } + + const variables = environment.variables || [] + + // Group variables by link_group (skip variables with no link_group) + const groupsMap = new Map() + for (const v of variables) { + if (!v.link_group) continue + const existing = groupsMap.get(v.link_group) || [] + existing.push(v) + groupsMap.set(v.link_group, existing) + } + + const groups = [...groupsMap.entries()].sort(([a], [b]) => a.localeCompare(b)) + + if (groups.length === 0) { + return ( +
+ +

No link groups in this environment

+

Link variables together to see them here

+
+ ) + } + + const handleValueChange = async (varId: string, valueIndex: number, newValue: string, currentValues: string[]) => { + const updated = [...currentValues] + updated[valueIndex] = newValue + try { + await updateVariable(environmentId, varId, { values: updated }) + } catch { + toast.error('Failed to update value') + } + } + + const handleDeleteValueRow = async (groupVars: typeof variables, rowIndex: number) => { + // Must have at least 1 value remaining + if ((groupVars[0]?.values.length || 0) <= 1) { + toast.error('Cannot delete the last value') + return + } + try { + for (const v of groupVars) { + await removeVariableValue(environmentId, v.id, rowIndex) + } + toast.success('Value row deleted') + } catch { + toast.error('Failed to delete value row') + } + } + + const [addingToGroup, setAddingToGroup] = useState(null) + const [addRowValues, setAddRowValues] = useState>({}) + const [renamingGroup, setRenamingGroup] = useState(null) + const [renameValue, setRenameValue] = useState('') + + const handleRenameGroup = async (oldName: string, groupVars: typeof variables) => { + const newName = renameValue.trim() + if (!newName || newName === oldName) { + setRenamingGroup(null) + return + } + try { + for (const v of groupVars) { + await updateVariable(environmentId, v.id, { link_group: newName }) + } + setRenamingGroup(null) + toast.success('Group renamed') + } catch { + toast.error('Failed to rename group') + } + } + + const handleAddValueRow = async (_groupName: string, groupVars: typeof variables) => { + try { + for (const v of groupVars) { + await addVariableValue(environmentId, v.id, addRowValues[v.id]?.trim() || '') + } + setAddingToGroup(null) + setAddRowValues({}) + toast.success('Value row added') + } catch { + toast.error('Failed to add value row') + } + } + + return ( +
+
+ +

Link Groups — {environment.name}

+ + {groups.length} group{groups.length !== 1 ? 's' : ''} + +
+ +
+ {groups.map(([groupName, groupVars], groupIndex) => { + const letter = String.fromCharCode(65 + groupIndex) + const selectedIndex = groupVars[0]?.selected_value_index ?? 0 + const rowCount = groupVars[0]?.values.length || 0 + + return ( +
+ {/* Group header */} +
+ + {letter} + + {renamingGroup === groupName ? ( + setRenameValue(e.target.value)} + onBlur={() => handleRenameGroup(groupName, groupVars)} + onKeyDown={e => { + if (e.key === 'Enter') handleRenameGroup(groupName, groupVars) + if (e.key === 'Escape') setRenamingGroup(null) + }} + className="px-2 py-0.5 text-sm font-medium bg-panel border border-cyan-500 rounded outline-none" + /> + ) : ( + { setRenamingGroup(groupName); setRenameValue(groupName) }} + title="Click to rename group" + > + {groupName} + + )} + + {groupVars.length} variables · {groupVars[0]?.values.length || 0} values + +
+ + {/* Table layout: header row + value rows */} +
+ {/* Header row */} +
+ {groupVars.map(variable => ( +
+ {variable.key} +
+ ))} +
+
+ {/* Value rows */} + {Array.from({ length: rowCount }).map((_, rowIndex) => { + const canDelete = rowIndex < (groupVars[0]?.values.length || 0) + return ( +
+ {groupVars.map(variable => ( +
+ {rowIndex < variable.values.length ? ( + handleValueChange(variable.id, rowIndex, newVal, variable.values)} + /> + ) : ( +
 
+ )} +
+ ))} +
+ {canDelete && ( + + )} +
+
+ ) + })} +
+ + {/* Add row */} + {addingToGroup === groupName ? ( +
+
+ {groupVars.map(v => ( +
+ {v.key}: + setAddRowValues(prev => ({ ...prev, [v.id]: e.target.value }))} + onKeyDown={e => { + if (e.key === 'Enter') handleAddValueRow(groupName, groupVars) + if (e.key === 'Escape') { setAddingToGroup(null); setAddRowValues({}) } + }} + placeholder="value" + autoFocus={v === groupVars[0]} + className="px-2 py-1 text-sm font-mono bg-panel border border-border rounded focus:border-cyan-500" + /> +
+ ))} +
+
+ + +
+
+ ) : ( +
+ +
+ )} +
+ ) + })} +
+
+ ) +} + +function EditableCell({ value, isSelected, onChange }: { + value: string + isSelected: boolean + onChange: (newVal: string) => void +}) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value) + + // Sync draft when value changes externally + useEffect(() => { setDraft(value) }, [value]) + + const commit = () => { + setEditing(false) + if (draft !== value) onChange(draft) + } + + if (editing) { + return ( +
+ setDraft(e.target.value)} + onBlur={commit} + onKeyDown={e => { + if (e.key === 'Enter') commit() + if (e.key === 'Escape') { setDraft(value); setEditing(false) } + }} + className="w-full px-3 py-1.5 text-sm font-mono bg-transparent border-0 outline-none focus:ring-1 focus:ring-cyan-500/50 rounded" + /> +
+ ) + } + + return ( +
setEditing(true)} + className={clsx( + 'px-3 py-1.5 text-sm font-mono cursor-text hover:bg-white/5', + isSelected ? 'bg-cyan-500/15 text-cyan-300' : 'text-text-secondary' + )} + > + {value || empty} +
+ ) +} diff --git a/src/components/environments/LinkedValueDropdown.tsx b/src/components/environments/LinkedValueDropdown.tsx index 162cc66..8b14aa3 100644 --- a/src/components/environments/LinkedValueDropdown.tsx +++ b/src/components/environments/LinkedValueDropdown.tsx @@ -52,6 +52,26 @@ export function LinkedValueDropdown({ // Open upward if not enough space below but enough above setOpenUpward(spaceBelow < dropdownHeight && spaceAbove > spaceBelow) + + // Calculate and set fixed position so dropdown isn't clipped by overflow parents + requestAnimationFrame(() => { + if (dropdownRef.current && containerRef.current) { + const r = containerRef.current.getBoundingClientRect() + const dd = dropdownRef.current + dd.style.position = 'fixed' + dd.style.left = `${r.left}px` + dd.style.minWidth = `${r.width}px` + if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) { + dd.style.bottom = `${window.innerHeight - r.top}px` + dd.style.top = 'auto' + dd.style.maxHeight = `${Math.min(spaceAbove - 8, dropdownHeight)}px` + } else { + dd.style.top = `${r.bottom + 4}px` + dd.style.bottom = 'auto' + dd.style.maxHeight = `${Math.min(spaceBelow - 8, dropdownHeight)}px` + } + } + }) } setIsOpen(!isOpen) } @@ -110,7 +130,7 @@ export function LinkedValueDropdown({ const currentLinkedInfo = getLinkedInfo(variable.selected_value_index) return ( -
+
{/* Trigger button */} + +
+
+ ) +} diff --git a/src/stores/environments.store.ts b/src/stores/environments.store.ts index e540566..baaf6ec 100644 --- a/src/stores/environments.store.ts +++ b/src/stores/environments.store.ts @@ -40,6 +40,7 @@ interface EnvironmentsState { selectVariableValue: (envId: string, varId: string, index: number) => Promise addVariableValue: (envId: string, varId: string, value: string) => Promise removeVariableValue: (envId: string, varId: string, index: number) => Promise + reorderVariables: (envId: string, variableIds: string[]) => Promise // Computed getGlobalEnvironments: () => Environment[] @@ -223,6 +224,11 @@ export const useEnvironmentsStore = create((set, get) => ({ await get().fetchEnvironments() }, + reorderVariables: async (envId, variableIds) => { + await api.post(`/environments/${envId}/variables/reorder/`, { variable_ids: variableIds }) + await get().fetchEnvironments() + }, + getGlobalEnvironments: () => { const { environments } = get() return environments.filter(e => !e.collection) diff --git a/src/stores/license.store.ts b/src/stores/license.store.ts new file mode 100644 index 0000000..f6db9eb --- /dev/null +++ b/src/stores/license.store.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand' +import { apiGet, apiPost } from '@/api/client' +import type { LicenseStatus } from '@/types' + +interface LicenseState { + status: LicenseStatus | null + isLoading: boolean + error: string | null + fetchStatus: () => Promise + activate: (key: string) => Promise +} + +export const useLicenseStore = create((set) => ({ + status: null, + isLoading: false, + error: null, + + fetchStatus: async () => { + set({ isLoading: true, error: null }) + try { + const data = await apiGet('/license/status/') + set({ status: data, isLoading: false }) + } catch { + set({ error: 'Failed to fetch license status', isLoading: false }) + } + }, + + activate: async (key: string) => { + set({ error: null }) + try { + await apiPost('/license/activate/', { license_key: key }) + const data = await apiGet('/license/status/') + set({ status: data }) + return true + } catch (err: unknown) { + const message = + err && typeof err === 'object' && 'response' in err + ? ((err as { response?: { data?: { detail?: string } } }).response?.data?.detail ?? 'Invalid license key') + : 'Failed to activate license' + set({ error: message }) + return false + } + }, +})) diff --git a/src/stores/tabs.store.ts b/src/stores/tabs.store.ts index f875b0a..5d3d480 100644 --- a/src/stores/tabs.store.ts +++ b/src/stores/tabs.store.ts @@ -1,7 +1,7 @@ import { create } from 'zustand' import { Request, Workflow, Environment, HttpMethod, KeyValuePair, RequestBody, AuthConfig, Response } from '@/types' -export type TabType = 'request' | 'workflow' | 'mcp' | 'ai' | 'environments' | 'environment' +export type TabType = 'request' | 'workflow' | 'mcp' | 'ai' | 'environments' | 'environment' | 'link-groups' // MCP tab data export interface McpTabData { diff --git a/src/test/stores/environments.test.ts b/src/test/stores/environments.test.ts index bb1e99e..d54d9ad 100644 --- a/src/test/stores/environments.test.ts +++ b/src/test/stores/environments.test.ts @@ -45,6 +45,7 @@ const createMockVariable = (overrides: Record = {}) => ({ description: '', is_secret: false, enabled: true, + order: 0, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', ...overrides, diff --git a/src/types/index.ts b/src/types/index.ts index be2e13f..45e44b0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -169,6 +169,7 @@ export interface EnvironmentVariable extends BaseModel { is_secret: boolean enabled: boolean link_group?: string | null // Variables with same link_group sync their selected_value_index + order: number } // Request History @@ -356,6 +357,15 @@ export interface AiMessage extends BaseModel { tokens_used: number } +// License types +export interface LicenseStatus { + trial_started_at: string + days_remaining: number + is_trial: boolean + is_activated: boolean + is_expired: boolean +} + // Proxy types export type ProxyType = 'http' | 'https' | 'socks4' | 'socks5'