From 8778a02834688a8a151d11cb66838197301edaf6 Mon Sep 17 00:00:00 2001 From: Grigori Kartashyan Date: Wed, 28 Jan 2026 23:58:45 +0100 Subject: [PATCH 1/8] Add 30-day trial licensing system with offline key validation Implements a licensing gate that allows full app use for 30 days, then requires a POSTAI-XXXXX-XXXXX-XXXXX-XXXXX license key validated offline via HMAC-SHA256 checksum. Co-Authored-By: Claude Opus 4.5 --- backend/licensing_app/__init__.py | 0 .../licensing_app/migrations/0001_initial.py | 29 ++++++ backend/licensing_app/migrations/__init__.py | 0 backend/licensing_app/models.py | 23 +++++ backend/licensing_app/serializers.py | 13 +++ backend/licensing_app/services.py | 30 ++++++ backend/licensing_app/tests.py | 67 ++++++++++++++ backend/licensing_app/urls.py | 7 ++ backend/licensing_app/views.py | 58 ++++++++++++ backend/postai/settings.py | 1 + backend/postai/urls.py | 1 + src/App.tsx | 10 +- src/components/licensing/LicenseGate.tsx | 91 +++++++++++++++++++ src/stores/license.store.ts | 44 +++++++++ src/types/index.ts | 9 ++ 15 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 backend/licensing_app/__init__.py create mode 100644 backend/licensing_app/migrations/0001_initial.py create mode 100644 backend/licensing_app/migrations/__init__.py create mode 100644 backend/licensing_app/models.py create mode 100644 backend/licensing_app/serializers.py create mode 100644 backend/licensing_app/services.py create mode 100644 backend/licensing_app/tests.py create mode 100644 backend/licensing_app/urls.py create mode 100644 backend/licensing_app/views.py create mode 100644 src/components/licensing/LicenseGate.tsx create mode 100644 src/stores/license.store.ts 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/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/__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..83601e6 --- /dev/null +++ b/backend/licensing_app/models.py @@ -0,0 +1,23 @@ +from django.db import models +from core.models import BaseModel + + +class License(BaseModel): + trial_started_at = models.DateTimeField(auto_now_add=True) + license_key = models.CharField(max_length=100, blank=True, default='') + activated_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ['created_at'] + + def __str__(self): + if self.license_key: + return f'License (activated)' + return f'License (trial)' + + @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..76e4e97 --- /dev/null +++ b/backend/licensing_app/services.py @@ -0,0 +1,30 @@ +import hashlib +import hmac +import re + +HMAC_SECRET = b'postai-license-key-secret-2024' +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() diff --git a/backend/licensing_app/tests.py b/backend/licensing_app/tests.py new file mode 100644 index 0000000..e32138c --- /dev/null +++ b/backend/licensing_app/tests.py @@ -0,0 +1,67 @@ +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) 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..11a7a4b --- /dev/null +++ b/backend/licensing_app/views.py @@ -0,0 +1,58 @@ +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 + +TRIAL_DAYS = 30 + + +class LicenseStatusView(APIView): + def get(self, request): + instance = License.get_instance() + 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/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/licensing/LicenseGate.tsx b/src/components/licensing/LicenseGate.tsx new file mode 100644 index 0000000..a3f8b23 --- /dev/null +++ b/src/components/licensing/LicenseGate.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useLicenseStore } from '@/stores/license.store' +import { KeyRound, Loader2, AlertCircle } from 'lucide-react' + +export function LicenseGate() { + const { activate, error } = useLicenseStore() + const [key, setKey] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const formatKey = (value: string) => { + const raw = value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 20) + const groups = raw.match(/.{1,5}/g) || [] + return groups.length ? `POSTAI-${groups.join('-')}` : value.startsWith('P') ? value : '' + } + + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value + if (v === '' || v === 'P' || v === 'PO' || v === 'POS' || v === 'POST' || v === 'POSTA' || v === 'POSTAI' || v === 'POSTAI-') { + setKey(v) + return + } + if (v.startsWith('POSTAI-')) { + setKey(formatKey(v.slice(7))) + } else { + setKey(v) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setIsSubmitting(true) + await activate(key) + setIsSubmitting(false) + } + + return ( +
+
+
+
+ +
+

Trial Expired

+

+ Your 30-day trial has ended. Enter a license key to continue using PostAI. +

+
+ +
+
+ + +
+ + {error && ( +
+ + {error} +
+ )} + + +
+
+
+ ) +} 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/types/index.ts b/src/types/index.ts index be2e13f..2ccff06 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -356,6 +356,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' From c40ecc24cd6fdd6e79a5c213e1380dbfc3d9f3db Mon Sep 17 00:00:00 2001 From: Grigori Kartashyan Date: Thu, 29 Jan 2026 00:01:14 +0100 Subject: [PATCH 2/8] Bump version to 1.3.0-beta.1 Co-Authored-By: Claude Opus 4.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d5f84ad..0689b8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "postai", - "version": "1.2.6", + "version": "1.3.0-beta.1", "description": "PostAI - Advanced API Testing Tool with AI Integration", "main": "dist-electron/main.js", "author": "PostAI Team", From 2a76f17a3d1db42050788330af5b595b98801185 Mon Sep 17 00:00:00 2001 From: Grigori Kartashyan Date: Thu, 29 Jan 2026 17:32:29 +0100 Subject: [PATCH 3/8] Add licensing_app to PyInstaller spec Co-Authored-By: Claude Opus 4.5 --- backend/postai_server.spec | 1 + 1 file changed, 1 insertion(+) 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', ] From 52bb0f067fdf4774b8726f038fada808aca2d4c2 Mon Sep 17 00:00:00 2001 From: Grigori Kartashyan Date: Thu, 29 Jan 2026 17:44:45 +0100 Subject: [PATCH 4/8] Add row signature to prevent license DB tampering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HMAC-SHA256 signature is computed over trial_started_at, license_key, and activated_at fields. Verified on every status check — if any field is edited directly in SQLite, the app treats it as expired. Co-Authored-By: Claude Opus 4.5 --- .../migrations/0002_license_row_signature.py | 18 ++++++++++ .../0003_alter_license_trial_started_at.py | 18 ++++++++++ backend/licensing_app/models.py | 11 ++++++- backend/licensing_app/services.py | 28 ++++++++++++++++ backend/licensing_app/tests.py | 33 +++++++++++++++++++ backend/licensing_app/views.py | 14 +++++++- 6 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 backend/licensing_app/migrations/0002_license_row_signature.py create mode 100644 backend/licensing_app/migrations/0003_alter_license_trial_started_at.py 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/models.py b/backend/licensing_app/models.py index 83601e6..c826968 100644 --- a/backend/licensing_app/models.py +++ b/backend/licensing_app/models.py @@ -3,9 +3,10 @@ class License(BaseModel): - trial_started_at = models.DateTimeField(auto_now_add=True) + 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'] @@ -15,6 +16,14 @@ def __str__(self): 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() diff --git a/backend/licensing_app/services.py b/backend/licensing_app/services.py index 76e4e97..45e5dec 100644 --- a/backend/licensing_app/services.py +++ b/backend/licensing_app/services.py @@ -3,6 +3,7 @@ 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})$') @@ -28,3 +29,30 @@ def generate_license_key() -> str: 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 index e32138c..95e5ad6 100644 --- a/backend/licensing_app/tests.py +++ b/backend/licensing_app/tests.py @@ -65,3 +65,36 @@ def test_activate_valid_key(self): 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/views.py b/backend/licensing_app/views.py index 11a7a4b..ed4e85d 100644 --- a/backend/licensing_app/views.py +++ b/backend/licensing_app/views.py @@ -5,7 +5,7 @@ from .models import License from .serializers import LicenseActivateSerializer -from .services import validate_license_key +from .services import validate_license_key, verify_license TRIAL_DAYS = 30 @@ -13,6 +13,18 @@ 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: From c980070d5193293d257aa793c670878fb4f95b3d Mon Sep 17 00:00:00 2001 From: Grigori Kartashyan Date: Thu, 29 Jan 2026 17:47:28 +0100 Subject: [PATCH 5/8] Add generate_license_key management command Usage: python manage.py generate_license_key [-n COUNT] Co-Authored-By: Claude Opus 4.5 --- backend/licensing_app/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/generate_license_key.py | 19 +++++++++++++++++++ 3 files changed, 19 insertions(+) create mode 100644 backend/licensing_app/management/__init__.py create mode 100644 backend/licensing_app/management/commands/__init__.py create mode 100644 backend/licensing_app/management/commands/generate_license_key.py 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()) From 9c17ddf60b1abd88e499456f0648a054e4edc93f Mon Sep 17 00:00:00 2001 From: Grigori Kartashyan Date: Thu, 29 Jan 2026 22:17:53 +0100 Subject: [PATCH 6/8] Show group letter badge (A, B, C...) on linked environment variables Adds a visual letter indicator next to the link icon so users can quickly identify which variables belong to the same linked group. Co-Authored-By: Claude Opus 4.5 --- .../environments/EnvironmentManager.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/environments/EnvironmentManager.tsx b/src/components/environments/EnvironmentManager.tsx index 58fde3f..04a744a 100644 --- a/src/components/environments/EnvironmentManager.tsx +++ b/src/components/environments/EnvironmentManager.tsx @@ -465,6 +465,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) @@ -553,7 +561,7 @@ function VariableRow({ + {(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) @@ -484,9 +724,15 @@ function VariableRow({ } return ( - <> +
+ {/* 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 */}