From 26b036dfa45bb4b7dbbc3d1b549308ed2f26bba1 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 21 Dec 2025 18:14:07 +0000 Subject: [PATCH 1/7] Discord integration --- .../Commands/RemoveExpiredDiscordRoles.php | 46 ++++++ .../Commands/RemoveExpiredGitHubAccess.php | 4 +- app/Console/Kernel.php | 6 + .../DiscordIntegrationController.php | 122 +++++++++++++++ .../GitHubIntegrationController.php | 2 +- app/Jobs/AssignDiscordMaxRoleJob.php | 69 +++++++++ app/Jobs/RemoveDiscordMaxRoleJob.php | 52 +++++++ .../StripeWebhookReceivedListener.php | 49 ++++++ app/Livewire/DiscordAccessBanner.php | 104 +++++++++++++ app/Models/User.php | 30 ++++ app/Support/DiscordApi.php | 145 ++++++++++++++++++ config/services.php | 9 ++ ...1702_add_discord_fields_to_users_table.php | 30 ++++ .../views/customer/integrations.blade.php | 102 ++++++++++++ .../views/customer/licenses/index.blade.php | 14 +- .../livewire/discord-access-banner.blade.php | 80 ++++++++++ .../livewire/git-hub-access-banner.blade.php | 118 +++++++------- routes/web.php | 8 + 18 files changed, 923 insertions(+), 67 deletions(-) create mode 100644 app/Console/Commands/RemoveExpiredDiscordRoles.php create mode 100644 app/Http/Controllers/DiscordIntegrationController.php create mode 100644 app/Jobs/AssignDiscordMaxRoleJob.php create mode 100644 app/Jobs/RemoveDiscordMaxRoleJob.php create mode 100644 app/Livewire/DiscordAccessBanner.php create mode 100644 app/Support/DiscordApi.php create mode 100644 database/migrations/2025_12_21_161702_add_discord_fields_to_users_table.php create mode 100644 resources/views/customer/integrations.blade.php create mode 100644 resources/views/livewire/discord-access-banner.blade.php diff --git a/app/Console/Commands/RemoveExpiredDiscordRoles.php b/app/Console/Commands/RemoveExpiredDiscordRoles.php new file mode 100644 index 00000000..c1dbf304 --- /dev/null +++ b/app/Console/Commands/RemoveExpiredDiscordRoles.php @@ -0,0 +1,46 @@ +whereNotNull('discord_role_granted_at') + ->whereNotNull('discord_id') + ->get(); + + foreach ($users as $user) { + if (! $user->hasMaxAccess()) { + $success = $discord->removeMaxRole($user->discord_id); + + if ($success) { + $user->update([ + 'discord_role_granted_at' => null, + ]); + + $this->info("Removed Discord role for user: {$user->email} ({$user->discord_username})"); + $removed++; + } else { + $this->error("Failed to remove Discord role for user: {$user->email} ({$user->discord_username})"); + } + } + } + + $this->info("Total users with Discord role removed: {$removed}"); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/RemoveExpiredGitHubAccess.php b/app/Console/Commands/RemoveExpiredGitHubAccess.php index afbb166b..6483f4b5 100644 --- a/app/Console/Commands/RemoveExpiredGitHubAccess.php +++ b/app/Console/Commands/RemoveExpiredGitHubAccess.php @@ -24,8 +24,8 @@ public function handle(): int ->get(); foreach ($users as $user) { - // Check if user still has an active Max license - if (! $user->hasActiveMaxLicense()) { + // Check if user still has Max access (direct or sub-license) + if (! $user->hasMaxAccess()) { // Remove from repository $success = $github->removeFromMobileRepo($user->github_username); diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c9e58a9c..50fc8e89 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -23,6 +23,12 @@ protected function schedule(Schedule $schedule): void ->dailyAt('10:00') ->onOneServer() ->runInBackground(); + + // Remove Discord Max role for users with expired Max licenses + $schedule->command('discord:remove-expired-roles') + ->dailyAt('10:30') + ->onOneServer() + ->runInBackground(); } /** diff --git a/app/Http/Controllers/DiscordIntegrationController.php b/app/Http/Controllers/DiscordIntegrationController.php new file mode 100644 index 00000000..edbd7323 --- /dev/null +++ b/app/Http/Controllers/DiscordIntegrationController.php @@ -0,0 +1,122 @@ +middleware('auth'); + } + + public function redirectToDiscord(): RedirectResponse + { + $params = http_build_query([ + 'client_id' => config('services.discord.client_id'), + 'redirect_uri' => config('services.discord.redirect'), + 'response_type' => 'code', + 'scope' => 'identify', + ]); + + return redirect('https://discord.com/api/oauth2/authorize?'.$params); + } + + public function handleCallback(): RedirectResponse + { + $code = request('code'); + + if (! $code) { + return redirect()->route('customer.integrations') + ->with('error', 'Discord authorization was cancelled.'); + } + + try { + $tokenResponse = Http::asForm()->post('https://discord.com/api/oauth2/token', [ + 'client_id' => config('services.discord.client_id'), + 'client_secret' => config('services.discord.client_secret'), + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => config('services.discord.redirect'), + ]); + + if ($tokenResponse->failed()) { + throw new \Exception('Failed to exchange code for token'); + } + + $accessToken = $tokenResponse->json('access_token'); + + $userResponse = Http::withToken($accessToken) + ->get('https://discord.com/api/v10/users/@me'); + + if ($userResponse->failed()) { + throw new \Exception('Failed to fetch Discord user'); + } + + $discordUser = $userResponse->json(); + + $user = Auth::user(); + $user->update([ + 'discord_id' => $discordUser['id'], + 'discord_username' => $discordUser['username'], + ]); + + $discord = DiscordApi::make(); + + if (! $discord->isGuildMember($discordUser['id'])) { + return redirect()->route('customer.integrations') + ->with('warning', 'Discord account connected! Please join the NativePHP Discord server to receive the Max role.'); + } + + if ($user->hasMaxAccess()) { + $success = $discord->assignMaxRole($discordUser['id']); + + if ($success) { + $user->update([ + 'discord_role_granted_at' => now(), + ]); + + return redirect()->route('customer.integrations') + ->with('success', 'Discord account connected and Max role assigned!'); + } + + return redirect()->route('customer.integrations') + ->with('warning', 'Discord account connected, but we could not assign the Max role. Please try again later.'); + } + + return redirect()->route('customer.integrations') + ->with('success', 'Discord account connected successfully!'); + } catch (\Exception $e) { + Log::error('Discord OAuth callback failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return redirect()->route('customer.integrations') + ->with('error', 'Failed to connect Discord account. Please try again.'); + } + } + + public function disconnect(): RedirectResponse + { + $user = Auth::user(); + + if ($user->discord_role_granted_at && $user->discord_id) { + $discord = DiscordApi::make(); + $discord->removeMaxRole($user->discord_id); + } + + $user->update([ + 'discord_id' => null, + 'discord_username' => null, + 'discord_role_granted_at' => null, + ]); + + return back()->with('success', 'Discord account disconnected successfully.'); + } +} diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index 5a6fd20f..da929e9b 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -54,7 +54,7 @@ public function requestRepoAccess(): RedirectResponse return back()->with('error', 'Please connect your GitHub account first.'); } - if (! $user->hasActiveMaxLicense()) { + if (! $user->hasMaxAccess()) { return back()->with('error', 'You need an active Max license to access the mobile repository.'); } diff --git a/app/Jobs/AssignDiscordMaxRoleJob.php b/app/Jobs/AssignDiscordMaxRoleJob.php new file mode 100644 index 00000000..139dcc25 --- /dev/null +++ b/app/Jobs/AssignDiscordMaxRoleJob.php @@ -0,0 +1,69 @@ +user->discord_id) { + Log::info('Skipping Discord role assignment - user has no Discord connected', [ + 'user_id' => $this->user->id, + ]); + + return; + } + + if (! $this->user->hasMaxAccess()) { + Log::info('Skipping Discord role assignment - user has no Max access', [ + 'user_id' => $this->user->id, + ]); + + return; + } + + $discord = DiscordApi::make(); + + if (! $discord->isGuildMember($this->user->discord_id)) { + Log::info('Skipping Discord role assignment - user is not in guild', [ + 'user_id' => $this->user->id, + 'discord_id' => $this->user->discord_id, + ]); + + return; + } + + $success = $discord->assignMaxRole($this->user->discord_id); + + if ($success) { + $this->user->update(['discord_role_granted_at' => now()]); + + Log::info('Discord Max role assigned successfully', [ + 'user_id' => $this->user->id, + 'discord_id' => $this->user->discord_id, + ]); + } else { + Log::error('Failed to assign Discord Max role', [ + 'user_id' => $this->user->id, + 'discord_id' => $this->user->discord_id, + ]); + } + } +} diff --git a/app/Jobs/RemoveDiscordMaxRoleJob.php b/app/Jobs/RemoveDiscordMaxRoleJob.php new file mode 100644 index 00000000..1cce367d --- /dev/null +++ b/app/Jobs/RemoveDiscordMaxRoleJob.php @@ -0,0 +1,52 @@ +user->discord_id) { + Log::info('Skipping Discord role removal - user has no Discord connected', [ + 'user_id' => $this->user->id, + ]); + + return; + } + + $discord = DiscordApi::make(); + + $success = $discord->removeMaxRole($this->user->discord_id); + + if ($success) { + $this->user->update(['discord_role_granted_at' => null]); + + Log::info('Discord Max role removed successfully', [ + 'user_id' => $this->user->id, + 'discord_id' => $this->user->discord_id, + ]); + } else { + Log::warning('Failed to remove Discord Max role (user may not have role)', [ + 'user_id' => $this->user->id, + 'discord_id' => $this->user->discord_id, + ]); + } + } +} diff --git a/app/Listeners/StripeWebhookReceivedListener.php b/app/Listeners/StripeWebhookReceivedListener.php index c8048bfe..efc0708e 100644 --- a/app/Listeners/StripeWebhookReceivedListener.php +++ b/app/Listeners/StripeWebhookReceivedListener.php @@ -4,6 +4,8 @@ use App\Jobs\CreateUserFromStripeCustomer; use App\Jobs\HandleInvoicePaidJob; +use App\Jobs\RemoveDiscordMaxRoleJob; +use App\Models\User; use Exception; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Cashier; @@ -19,6 +21,8 @@ public function handle(WebhookReceived $event): void match ($event->payload['type']) { 'invoice.paid' => $this->handleInvoicePaid($event), 'customer.subscription.created' => $this->createUserIfNotExists($event->payload['data']['object']['customer']), + 'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event), + 'customer.subscription.updated' => $this->handleSubscriptionUpdated($event), default => null, }; } @@ -46,4 +50,49 @@ private function handleInvoicePaid(WebhookReceived $event): void dispatch(new HandleInvoicePaidJob($invoice)); } + + private function handleSubscriptionDeleted(WebhookReceived $event): void + { + $subscription = $event->payload['data']['object']; + $customerId = $subscription['customer']; + + $user = Cashier::findBillable($customerId); + + if (! $user instanceof User) { + return; + } + + $this->removeDiscordRoleIfNoMaxLicense($user); + } + + private function handleSubscriptionUpdated(WebhookReceived $event): void + { + $subscription = $event->payload['data']['object']; + $customerId = $subscription['customer']; + + $user = Cashier::findBillable($customerId); + + if (! $user instanceof User) { + return; + } + + $status = $subscription['status']; + + if (in_array($status, ['canceled', 'unpaid', 'past_due', 'incomplete_expired'])) { + $this->removeDiscordRoleIfNoMaxLicense($user); + } + } + + private function removeDiscordRoleIfNoMaxLicense(User $user): void + { + if (! $user->discord_id) { + return; + } + + if ($user->hasMaxAccess()) { + return; + } + + dispatch(new RemoveDiscordMaxRoleJob($user)); + } } diff --git a/app/Livewire/DiscordAccessBanner.php b/app/Livewire/DiscordAccessBanner.php new file mode 100644 index 00000000..dd882880 --- /dev/null +++ b/app/Livewire/DiscordAccessBanner.php @@ -0,0 +1,104 @@ +inline = $inline; + $this->checkRoleStatus(); + } + + public function checkRoleStatus(): void + { + $user = auth()->user(); + + if (! $user || ! $user->discord_id) { + $this->hasMaxRole = false; + $this->isGuildMember = false; + + return; + } + + $cacheKey = "discord_role_status_{$user->id}"; + + $status = Cache::remember($cacheKey, 300, function () use ($user) { + $discord = DiscordApi::make(); + + return [ + 'isGuildMember' => $discord->isGuildMember($user->discord_id), + 'hasMaxRole' => $discord->hasMaxRole($user->discord_id), + ]; + }); + + $this->isGuildMember = $status['isGuildMember']; + $this->hasMaxRole = $status['hasMaxRole']; + + if ($this->hasMaxRole && ! $user->discord_role_granted_at) { + $user->update(['discord_role_granted_at' => now()]); + } + } + + public function refreshStatus(): void + { + $user = auth()->user(); + + if ($user) { + Cache::forget("discord_role_status_{$user->id}"); + } + + $this->checkRoleStatus(); + } + + public function requestMaxRole(): void + { + $user = auth()->user(); + + if (! $user || ! $user->discord_id) { + session()->flash('error', 'Please connect your Discord account first.'); + + return; + } + + if (! $user->hasMaxAccess()) { + session()->flash('error', 'You need an active Max license to receive the Max role.'); + + return; + } + + $discord = DiscordApi::make(); + + if (! $discord->isGuildMember($user->discord_id)) { + session()->flash('error', 'Please join the NativePHP Discord server first.'); + + return; + } + + $success = $discord->assignMaxRole($user->discord_id); + + if ($success) { + $user->update(['discord_role_granted_at' => now()]); + Cache::forget("discord_role_status_{$user->id}"); + $this->checkRoleStatus(); + session()->flash('success', 'Max role assigned successfully!'); + } else { + session()->flash('error', 'Failed to assign Max role. Please try again later.'); + } + } + + public function render() + { + return view('livewire.discord-access-banner'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 4a138df0..bd2d710b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -28,6 +28,7 @@ class User extends Authenticatable implements FilamentUser 'email_verified_at' => 'datetime', 'password' => 'hashed', 'mobile_repo_access_granted_at' => 'datetime', + 'discord_role_granted_at' => 'datetime', ]; public function canAccessPanel(Panel $panel): bool @@ -65,6 +66,35 @@ public function hasActiveMaxLicense(): bool ->exists(); } + public function hasActiveMaxSubLicense(): bool + { + return SubLicense::query() + ->where('assigned_email', $this->email) + ->where('is_suspended', false) + ->whereActive() + ->whereHas('parentLicense', function ($query) { + $query->where('policy_name', 'max') + ->where('is_suspended', false) + ->whereActive(); + }) + ->exists(); + } + + public function hasMaxAccess(): bool + { + return $this->hasActiveMaxLicense() || $this->hasActiveMaxSubLicense(); + } + + public function hasDiscordConnected(): bool + { + return ! empty($this->discord_id); + } + + public function hasActualLicense(): bool + { + return $this->licenses()->exists(); + } + public function getFirstNameAttribute(): ?string { if (empty($this->name)) { diff --git a/app/Support/DiscordApi.php b/app/Support/DiscordApi.php new file mode 100644 index 00000000..367fa1e6 --- /dev/null +++ b/app/Support/DiscordApi.php @@ -0,0 +1,145 @@ +guildId, + $discordUserId + ); + + Log::debug('Checking Discord guild membership', [ + 'url' => $url, + 'guild_id' => $this->guildId, + 'discord_user_id' => $discordUserId, + ]); + + $response = Http::withToken($this->botToken, 'Bot')->get($url); + + if ($response->status() === 404) { + Log::info('Discord user not found in guild', [ + 'discord_user_id' => $discordUserId, + 'guild_id' => $this->guildId, + ]); + + return false; + } + + if ($response->failed()) { + Log::error('Failed to check Discord guild membership', [ + 'discord_user_id' => $discordUserId, + 'guild_id' => $this->guildId, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return false; + } + + Log::info('Discord user is guild member', [ + 'discord_user_id' => $discordUserId, + 'guild_id' => $this->guildId, + ]); + + return true; + } + + public function assignMaxRole(string $discordUserId): bool + { + $response = Http::withToken($this->botToken, 'Bot') + ->put(sprintf( + '%s/guilds/%s/members/%s/roles/%s', + self::BASE_URL, + $this->guildId, + $discordUserId, + $this->maxRoleId + )); + + if ($response->failed()) { + Log::error('Failed to assign Discord Max role', [ + 'discord_user_id' => $discordUserId, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return false; + } + + return true; + } + + public function removeMaxRole(string $discordUserId): bool + { + $response = Http::withToken($this->botToken, 'Bot') + ->delete(sprintf( + '%s/guilds/%s/members/%s/roles/%s', + self::BASE_URL, + $this->guildId, + $discordUserId, + $this->maxRoleId + )); + + if ($response->failed()) { + Log::error('Failed to remove Discord Max role', [ + 'discord_user_id' => $discordUserId, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return false; + } + + return true; + } + + public function hasMaxRole(string $discordUserId): bool + { + $response = Http::withToken($this->botToken, 'Bot') + ->get(sprintf( + '%s/guilds/%s/members/%s', + self::BASE_URL, + $this->guildId, + $discordUserId + )); + + if ($response->failed()) { + Log::error('Failed to check Discord user roles', [ + 'discord_user_id' => $discordUserId, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return false; + } + + $member = $response->json(); + $roles = $member['roles'] ?? []; + + return in_array($this->maxRoleId, $roles, true); + } +} diff --git a/config/services.php b/config/services.php index 805e88dd..2f9c0223 100644 --- a/config/services.php +++ b/config/services.php @@ -50,6 +50,15 @@ 'token' => env('GITHUB_TOKEN'), ], + 'discord' => [ + 'client_id' => env('DISCORD_CLIENT_ID'), + 'client_secret' => env('DISCORD_CLIENT_SECRET'), + 'redirect' => env('APP_URL').'/auth/discord/callback', + 'bot_token' => env('DISCORD_BOT_TOKEN'), + 'guild_id' => env('DISCORD_GUILD_ID'), + 'max_role_id' => env('DISCORD_MAX_ROLE_ID'), + ], + 'turnstile' => [ 'site_key' => env('TURNSTILE_SITE_KEY'), 'secret_key' => env('TURNSTILE_SECRET_KEY'), diff --git a/database/migrations/2025_12_21_161702_add_discord_fields_to_users_table.php b/database/migrations/2025_12_21_161702_add_discord_fields_to_users_table.php new file mode 100644 index 00000000..18e33bcd --- /dev/null +++ b/database/migrations/2025_12_21_161702_add_discord_fields_to_users_table.php @@ -0,0 +1,30 @@ +string('discord_id')->nullable()->after('mobile_repo_access_granted_at'); + $table->string('discord_username')->nullable()->after('discord_id'); + $table->timestamp('discord_role_granted_at')->nullable()->after('discord_username'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['discord_id', 'discord_username', 'discord_role_granted_at']); + }); + } +}; diff --git a/resources/views/customer/integrations.blade.php b/resources/views/customer/integrations.blade.php new file mode 100644 index 00000000..07bd2c34 --- /dev/null +++ b/resources/views/customer/integrations.blade.php @@ -0,0 +1,102 @@ + +
+ {{-- Header --}} +
+
+
+
+ +

Integrations

+

+ Connect your accounts to unlock additional features +

+
+
+
+
+ + {{-- Content --}} +
+ {{-- Flash Messages --}} + @if(session()->has('success')) +
+
+ + + +

{{ session('success') }}

+
+
+ @endif + + @if(session()->has('warning')) +
+
+ + + +

{{ session('warning') }}

+
+
+ @endif + + @if(session()->has('error')) +
+
+ + + +

{{ session('error') }}

+
+
+ @endif + + {{-- Info Section --}} +
+

About Integrations

+
+
    +
  • GitHub: Max license holders can access the private nativephp/mobile repository.
  • +
  • Discord: Max license holders receive a special "Max" role in the NativePHP Discord server.
  • +
+ @if(auth()->user()->hasMaxAccess()) +

+ Need help? Join our Discord community. +

+ @else +

+ These integrations are available to Max license holders. Upgrade to Max to unlock these features. +

+ @endif +
+
+ + @if(auth()->user()->hasMaxAccess()) + {{-- Integration Cards --}} +
+ {{-- GitHub Integration --}} + + + {{-- Discord Integration --}} + +
+ @endif +
+
+
diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index 2e81459e..d6ff30c7 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -14,6 +14,9 @@ Showcase + + Integrations + Manage Subscription @@ -24,10 +27,11 @@ {{-- Banners --}}
-
- +
+ @if(auth()->user()->hasActualLicense()) + + @endif -
@@ -151,7 +155,9 @@ @endforeach
- @else + @endif + + @if($licenses->count() === 0 && $assignedSubLicenses->count() === 0)

No licenses found

diff --git a/resources/views/livewire/discord-access-banner.blade.php b/resources/views/livewire/discord-access-banner.blade.php new file mode 100644 index 00000000..f542db93 --- /dev/null +++ b/resources/views/livewire/discord-access-banner.blade.php @@ -0,0 +1,80 @@ +
+
!$inline])> +
+
+
+
+ +
+
+

