Skip to content

Commit ffe2055

Browse files
simonhampclaude
andcommitted
Add license key rotation and hide licenses section for users without licenses
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 02e9b89 commit ffe2055

File tree

7 files changed

+362
-13
lines changed

7 files changed

+362
-13
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace App\Actions\Licenses;
4+
5+
use App\Models\License;
6+
use App\Services\Anystack\Anystack;
7+
8+
class RotateLicenseKey
9+
{
10+
/**
11+
* Rotate a license key by creating a new Anystack license
12+
* and suspending the old one.
13+
*/
14+
public function handle(License $license): License
15+
{
16+
$newLicenseData = $this->createNewAnystackLicense($license);
17+
18+
$oldAnystackId = $license->anystack_id;
19+
20+
$license->update([
21+
'anystack_id' => $newLicenseData['id'],
22+
'key' => $newLicenseData['key'],
23+
]);
24+
25+
$this->suspendOldAnystackLicense($license, $oldAnystackId);
26+
27+
return $license;
28+
}
29+
30+
private function createNewAnystackLicense(License $license): array
31+
{
32+
return Anystack::api()
33+
->licenses($license->anystack_product_id)
34+
->create([
35+
'policy_id' => $license->subscriptionType->anystackPolicyId(),
36+
'contact_id' => $license->user->anystack_contact_id,
37+
])
38+
->json('data');
39+
}
40+
41+
private function suspendOldAnystackLicense(License $license, string $oldAnystackId): void
42+
{
43+
Anystack::api()
44+
->license($oldAnystackId, $license->anystack_product_id)
45+
->suspend();
46+
}
47+
}

app/Livewire/Customer/Licenses/Show.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Livewire\Customer\Licenses;
44

5+
use App\Actions\Licenses\RotateLicenseKey;
56
use App\Models\License;
67
use Livewire\Attributes\Layout;
78
use Livewire\Attributes\Title;
@@ -43,6 +44,21 @@ public function updateLicenseName(): void
4344
session()->flash('success', 'License name updated successfully!');
4445
}
4546

