{{-- Licenses Card --}}
-
+ @if($this->licenseCount > 0)
+
+ @endif
{{-- EAP Status Card --}}
@endif
+ @if(session('error'))
+
+ {{ session('error') }}
+
+ @endif
+
{{-- License Information Card --}}
@@ -34,7 +40,14 @@
License Key
-
+
+
+ @if(! $license->is_suspended && ! ($license->expires_at && $license->expires_at->isPast()))
+
+
+
+ @endif
+
@@ -150,4 +163,35 @@
+
+ {{-- Rotate License Key Confirmation Modal --}}
+
+
+
+ Rotate License Key
+
+ Are you sure you want to rotate this license key? This action cannot be undone.
+
+
+
+
+ After rotating your key, you will need to:
+
+
+ - Update the license key in all your NativePHP applications
+ - Update any CI/CD pipelines or deployment scripts
+ - Notify any team members using this key
+
+
+
+
+
+
+
+ Cancel
+
+ Rotate Key
+
+
+
diff --git a/tests/Feature/Actions/Licenses/RotateLicenseKeyTest.php b/tests/Feature/Actions/Licenses/RotateLicenseKeyTest.php
new file mode 100644
index 00000000..be4f4276
--- /dev/null
+++ b/tests/Feature/Actions/Licenses/RotateLicenseKeyTest.php
@@ -0,0 +1,129 @@
+ Http::response([
+ 'data' => [
+ 'id' => $newAnystackId,
+ 'key' => $newKey,
+ 'expires_at' => now()->addYear()->toIso8601String(),
+ 'created_at' => now()->toIso8601String(),
+ 'updated_at' => now()->toIso8601String(),
+ ],
+ ], 201),
+ 'https://api.anystack.sh/v1/products/*/licenses/*' => Http::response([
+ 'data' => [
+ 'suspended' => true,
+ ],
+ ], 200),
+ ]);
+
+ $user = User::factory()->create(['anystack_contact_id' => 'contact-123']);
+ $license = License::factory()->active()->create([
+ 'user_id' => $user->id,
+ 'anystack_id' => 'old-anystack-id',
+ 'key' => 'old-license-key',
+ 'policy_name' => 'mini',
+ ]);
+
+ $action = resolve(RotateLicenseKey::class);
+ $result = $action->handle($license);
+
+ $license->refresh();
+ $this->assertEquals($newAnystackId, $license->anystack_id);
+ $this->assertEquals($newKey, $license->key);
+
+ Http::assertSent(function ($request) {
+ return $request->method() === 'POST' &&
+ str_contains($request->url(), '/products/') &&
+ str_contains($request->url(), '/licenses');
+ });
+
+ Http::assertSent(function ($request) {
+ return $request->method() === 'PATCH' &&
+ str_contains($request->url(), '/licenses/old-anystack-id') &&
+ $request->data() === ['suspended' => true];
+ });
+ }
+
+ #[Test]
+ public function it_preserves_other_license_attributes_when_rotating(): void
+ {
+ Http::fake([
+ 'https://api.anystack.sh/v1/products/*/licenses' => Http::response([
+ 'data' => [
+ 'id' => 'new-anystack-id',
+ 'key' => 'new-key',
+ 'expires_at' => now()->addYear()->toIso8601String(),
+ 'created_at' => now()->toIso8601String(),
+ 'updated_at' => now()->toIso8601String(),
+ ],
+ ], 201),
+ 'https://api.anystack.sh/v1/products/*/licenses/*' => Http::response([], 200),
+ ]);
+
+ $user = User::factory()->create(['anystack_contact_id' => 'contact-123']);
+ $license = License::factory()->active()->create([
+ 'user_id' => $user->id,
+ 'policy_name' => 'pro',
+ 'name' => 'My Production License',
+ ]);
+
+ $originalSubscriptionItemId = $license->subscription_item_id;
+
+ $action = resolve(RotateLicenseKey::class);
+ $action->handle($license);
+
+ $license->refresh();
+ $this->assertEquals($user->id, $license->user_id);
+ $this->assertEquals('pro', $license->policy_name);
+ $this->assertEquals('My Production License', $license->name);
+ $this->assertEquals($originalSubscriptionItemId, $license->subscription_item_id);
+ $this->assertFalse($license->is_suspended);
+ }
+
+ #[Test]
+ public function it_fails_when_create_api_call_fails(): void
+ {
+ Http::fake([
+ 'https://api.anystack.sh/v1/products/*/licenses' => Http::response([], 500),
+ ]);
+
+ $user = User::factory()->create(['anystack_contact_id' => 'contact-123']);
+ $license = License::factory()->active()->create([
+ 'user_id' => $user->id,
+ 'anystack_id' => 'original-id',
+ 'key' => 'original-key',
+ 'policy_name' => 'mini',
+ ]);
+
+ $this->expectException(RequestException::class);
+
+ $action = resolve(RotateLicenseKey::class);
+ $action->handle($license);
+
+ $license->refresh();
+ $this->assertEquals('original-id', $license->anystack_id);
+ $this->assertEquals('original-key', $license->key);
+ }
+}
diff --git a/tests/Feature/CustomerLicenseManagementTest.php b/tests/Feature/CustomerLicenseManagementTest.php
index 146702d2..76756db8 100644
--- a/tests/Feature/CustomerLicenseManagementTest.php
+++ b/tests/Feature/CustomerLicenseManagementTest.php
@@ -3,10 +3,13 @@
namespace Tests\Feature;
use App\Features\ShowAuthButtons;
+use App\Livewire\Customer\Licenses\Show;
use App\Models\License;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Http;
use Laravel\Pennant\Feature;
+use Livewire\Livewire;
use Tests\TestCase;
class CustomerLicenseManagementTest extends TestCase
@@ -298,6 +301,112 @@ public function test_dashboard_shows_license_count(): void
$response = $this->actingAs($user)->get('/dashboard');
$response->assertStatus(200);
- $response->assertSee('Licenses');
+ $response->assertSee('View licenses');
+ }
+
+ public function test_dashboard_hides_licenses_card_when_user_has_no_licenses(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->get('/dashboard');
+
+ $response->assertStatus(200);
+ $response->assertDontSee('View licenses');
+ }
+
+ public function test_customer_can_rotate_license_key(): void
+ {
+ Http::fake([
+ 'https://api.anystack.sh/v1/products/*/licenses' => Http::response([
+ 'data' => [
+ 'id' => 'new-anystack-id',
+ 'key' => 'new-rotated-key',
+ 'expires_at' => now()->addYear()->toIso8601String(),
+ 'created_at' => now()->toIso8601String(),
+ 'updated_at' => now()->toIso8601String(),
+ ],
+ ], 201),
+ 'https://api.anystack.sh/v1/products/*/licenses/*' => Http::response([
+ 'data' => ['suspended' => true],
+ ], 200),
+ ]);
+
+ $user = User::factory()->create(['anystack_contact_id' => 'contact-123']);
+ $license = License::factory()->active()->create([
+ 'user_id' => $user->id,
+ 'policy_name' => 'mini',
+ 'key' => 'old-key-to-rotate',
+ 'anystack_id' => 'old-anystack-id',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(Show::class, ['licenseKey' => 'old-key-to-rotate'])
+ ->call('rotateLicenseKey')
+ ->assertRedirect(route('customer.licenses.show', 'new-rotated-key'));
+
+ $license->refresh();
+ $this->assertEquals('new-rotated-key', $license->key);
+ $this->assertEquals('new-anystack-id', $license->anystack_id);
+ $this->assertFalse($license->is_suspended);
+ }
+
+ public function test_customer_cannot_rotate_suspended_license_key(): void
+ {
+ $user = User::factory()->create();
+ $license = License::factory()->suspended()->create([
+ 'user_id' => $user->id,
+ 'key' => 'suspended-license-key',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(Show::class, ['licenseKey' => 'suspended-license-key'])
+ ->call('rotateLicenseKey')
+ ->assertNoRedirect();
+
+ Http::assertNothingSent();
+ }
+
+ public function test_customer_cannot_rotate_expired_license_key(): void
+ {
+ $user = User::factory()->create();
+ $license = License::factory()->expired()->create([
+ 'user_id' => $user->id,
+ 'key' => 'expired-license-key',
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(Show::class, ['licenseKey' => 'expired-license-key'])
+ ->call('rotateLicenseKey')
+ ->assertNoRedirect();
+
+ Http::assertNothingSent();
+ }
+
+ public function test_active_license_shows_rotate_button(): void
+ {
+ $user = User::factory()->create();
+ $license = License::factory()->active()->create([
+ 'user_id' => $user->id,
+ 'key' => 'active-license-key',
+ ]);
+
+ $response = $this->actingAs($user)->get('/dashboard/licenses/active-license-key');
+
+ $response->assertStatus(200);
+ $response->assertSee('Rotate key');
+ }
+
+ public function test_suspended_license_does_not_show_rotate_button(): void
+ {
+ $user = User::factory()->create();
+ $license = License::factory()->suspended()->create([
+ 'user_id' => $user->id,
+ 'key' => 'suspended-license-key',
+ ]);
+
+ $response = $this->actingAs($user)->get('/dashboard/licenses/suspended-license-key');
+
+ $response->assertStatus(200);
+ $response->assertDontSee('Rotate key');
}
}