Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions app/Actions/Licenses/RotateLicenseKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace App\Actions\Licenses;

use App\Models\License;
use App\Services\Anystack\Anystack;

class RotateLicenseKey
{
/**
* Rotate a license key by creating a new Anystack license
* and suspending the old one.
*/
public function handle(License $license): License
{
$newLicenseData = $this->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();
}
}
16 changes: 16 additions & 0 deletions app/Livewire/Customer/Licenses/Show.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
8 changes: 5 additions & 3 deletions resources/views/components/layouts/dashboard.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text
Dashboard
</flux:sidebar.item>

<flux:sidebar.item icon="key" href="{{ route('customer.licenses.list') }}" :current="request()->routeIs('customer.licenses.*')">
Licenses
</flux:sidebar.item>
@if(auth()->user()->licenses()->exists())
<flux:sidebar.item icon="key" href="{{ route('customer.licenses.list') }}" :current="request()->routeIs('customer.licenses.*')">
Licenses
</flux:sidebar.item>
@endif

@feature(App\Features\ShowPlugins::class)
<flux:sidebar.item icon="shopping-bag" href="{{ route('customer.purchased-plugins.index') }}" :current="request()->routeIs('customer.purchased-plugins.*')">
Expand Down
18 changes: 10 additions & 8 deletions resources/views/livewire/customer/dashboard.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,16 @@
{{-- Dashboard Cards --}}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{{-- Licenses Card --}}
<x-dashboard-card
title="Licenses"
:count="$this->licenseCount"
icon="key"
color="blue"
:href="route('customer.licenses.list')"
link-text="View licenses"
/>
@if($this->licenseCount > 0)
<x-dashboard-card
title="Licenses"
:count="$this->licenseCount"
icon="key"
color="blue"
:href="route('customer.licenses.list')"
link-text="View licenses"
/>
@endif

{{-- EAP Status Card --}}
<x-dashboard-card
Expand Down
46 changes: 45 additions & 1 deletion resources/views/livewire/customer/licenses/show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
</flux:callout>
@endif

@if(session('error'))
<flux:callout variant="danger" icon="exclamation-circle" class="mb-6">
<flux:callout.text>{{ session('error') }}</flux:callout.text>
</flux:callout>
@endif

{{-- License Information Card --}}
<flux:card class="mb-6">
<div class="flex items-center justify-between mb-4">
Expand All @@ -34,7 +40,14 @@
<flux:table.row>
<flux:table.cell class="font-medium text-zinc-500 dark:text-zinc-400">License Key</flux:table.cell>
<flux:table.cell>
<x-customer.masked-key :key-value="$license->key" />
<div class="flex items-center justify-between">
<x-customer.masked-key :key-value="$license->key" />
@if(! $license->is_suspended && ! ($license->expires_at && $license->expires_at->isPast()))
<flux:modal.trigger name="rotate-license-key">
<flux:button size="sm" icon="arrow-path" tooltip="Rotate key" />
</flux:modal.trigger>
@endif
</div>
</flux:table.cell>
</flux:table.row>

Expand Down Expand Up @@ -150,4 +163,35 @@
</div>
</form>
</flux:modal>

{{-- Rotate License Key Confirmation Modal --}}
<flux:modal name="rotate-license-key" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Rotate License Key</flux:heading>
<flux:text class="mt-2">
Are you sure you want to rotate this license key? This action cannot be undone.
</flux:text>
</div>

<flux:callout variant="warning" icon="exclamation-triangle">
<flux:callout.heading>After rotating your key, you will need to:</flux:callout.heading>
<flux:callout.text>
<ul class="mt-1 list-disc pl-5 text-sm">
<li>Update the license key in all your NativePHP applications</li>
<li>Update any CI/CD pipelines or deployment scripts</li>
<li>Notify any team members using this key</li>
</ul>
</flux:callout.text>
</flux:callout>

<div class="flex gap-2">
<flux:spacer />
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button variant="danger" wire:click="rotateLicenseKey">Rotate Key</flux:button>
</div>
</div>
</flux:modal>
</div>
129 changes: 129 additions & 0 deletions tests/Feature/Actions/Licenses/RotateLicenseKeyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

namespace Tests\Feature\Actions\Licenses;

use App\Actions\Licenses\RotateLicenseKey;
use App\Models\License;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class RotateLicenseKeyTest extends TestCase
{
use RefreshDatabase;

#[Test]
public function it_rotates_a_license_key(): void
{
$newAnystackId = 'new-anystack-id';
$newKey = 'new-license-key-uuid';

Http::fake([
'https://api.anystack.sh/v1/products/*/licenses' => 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);
}
}
Loading
Loading