diff --git a/app/Actions/Licenses/RotateLicenseKey.php b/app/Actions/Licenses/RotateLicenseKey.php new file mode 100644 index 00000000..b7e9584b --- /dev/null +++ b/app/Actions/Licenses/RotateLicenseKey.php @@ -0,0 +1,47 @@ +createNewAnystackLicense($license); + + $oldAnystackId = $license->anystack_id; + + $license->update([ + 'anystack_id' => $newLicenseData['id'], + 'key' => $newLicenseData['key'], + ]); + + $this->suspendOldAnystackLicense($license, $oldAnystackId); + + return $license; + } + + private function createNewAnystackLicense(License $license): array + { + return Anystack::api() + ->licenses($license->anystack_product_id) + ->create([ + 'policy_id' => $license->subscriptionType->anystackPolicyId(), + 'contact_id' => $license->user->anystack_contact_id, + ]) + ->json('data'); + } + + private function suspendOldAnystackLicense(License $license, string $oldAnystackId): void + { + Anystack::api() + ->license($oldAnystackId, $license->anystack_product_id) + ->suspend(); + } +} diff --git a/app/Livewire/Customer/Licenses/Show.php b/app/Livewire/Customer/Licenses/Show.php index 8ffc494b..466085b8 100644 --- a/app/Livewire/Customer/Licenses/Show.php +++ b/app/Livewire/Customer/Licenses/Show.php @@ -2,6 +2,7 @@ namespace App\Livewire\Customer\Licenses; +use App\Actions\Licenses\RotateLicenseKey; use App\Models\License; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -43,6 +44,21 @@ public function updateLicenseName(): void session()->flash('success', 'License name updated successfully!'); } + public function rotateLicenseKey(RotateLicenseKey $action): void + { + if ($this->license->is_suspended || ($this->license->expires_at && $this->license->expires_at->isPast())) { + session()->flash('error', 'Cannot rotate a suspended or expired license key.'); + + return; + } + + $action->handle($this->license); + + session()->flash('success', 'Your license key has been rotated. Please update any applications using the old key.'); + + $this->redirectRoute('customer.licenses.show', $this->license->key); + } + public function render() { return view('livewire.customer.licenses.show'); diff --git a/resources/views/components/layouts/dashboard.blade.php b/resources/views/components/layouts/dashboard.blade.php index ee150069..12c7fd5b 100644 --- a/resources/views/components/layouts/dashboard.blade.php +++ b/resources/views/components/layouts/dashboard.blade.php @@ -97,9 +97,11 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text Dashboard - - Licenses - + @if(auth()->user()->licenses()->exists()) + + Licenses + + @endif @feature(App\Features\ShowPlugins::class) diff --git a/resources/views/livewire/customer/dashboard.blade.php b/resources/views/livewire/customer/dashboard.blade.php index 6928deba..8aa292c7 100644 --- a/resources/views/livewire/customer/dashboard.blade.php +++ b/resources/views/livewire/customer/dashboard.blade.php @@ -61,14 +61,16 @@ {{-- Dashboard Cards --}}
{{-- 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'); } }