47+
public function rotateLicenseKey(RotateLicenseKey $action): void
48+
{
49+
if ($this->license->is_suspended || ($this->license->expires_at && $this->license->expires_at->isPast())) {
50+
session()->flash('error', 'Cannot rotate a suspended or expired license key.');
51+
52+
return;
53+
}
54+
55+
$action->handle($this->license);
56+
57+
session()->flash('success', 'Your license key has been rotated. Please update any applications using the old key.');
58+
59+
$this->redirectRoute('customer.licenses.show', $this->license->key);
60+
}
61+
4662
public function render()
4763
{
4864
return view('livewire.customer.licenses.show');

resources/views/components/layouts/dashboard.blade.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,11 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text
9797
Dashboard
9898
</flux:sidebar.item>
9999

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

104106
@feature(App\Features\ShowPlugins::class)
105107
<flux:sidebar.item icon="shopping-bag" href="{{ route('customer.purchased-plugins.index') }}" :current="request()->routeIs('customer.purchased-plugins.*')">

resources/views/livewire/customer/dashboard.blade.php

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,16 @@
6161
{{-- Dashboard Cards --}}
6262
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
6363
{{-- Licenses Card --}}
64-
<x-dashboard-card
65-
title="Licenses"
66-
:count="$this->licenseCount"
67-
icon="key"
68-
color="blue"
69-
:href="route('customer.licenses.list')"
70-
link-text="View licenses"
71-
/>
64+
@if($this->licenseCount > 0)
65+
<x-dashboard-card
66+
title="Licenses"
67+
:count="$this->licenseCount"
68+
icon="key"
69+
color="blue"
70+
:href="route('customer.licenses.list')"
71+
link-text="View licenses"
72+
/>
73+
@endif
7274

7375
{{-- EAP Status Card --}}
7476
<x-dashboard-card

resources/views/livewire/customer/licenses/show.blade.php

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
</flux:callout>
1717
@endif
1818

19+
@if(session('error'))
20+
<flux:callout variant="danger" icon="exclamation-circle" class="mb-6">
21+
<flux:callout.text>{{ session('error') }}</flux:callout.text>
22+
</flux:callout>
23+
@endif
24+
1925
{{-- License Information Card --}}
2026
<flux:card class="mb-6">
2127
<div class="flex items-center justify-between mb-4">
@@ -34,7 +40,14 @@
3440
<flux:table.row>
3541
<flux:table.cell class="font-medium text-zinc-500 dark:text-zinc-400">License Key</flux:table.cell>
3642
<flux:table.cell>
37-
<x-customer.masked-key :key-value="$license->key" />
43+
<div class="flex items-center justify-between">
44+
<x-customer.masked-key :key-value="$license->key" />
45+
@if(! $license->is_suspended && ! ($license->expires_at && $license->expires_at->isPast()))
46+
<flux:modal.trigger name="rotate-license-key">
47+
<flux:button size="sm" icon="arrow-path" tooltip="Rotate key" />
48+
</flux:modal.trigger>
49+
@endif
50+
</div>
3851
</flux:table.cell>
3952
</flux:table.row>
4053

@@ -150,4 +163,35 @@
150163
</div>
151164
</form>
152165
</flux:modal>
166+
167+
{{-- Rotate License Key Confirmation Modal --}}
168+
<flux:modal name="rotate-license-key" class="md:w-96">
169+
<div class="space-y-6">
170+
<div>
171+
<flux:heading size="lg">Rotate License Key</flux:heading>
172+
<flux:text class="mt-2">
173+
Are you sure you want to rotate this license key? This action cannot be undone.
174+
</flux:text>
175+
</div>
176+
177+
<flux:callout variant="warning" icon="exclamation-triangle">
178+
<flux:callout.heading>After rotating your key, you will need to:</flux:callout.heading>
179+
<flux:callout.text>
180+
<ul class="mt-1 list-disc pl-5 text-sm">
181+
<li>Update the license key in all your NativePHP applications</li>
182+
<li>Update any CI/CD pipelines or deployment scripts</li>
183+
<li>Notify any team members using this key</li>
184+
</ul>
185+
</flux:callout.text>
186+
</flux:callout>
187+
188+
<div class="flex gap-2">
189+
<flux:spacer />
190+
<flux:modal.close>
191+
<flux:button variant="ghost">Cancel</flux:button>
192+
</flux:modal.close>
193+
<flux:button variant="danger" wire:click="rotateLicenseKey">Rotate Key</flux:button>
194+
</div>
195+
</div>
196+
</flux:modal>
153197
</div>
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
namespace Tests\Feature\Actions\Licenses;
4+
5+
use App\Actions\Licenses\RotateLicenseKey;
6+
use App\Models\License;
7+
use App\Models\User;
8+
use Illuminate\Foundation\Testing\RefreshDatabase;
9+
use Illuminate\Http\Client\RequestException;
10+
use Illuminate\Support\Facades\Http;
11+
use PHPUnit\Framework\Attributes\Test;
12+
use Tests\TestCase;
13+
14+
class RotateLicenseKeyTest extends TestCase
15+
{
16+
use RefreshDatabase;
17+
18+
#[Test]
19+
public function it_rotates_a_license_key(): void
20+
{
21+
$newAnystackId = 'new-anystack-id';
22+
$newKey = 'new-license-key-uuid';
23+
24+
Http::fake([
25+
'https://api.anystack.sh/v1/products/*/licenses' => Http::response([
26+
'data' => [
27+
'id' => $newAnystackId,
28+
'key' => $newKey,
29+
'expires_at' => now()->addYear()->toIso8601String(),
30+
'created_at' => now()->toIso8601String(),
31+
'updated_at' => now()->toIso8601String(),
32+
],
33+
], 201),
34+
'https://api.anystack.sh/v1/products/*/licenses/*' => Http::response([
35+
'data' => [
36+
'suspended' => true,
37+
],
38+
], 200),
39+
]);
40+
41+
$user = User::factory()->create(['anystack_contact_id' => 'contact-123']);
42+
$license = License::factory()->active()->create([
43+
'user_id' => $user->id,
44+
'anystack_id' => 'old-anystack-id',
45+
'key' => 'old-license-key',
46+
'policy_name' => 'mini',
47+
]);
48+
49+
$action = resolve(RotateLicenseKey::class);
50+
$result = $action->handle($license);
51+
52+
$license->refresh();
53+
$this->assertEquals($newAnystackId, $license->anystack_id);
54+
$this->assertEquals($newKey, $license->key);
55+
56+
Http::assertSent(function ($request) {
57+
return $request->method() === 'POST' &&
58+
str_contains($request->url(), '/products/') &&
59+
str_contains($request->url(), '/licenses');
60+
});
61+
62+
Http::assertSent(function ($request) {
63+
return $request->method() === 'PATCH' &&
64+
str_contains($request->url(), '/licenses/old-anystack-id') &&
65+
$request->data() === ['suspended' => true];
66+
});
67+
}
68+
69+
#[Test]
70+
public function it_preserves_other_license_attributes_when_rotating(): void
71+
{
72+
Http::fake([
73+
'https://api.anystack.sh/v1/products/*/licenses' => Http::response([
74+
'data' => [
75+
'id' => 'new-anystack-id',
76+
'key' => 'new-key',
77+
'expires_at' => now()->addYear()->toIso8601String(),
78+
'created_at' => now()->toIso8601String(),
79+
'updated_at' => now()->toIso8601String(),
80+
],
81+
], 201),
82+
'https://api.anystack.sh/v1/products/*/licenses/*' => Http::response([], 200),
83+
]);
84+
85+
$user = User::factory()->create(['anystack_contact_id' => 'contact-123']);
86+
$license = License::factory()->active()->create([
87+
'user_id' => $user->id,
88+
'policy_name' => 'pro',
89+
'name' => 'My Production License',
90+
]);
91+
92+
$originalSubscriptionItemId = $license->subscription_item_id;
93+
94+
$action = resolve(RotateLicenseKey::class);
95+
$action->handle($license);
96+
97+
$license->refresh();
98+
$this->assertEquals($user->id, $license->user_id);
99+
$this->assertEquals('pro', $license->policy_name);
100+
$this->assertEquals('My Production License', $license->name);
101+
$this->assertEquals($originalSubscriptionItemId, $license->subscription_item_id);
102+
$this->assertFalse($license->is_suspended);
103+
}
104+
105+
#[Test]
106+
public function it_fails_when_create_api_call_fails(): void
107+
{
108+
Http::fake([
109+
'https://api.anystack.sh/v1/products/*/licenses' => Http::response([], 500),
110+
]);
111+
112+
$user = User::factory()->create(['anystack_contact_id' => 'contact-123']);
113+
$license = License::factory()->active()->create([
114+
'user_id' => $user->id,
115+
'anystack_id' => 'original-id',
116+
'key' => 'original-key',
117+
'policy_name' => 'mini',
118+
]);
119+
120+
$this->expectException(RequestException::class);
121+
122+
$action = resolve(RotateLicenseKey::class);
123+
$action->handle($license);
124+
125+
$license->refresh();
126+
$this->assertEquals('original-id', $license->anystack_id);
127+
$this->assertEquals('original-key', $license->key);
128+
}
129+
}

0 commit comments

Comments
 (0)