Skip to content

Commit e843cd4

Browse files
committed
feat: implement an api endpoint to create a new license
1 parent f37d53b commit e843cd4

File tree

24 files changed

+1331
-763
lines changed

24 files changed

+1331
-763
lines changed

app/Enums/LicenseSource.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace App\Enums;
4+
5+
enum LicenseSource: string
6+
{
7+
case Stripe = 'stripe';
8+
case Bifrost = 'bifrost';
9+
case Manual = 'manual';
10+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
namespace App\Filament\Resources;
4+
5+
use App\Filament\Resources\PersonalAccessTokenResource\Pages;
6+
use Filament\Forms;
7+
use Filament\Forms\Form;
8+
use Filament\Resources\Resource;
9+
use Filament\Tables;
10+
use Filament\Tables\Table;
11+
use Laravel\Sanctum\PersonalAccessToken;
12+
13+
class PersonalAccessTokenResource extends Resource
14+
{
15+
protected static ?string $model = PersonalAccessToken::class;
16+
17+
protected static ?string $navigationIcon = 'heroicon-o-key';
18+
19+
protected static ?string $navigationLabel = 'API Keys';
20+
21+
protected static ?string $modelLabel = 'API Key';
22+
23+
protected static ?string $pluralModelLabel = 'API Keys';
24+
25+
public static function form(Form $form): Form
26+
{
27+
return $form
28+
->schema([
29+
Forms\Components\TextInput::make('name')
30+
->required()
31+
->maxLength(255)
32+
->helperText('A descriptive name for this API key'),
33+
34+
Forms\Components\Select::make('tokenable_id')
35+
->label('User')
36+
->options(\App\Models\User::pluck('name', 'id'))
37+
->required()
38+
->searchable(),
39+
40+
Forms\Components\Hidden::make('tokenable_type')
41+
->default(\App\Models\User::class),
42+
43+
Forms\Components\TextInput::make('abilities')
44+
->default('*')
45+
->helperText('Comma-separated list of abilities. Use * for all abilities.'),
46+
47+
Forms\Components\DateTimePicker::make('expires_at')
48+
->label('Expires At')
49+
->nullable()
50+
->helperText('Leave empty for no expiration'),
51+
]);
52+
}
53+
54+
public static function table(Table $table): Table
55+
{
56+
return $table
57+
->columns([
58+
Tables\Columns\TextColumn::make('name')
59+
->searchable()
60+
->sortable(),
61+
62+
Tables\Columns\TextColumn::make('tokenable.name')
63+
->label('User')
64+
->searchable()
65+
->sortable(),
66+
67+
Tables\Columns\TextColumn::make('abilities')
68+
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : $state),
69+
70+
Tables\Columns\TextColumn::make('last_used_at')
71+
->dateTime()
72+
->sortable()
73+
->placeholder('Never'),
74+
75+
Tables\Columns\TextColumn::make('expires_at')
76+
->dateTime()
77+
->sortable()
78+
->placeholder('Never'),
79+
80+
Tables\Columns\TextColumn::make('created_at')
81+
->dateTime()
82+
->sortable()
83+
->toggleable(isToggledHiddenByDefault: true),
84+
])
85+
->filters([
86+
//
87+
])
88+
->actions([
89+
Tables\Actions\DeleteAction::make(),
90+
])
91+
->bulkActions([
92+
Tables\Actions\DeleteBulkAction::make(),
93+
])
94+
->defaultSort('created_at', 'desc');
95+
}
96+
97+
public static function getRelations(): array
98+
{
99+
return [
100+
//
101+
];
102+
}
103+
104+
public static function getPages(): array
105+
{
106+
return [
107+
'index' => Pages\ListPersonalAccessTokens::route('/'),
108+
'create' => Pages\CreatePersonalAccessToken::route('/create'),
109+
];
110+
}
111+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\PersonalAccessTokenResource\Pages;
4+
5+
use App\Filament\Resources\PersonalAccessTokenResource;
6+
use App\Models\User;
7+
use Filament\Notifications\Notification;
8+
use Filament\Resources\Pages\CreateRecord;
9+
use Illuminate\Database\Eloquent\Model;
10+
11+
class CreatePersonalAccessToken extends CreateRecord
12+
{
13+
protected static string $resource = PersonalAccessTokenResource::class;
14+
15+
protected function handleRecordCreation(array $data): Model
16+
{
17+
// We need to handle token creation specially since we can't store plain text tokens
18+
/** @var User $user */
19+
$user = User::find($data['tokenable_id']);
20+
21+
// Parse abilities - handle both string and array input
22+
$abilities = $data['abilities'] ?? '*';
23+
if (is_string($abilities)) {
24+
$abilities = $abilities === '*' ? ['*'] : explode(',', $abilities);
25+
$abilities = array_map('trim', $abilities);
26+
}
27+
28+
$token = $user->createToken(
29+
name: $data['name'],
30+
abilities: $abilities,
31+
expiresAt: $data['expires_at'] ?? null
32+
);
33+
34+
// Store the plain text token to show to user
35+
session(['new_api_token' => $token->plainTextToken]);
36+
37+
// Return the token model
38+
return $token->accessToken;
39+
}
40+
41+
protected function afterCreate(): void
42+
{
43+
$token = session('new_api_token');
44+
45+
if ($token) {
46+
Notification::make()
47+
->title('API Key Created Successfully')
48+
->body("Your API key: {$token}")
49+
->success()
50+
->persistent()
51+
->send();
52+
53+
session()->forget('new_api_token');
54+
}
55+
}
56+
57+
protected function getRedirectUrl(): string
58+
{
59+
return $this->getResource()::getUrl('index');
60+
}
61+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\PersonalAccessTokenResource\Pages;
4+
5+
use App\Filament\Resources\PersonalAccessTokenResource;
6+
use Filament\Actions;
7+
use Filament\Resources\Pages\EditRecord;
8+
9+
class EditPersonalAccessToken extends EditRecord
10+
{
11+
protected static string $resource = PersonalAccessTokenResource::class;
12+
13+
protected function getHeaderActions(): array
14+
{
15+
return [
16+
Actions\DeleteAction::make(),
17+
];
18+
}
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace App\Filament\Resources\PersonalAccessTokenResource\Pages;
4+
5+
use App\Filament\Resources\PersonalAccessTokenResource;
6+
use Filament\Actions;
7+
use Filament\Resources\Pages\ListRecords;
8+
9+
class ListPersonalAccessTokens extends ListRecords
10+
{
11+
protected static string $resource = PersonalAccessTokenResource::class;
12+
13+
protected function getHeaderActions(): array
14+
{
15+
return [
16+
Actions\CreateAction::make(),
17+
];
18+
}
19+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api;
4+
5+
use App\Enums\LicenseSource;
6+
use App\Enums\Subscription;
7+
use App\Http\Controllers\Controller;
8+
use App\Jobs\CreateAnystackLicenseJob;
9+
use App\Models\License;
10+
use App\Models\User;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\Hash;
13+
use Illuminate\Support\Str;
14+
use Illuminate\Validation\Rules\Enum;
15+
16+
class LicenseController extends Controller
17+
{
18+
public function store(Request $request)
19+
{
20+
$validated = $request->validate([
21+
'email' => 'required|email',
22+
'name' => 'required|string|max:255',
23+
'subscription' => ['required', new Enum(Subscription::class)],
24+
]);
25+
26+
// Find or create user
27+
$user = User::firstOrCreate(
28+
['email' => $validated['email']],
29+
[
30+
'name' => $validated['name'],
31+
'password' => Hash::make(Str::random(32)), // Random password
32+
]
33+
);
34+
35+
// Create the license via job
36+
$subscription = Subscription::from($validated['subscription']);
37+
38+
CreateAnystackLicenseJob::dispatchSync(
39+
user: $user,
40+
subscription: $subscription,
41+
subscriptionItemId: null, // No subscription item for API-created licenses
42+
firstName: null, // Set to null as requested
43+
lastName: null, // Set to null as requested
44+
source: LicenseSource::Bifrost
45+
);
46+
47+
// Since we're using dispatchSync, the job has completed by this point
48+
// Find the created license
49+
$license = License::where('user_id', $user->id)
50+
->where('policy_name', $subscription->value)
51+
->where('source', LicenseSource::Bifrost)
52+
->latest()
53+
->firstOrFail();
54+
55+
return response()->json($license);
56+
}
57+
}

app/Jobs/CreateAnystackLicenseJob.php

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

33
namespace App\Jobs;
44

5+
use App\Enums\LicenseSource;
56
use App\Enums\Subscription;
67
use App\Models\License;
78
use App\Models\User;
@@ -24,6 +25,7 @@ public function __construct(
2425
public ?int $subscriptionItemId = null,
2526
public ?string $firstName = null,
2627
public ?string $lastName = null,
28+
public LicenseSource $source = LicenseSource::Stripe,
2729
) {}
2830

2931
public function handle(): void
@@ -42,6 +44,7 @@ public function handle(): void
4244
'user_id' => $this->user->id,
4345
'subscription_item_id' => $this->subscriptionItemId,
4446
'policy_name' => $this->subscription->value,
47+
'source' => $this->source,
4548
'key' => $licenseData['key'],
4649
'expires_at' => $licenseData['expires_at'],
4750
'created_at' => $licenseData['created_at'],

app/Models/License.php

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

33
namespace App\Models;
44

5+
use App\Enums\LicenseSource;
56
use App\Enums\Subscription;
67
use Illuminate\Database\Eloquent\Builder;
78
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -18,6 +19,7 @@ class License extends Model
1819
protected $casts = [
1920
'expires_at' => 'datetime',
2021
'is_suspended' => 'boolean',
22+
'source' => LicenseSource::class,
2123
];
2224

2325
/**

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"guzzlehttp/guzzle": "^7.2",
1717
"laravel/cashier": "^15.6",
1818
"laravel/framework": "^10.10",
19-
"laravel/sanctum": "^3.2",
19+
"laravel/sanctum": "^3.3",
2020
"laravel/tinker": "^2.8",
2121
"league/commonmark": "^2.4",
2222
"livewire/livewire": "^3.6.4",

0 commit comments

Comments
 (0)