+ Discord Max Role +

+
+ @if(auth()->user()->discord_username) +

Connected as {{ auth()->user()->discord_username }}

+ + @if(!$isGuildMember) +

+ + Not in Server + +

+ @elseif($hasMaxRole) +

+ + Max Role Active + +

+ @elseif(auth()->user()->hasMaxAccess()) +

+ + Eligible + +

+ @endif + @else +

Connect your Discord account to receive the Max role.

+ @endif +
+
+
+
+ @if(auth()->user()->discord_username) + @if($hasMaxRole) + + Open Discord + + @elseif(!$isGuildMember) + + Join Discord Server + + + @elseif(auth()->user()->hasMaxAccess()) + + @endif +
+ @csrf + @method('DELETE') + +
+ @else + + Connect Discord + + @endif +
+
+
+
+
diff --git a/resources/views/livewire/git-hub-access-banner.blade.php b/resources/views/livewire/git-hub-access-banner.blade.php index e287fbe7..a44d5416 100644 --- a/resources/views/livewire/git-hub-access-banner.blade.php +++ b/resources/views/livewire/git-hub-access-banner.blade.php @@ -1,74 +1,72 @@
-@if(auth()->user()->hasActiveMaxLicense()) +@if(auth()->user()->hasMaxAccess())
!$inline])>
-
-
- -
-
-

- nativephp/mobile Repo Access -

-
- @if(auth()->user()->github_username) -

Connected as {{ '@' . auth()->user()->github_username }}

+
+
+
+ +
+
+

+ nativephp/mobile Repo Access +

+
+ @if(auth()->user()->github_username) +

Connected as {{ '@' . auth()->user()->github_username }}

- @if($collaboratorStatus === 'active') -

- - Access Granted - -

-

You have access to the nativephp/mobile repository.

- @elseif($collaboratorStatus === 'pending') -

- - Invitation Pending - -

-

Check your GitHub notifications to accept the invitation.

+ @if($collaboratorStatus === 'active') +

+ + Access Granted + +

+ @elseif($collaboratorStatus === 'pending') +

+ + Invitation Pending + +

+ @endif @else -

Request access to the nativephp/mobile repository.

+

Connect your GitHub account to access the nativephp/mobile repository.

@endif - @else -

Connect your GitHub account to access the nativephp/mobile repository.

- @endif +
-
- @if(auth()->user()->github_username) - @if($collaboratorStatus === 'active') - - View Repo - - @elseif($collaboratorStatus === 'pending') - - @else -
- @csrf - -
- @endif -
+
+
+ @if(auth()->user()->github_username) + @if($collaboratorStatus === 'active') + + View Repo + + @elseif($collaboratorStatus === 'pending') + + @else + @csrf - @method('DELETE') - - @else - - Connect GitHub - @endif -
+
+ @csrf + @method('DELETE') + +
+ @else + + Connect GitHub + + @endif
diff --git a/routes/web.php b/routes/web.php index 13823448..903db913 100644 --- a/routes/web.php +++ b/routes/web.php @@ -157,6 +157,13 @@ Route::delete('customer/github/disconnect', [App\Http\Controllers\GitHubIntegrationController::class, 'disconnect'])->name('github.disconnect'); }); +// Discord OAuth routes +Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->group(function () { + Route::get('auth/discord', [App\Http\Controllers\DiscordIntegrationController::class, 'redirectToDiscord'])->name('discord.redirect'); + Route::get('auth/discord/callback', [App\Http\Controllers\DiscordIntegrationController::class, 'handleCallback'])->name('discord.callback'); + Route::delete('customer/discord/disconnect', [App\Http\Controllers\DiscordIntegrationController::class, 'disconnect'])->name('discord.disconnect'); +}); + Route::get('callback', function (Illuminate\Http\Request $request) { $url = $request->query('url'); @@ -170,6 +177,7 @@ // Customer license management routes Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->prefix('customer')->name('customer.')->group(function () { Route::get('licenses', [CustomerLicenseController::class, 'index'])->name('licenses'); + Route::view('integrations', 'customer.integrations')->name('integrations'); Route::get('licenses/{licenseKey}', [CustomerLicenseController::class, 'show'])->name('licenses.show'); Route::patch('licenses/{licenseKey}', [CustomerLicenseController::class, 'update'])->name('licenses.update'); From 1cb559d8e37fb62a0aa1853e6961ccd26cba5349 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 21 Dec 2025 18:14:36 +0000 Subject: [PATCH 2/7] Allow registration --- .../Auth/CustomerAuthController.php | 26 +++++ resources/views/auth/login.blade.php | 5 +- resources/views/auth/register.blade.php | 107 ++++++++++++++++++ .../components/navbar/mobile-menu.blade.php | 6 +- .../views/components/navigation-bar.blade.php | 1 + routes/web.php | 3 + 6 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 resources/views/auth/register.blade.php diff --git a/app/Http/Controllers/Auth/CustomerAuthController.php b/app/Http/Controllers/Auth/CustomerAuthController.php index cc98b7af..7cb45557 100644 --- a/app/Http/Controllers/Auth/CustomerAuthController.php +++ b/app/Http/Controllers/Auth/CustomerAuthController.php @@ -4,9 +4,11 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; +use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Illuminate\View\View; class CustomerAuthController extends Controller @@ -16,6 +18,30 @@ public function showLogin(): View return view('auth.login'); } + public function showRegister(): View + { + return view('auth.register'); + } + + public function register(Request $request): RedirectResponse + { + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'string', 'email:rfc,dns', 'max:255', 'unique:users'], + 'password' => ['required', 'string', 'min:8', 'confirmed'], + ]); + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + Auth::login($user); + + return redirect()->route('customer.licenses'); + } + public function login(LoginRequest $request): RedirectResponse { $request->authenticate(); diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 9364dac6..074f95dc 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -6,7 +6,10 @@ Sign in to your account

- Manage your NativePHP licenses + Or + + create a new account +

diff --git a/resources/views/auth/register.blade.php b/resources/views/auth/register.blade.php new file mode 100644 index 00000000..55a06908 --- /dev/null +++ b/resources/views/auth/register.blade.php @@ -0,0 +1,107 @@ + +
+
+
+

+ Create your account +

+

+ Already have an account? + + Sign in + +

+
+ +
+ @csrf + +
+
+ + + @error('name') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('email') +

{{ $message }}

+ @enderror +
+ +
+ + + @error('password') +

{{ $message }}

+ @enderror +
+ +
+ + +
+
+ +
+ +
+ +

+ By creating an account, you agree to our + Terms of Service + and + Privacy Policy. +

+
+
+
+
diff --git a/resources/views/components/navbar/mobile-menu.blade.php b/resources/views/components/navbar/mobile-menu.blade.php index 21a9a301..0f25101d 100644 --- a/resources/views/components/navbar/mobile-menu.blade.php +++ b/resources/views/components/navbar/mobile-menu.blade.php @@ -224,8 +224,12 @@ class="w-full" @else diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php index 31485eb6..d426a7a5 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -161,6 +161,7 @@ class="inline" diff --git a/routes/web.php b/routes/web.php index 903db913..db5b696e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -138,6 +138,9 @@ Route::get('login', [CustomerAuthController::class, 'showLogin'])->name('customer.login'); Route::post('login', [CustomerAuthController::class, 'login']); + Route::get('register', [CustomerAuthController::class, 'showRegister'])->name('customer.register'); + Route::post('register', [CustomerAuthController::class, 'register']); + Route::get('forgot-password', [CustomerAuthController::class, 'showForgotPassword'])->name('password.request'); Route::post('forgot-password', [CustomerAuthController::class, 'sendPasswordResetLink'])->name('password.email'); From 2df4432eca3a30127da3adba81c403bca462a73b Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 21 Dec 2025 18:15:09 +0000 Subject: [PATCH 3/7] Only show discounted prices to full license holders --- resources/views/pricing.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/pricing.blade.php b/resources/views/pricing.blade.php index ea99d31d..9e3ecf36 100644 --- a/resources/views/pricing.blade.php +++ b/resources/views/pricing.blade.php @@ -607,7 +607,7 @@ class="absolute inset-0 -z-10 h-full w-full object-cover" {{-- Pricing Section --}} - + {{-- Ultra Section --}} From 739f8a6536c9f851156010f27e58b0c448859ff8 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 21 Dec 2025 18:15:24 +0000 Subject: [PATCH 4/7] Show assigned licenses on dashboard --- .../Controllers/CustomerLicenseController.php | 13 +++- .../views/customer/licenses/index.blade.php | 70 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/CustomerLicenseController.php b/app/Http/Controllers/CustomerLicenseController.php index 01b3f1dc..a7571ac4 100644 --- a/app/Http/Controllers/CustomerLicenseController.php +++ b/app/Http/Controllers/CustomerLicenseController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\SubLicense; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -19,7 +20,17 @@ public function index(): View $user = Auth::user(); $licenses = $user->licenses()->orderBy('created_at', 'desc')->get(); - return view('customer.licenses.index', compact('licenses')); + // Fetch sub-licenses assigned to this user's email (excluding those from licenses they own) + $assignedSubLicenses = SubLicense::query() + ->with('parentLicense') + ->where('assigned_email', $user->email) + ->whereHas('parentLicense', function ($query) use ($user) { + $query->where('user_id', '!=', $user->id); + }) + ->orderBy('created_at', 'desc') + ->get(); + + return view('customer.licenses.index', compact('licenses', 'assignedSubLicenses')); } public function show(string $licenseKey): View diff --git a/resources/views/customer/licenses/index.blade.php b/resources/views/customer/licenses/index.blade.php index d6ff30c7..50de6b21 100644 --- a/resources/views/customer/licenses/index.blade.php +++ b/resources/views/customer/licenses/index.blade.php @@ -157,6 +157,76 @@
@endif + {{-- Assigned Sub-Licenses --}} + @if($assignedSubLicenses->count() > 0) +
+

Assigned Sub-Licenses

+
+
    + @foreach($assignedSubLicenses as $subLicense) +
  • +
    +
    +
    +
    + @if($subLicense->is_suspended) +
    + @elseif($subLicense->expires_at && $subLicense->expires_at->isPast()) +
    + @else +
    + @endif +
    +
    +
    +
    +

    + {{ $subLicense->parentLicense->policy_name ?? 'Sub-License' }} +

    +

    + Sub-license +

    +
    + @if($subLicense->is_suspended) + + Suspended + + @elseif($subLicense->expires_at && $subLicense->expires_at->isPast()) + + Expired + + @else + + Active + + @endif +
    +

    + {{ $subLicense->key }} +

    +
    +
    +
    +

    + @if($subLicense->expires_at) + Expires {{ $subLicense->expires_at->format('M j, Y') }} + @else + No expiration + @endif +

    +

    + Assigned {{ $subLicense->created_at->format('M j, Y') }} +

    +
    +
    +
    +
  • + @endforeach +
+
+
+ @endif + @if($licenses->count() === 0 && $assignedSubLicenses->count() === 0)
From 99847ad633b6feefbf865429016b62354942ef45 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 21 Dec 2025 18:23:37 +0000 Subject: [PATCH 5/7] Rate limit --- routes/web.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/web.php b/routes/web.php index db5b696e..ec50f34f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -139,7 +139,7 @@ Route::post('login', [CustomerAuthController::class, 'login']); Route::get('register', [CustomerAuthController::class, 'showRegister'])->name('customer.register'); - Route::post('register', [CustomerAuthController::class, 'register']); + Route::post('register', [CustomerAuthController::class, 'register'])->middleware('throttle:5,1'); Route::get('forgot-password', [CustomerAuthController::class, 'showForgotPassword'])->name('password.request'); Route::post('forgot-password', [CustomerAuthController::class, 'sendPasswordResetLink'])->name('password.email'); From 1ce3f9d504b8196e246ce54f69c6d351457ac418 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 21 Dec 2025 18:27:03 +0000 Subject: [PATCH 6/7] Remove access for license revocation --- .../CustomerSubLicenseController.php | 20 ++++ app/Jobs/RevokeMaxAccessJob.php | 101 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 app/Jobs/RevokeMaxAccessJob.php diff --git a/app/Http/Controllers/CustomerSubLicenseController.php b/app/Http/Controllers/CustomerSubLicenseController.php index 85c1881f..c103b81a 100644 --- a/app/Http/Controllers/CustomerSubLicenseController.php +++ b/app/Http/Controllers/CustomerSubLicenseController.php @@ -6,6 +6,7 @@ use App\Actions\SubLicenses\SuspendSubLicense; use App\Http\Requests\CreateSubLicenseRequest; use App\Jobs\CreateAnystackSubLicenseJob; +use App\Jobs\RevokeMaxAccessJob; use App\Jobs\UpdateAnystackContactAssociationJob; use App\Models\License; use App\Models\SubLicense; @@ -68,6 +69,11 @@ public function update(Request $request, string $licenseKey, SubLicense $subLice UpdateAnystackContactAssociationJob::dispatch($subLicense, $request->assigned_email); } + // If the email was changed and this is a Max license, revoke access for the old email + if ($oldEmail && $oldEmail !== $request->assigned_email && $license->policy_name === 'max') { + RevokeMaxAccessJob::dispatch($oldEmail); + } + return redirect()->route('customer.licenses.show', $licenseKey) ->with('success', 'Sub-license updated successfully!'); } @@ -82,8 +88,15 @@ public function destroy(string $licenseKey, SubLicense $subLicense): RedirectRes abort(404); } + $assignedEmail = $subLicense->assigned_email; + app(DeleteSubLicense::class)->handle($subLicense); + // If this was a Max license and had an assigned email, revoke access + if ($assignedEmail && $license->policy_name === 'max') { + RevokeMaxAccessJob::dispatch($assignedEmail); + } + return redirect()->route('customer.licenses.show', $licenseKey) ->with('success', 'Sub-license deleted successfully!'); } @@ -98,8 +111,15 @@ public function suspend(string $licenseKey, SubLicense $subLicense): RedirectRes abort(404); } + $assignedEmail = $subLicense->assigned_email; + app(SuspendSubLicense::class)->handle($subLicense); + // If this was a Max license and had an assigned email, revoke access + if ($assignedEmail && $license->policy_name === 'max') { + RevokeMaxAccessJob::dispatch($assignedEmail); + } + return redirect()->route('customer.licenses.show', $licenseKey) ->with('success', 'Sub-license suspended successfully!'); } diff --git a/app/Jobs/RevokeMaxAccessJob.php b/app/Jobs/RevokeMaxAccessJob.php new file mode 100644 index 00000000..b08d0686 --- /dev/null +++ b/app/Jobs/RevokeMaxAccessJob.php @@ -0,0 +1,101 @@ +email)->first(); + + if (! $user) { + Log::info('Skipping access revocation - no user found for email', [ + 'email' => $this->email, + ]); + + return; + } + + // Check if user still has Max access through another license or sub-license + if ($user->hasMaxAccess()) { + Log::info('Skipping access revocation - user still has Max access', [ + 'user_id' => $user->id, + 'email' => $this->email, + ]); + + return; + } + + // Revoke GitHub access + $this->revokeGitHubAccess($user); + + // Revoke Discord role + $this->revokeDiscordRole($user); + } + + private function revokeGitHubAccess(User $user): void + { + if (! $user->github_username || ! $user->mobile_repo_access_granted_at) { + return; + } + + $github = GitHubOAuth::make(); + $success = $github->removeFromMobileRepo($user->github_username); + + if ($success) { + $user->update(['mobile_repo_access_granted_at' => null]); + + Log::info('GitHub access revoked for user', [ + 'user_id' => $user->id, + 'github_username' => $user->github_username, + ]); + } else { + Log::warning('Failed to revoke GitHub access for user', [ + 'user_id' => $user->id, + 'github_username' => $user->github_username, + ]); + } + } + + private function revokeDiscordRole(User $user): void + { + if (! $user->discord_id || ! $user->discord_role_granted_at) { + return; + } + + $discord = DiscordApi::make(); + $success = $discord->removeMaxRole($user->discord_id); + + if ($success) { + $user->update(['discord_role_granted_at' => null]); + + Log::info('Discord Max role revoked for user', [ + 'user_id' => $user->id, + 'discord_id' => $user->discord_id, + ]); + } else { + Log::warning('Failed to revoke Discord Max role for user', [ + 'user_id' => $user->id, + 'discord_id' => $user->discord_id, + ]); + } + } +} From 991ba050a590df9ba2772ded2320103f5ceb2a64 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 21 Dec 2025 18:32:05 +0000 Subject: [PATCH 7/7] Fix tests --- tests/Feature/CustomerAuthenticationTest.php | 2 +- tests/Feature/GitHubIntegrationTest.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Feature/CustomerAuthenticationTest.php b/tests/Feature/CustomerAuthenticationTest.php index be0403db..d849388e 100644 --- a/tests/Feature/CustomerAuthenticationTest.php +++ b/tests/Feature/CustomerAuthenticationTest.php @@ -27,7 +27,7 @@ public function test_customer_can_view_login_page(): void $response->assertStatus(200); $response->assertSee('Sign in to your account'); - $response->assertSee('Manage your NativePHP licenses'); + $response->assertSee('create a new account'); } public function test_customer_can_login_with_valid_credentials(): void diff --git a/tests/Feature/GitHubIntegrationTest.php b/tests/Feature/GitHubIntegrationTest.php index 92434e17..ddea43f8 100644 --- a/tests/Feature/GitHubIntegrationTest.php +++ b/tests/Feature/GitHubIntegrationTest.php @@ -37,7 +37,7 @@ public function test_user_with_active_max_license_sees_github_integration_card() 'is_suspended' => false, ]); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/customer/integrations'); $response->assertStatus(200); $response->assertSee('nativephp/mobile'); @@ -57,7 +57,7 @@ public function test_user_without_max_license_does_not_see_github_integration_ca 'is_suspended' => false, ]); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/customer/integrations'); $response->assertStatus(200); $response->assertDontSee('Repo Access'); @@ -78,7 +78,7 @@ public function test_user_with_connected_github_sees_username(): void 'is_suspended' => false, ]); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/customer/integrations'); $response->assertStatus(200); $response->assertSee('Connected as'); @@ -254,7 +254,7 @@ public function test_user_with_active_collaborator_status_sees_access_granted(): 'is_suspended' => false, ]); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/customer/integrations'); $response->assertStatus(200); $response->assertSee('Access Granted'); @@ -283,7 +283,7 @@ public function test_user_with_pending_invitation_sees_pending_status(): void 'is_suspended' => false, ]); - $response = $this->actingAs($user)->get('/customer/licenses'); + $response = $this->actingAs($user)->get('/customer/integrations'); $response->assertStatus(200); $response->assertSee('Invitation Pending');