Skip to content
Draft
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
5 changes: 5 additions & 0 deletions app/Http/Controllers/DiscordIntegrationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,18 @@ public function disconnect(): RedirectResponse
if ($user->discord_early_adopter_role_granted_at) {
$discord->removeEarlyAdopterRole($user->discord_id);
}

if ($user->discord_plugin_developer_role_granted_at) {
$discord->removePluginDeveloperRole($user->discord_id);
}
}

$user->update([
'discord_id' => null,
'discord_username' => null,
'discord_role_granted_at' => null,
'discord_early_adopter_role_granted_at' => null,
'discord_plugin_developer_role_granted_at' => null,
]);

return back()->with('success', 'Discord account disconnected successfully.');
Expand Down
45 changes: 45 additions & 0 deletions app/Livewire/DiscordAccessBanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ class DiscordAccessBanner extends Component

public bool $hasEarlyAdopterRole = false;

public bool $hasPluginDeveloperRole = false;

public bool $isGuildMember = false;

public function mount(bool $inline = false): void
Expand All @@ -29,6 +31,7 @@ public function checkRoleStatus(): void
if (! $user || ! $user->discord_id) {
$this->hasUltraRole = false;
$this->hasEarlyAdopterRole = false;
$this->hasPluginDeveloperRole = false;
$this->isGuildMember = false;

return;
Expand All @@ -43,12 +46,14 @@ public function checkRoleStatus(): void
'isGuildMember' => $discord->isGuildMember($user->discord_id),
'hasUltraRole' => $discord->hasUltraRole($user->discord_id),
'hasEarlyAdopterRole' => $discord->hasEarlyAdopterRole($user->discord_id),
'hasPluginDeveloperRole' => $discord->hasPluginDeveloperRole($user->discord_id),
];
});

$this->isGuildMember = $status['isGuildMember'];
$this->hasUltraRole = $status['hasUltraRole'];
$this->hasEarlyAdopterRole = $status['hasEarlyAdopterRole'];
$this->hasPluginDeveloperRole = $status['hasPluginDeveloperRole'] ?? false;

if ($this->hasUltraRole && ! $user->discord_role_granted_at) {
$user->update(['discord_role_granted_at' => now()]);
Expand All @@ -57,6 +62,10 @@ public function checkRoleStatus(): void
if ($this->hasEarlyAdopterRole && ! $user->discord_early_adopter_role_granted_at) {
$user->update(['discord_early_adopter_role_granted_at' => now()]);
}

if ($this->hasPluginDeveloperRole && ! $user->discord_plugin_developer_role_granted_at) {
$user->update(['discord_plugin_developer_role_granted_at' => now()]);
}
}

public function refreshStatus(): void
Expand Down Expand Up @@ -142,6 +151,42 @@ public function requestEarlyAdopterRole(): void
}
}

public function requestPluginDeveloperRole(): void
{
$user = auth()->user();

if (! $user || ! $user->discord_id) {
session()->flash('error', 'Please connect your Discord account first.');

return;
}

if (! $user->hasHadPluginApproved()) {
session()->flash('error', 'The Plugin Developer role is for developers who have had a plugin approved for the Marketplace.');

return;
}

$discord = DiscordApi::make();

if (! $discord->isGuildMember($user->discord_id)) {
session()->flash('error', 'Please join the NativePHP Discord server first.');

return;
}

$success = $discord->assignPluginDeveloperRole($user->discord_id);

if ($success) {
$user->update(['discord_plugin_developer_role_granted_at' => now()]);
Cache::forget("discord_role_status_{$user->id}");
$this->checkRoleStatus();
session()->flash('success', 'Plugin Developer role assigned successfully!');
} else {
session()->flash('error', 'Failed to assign Plugin Developer role. Please try again later.');
}
}

public function render()
{
return view('livewire.discord-access-banner');
Expand Down
20 changes: 20 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace App\Models;

use App\Enums\GitHubAuthType;
use App\Enums\PluginActivityType;
use App\Enums\PluginStatus;
use App\Enums\PriceTier;
use App\Enums\Subscription;
use App\Enums\TeamUserStatus;
Expand All @@ -11,6 +13,7 @@
use Filament\Models\Contracts\HasName;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
Expand Down Expand Up @@ -402,6 +405,22 @@ public function getEligiblePriceTiers(): array
return $tiers;
}

/**
* Whether any of the user's plugins has ever been approved for the Marketplace,
* even if it has since been rejected, withdrawn or deactivated.
*/
public function hasHadPluginApproved(): bool
{
return $this->plugins()
->where(function (Builder $query): void {
$query->where('status', PluginStatus::Approved)
->orWhereHas('activities', function (Builder $query): void {
$query->where('type', PluginActivityType::Approved);
});
})
->exists();
}

public function hasDiscordConnected(): bool
{
return ! empty($this->discord_id);
Expand Down Expand Up @@ -709,6 +728,7 @@ protected function casts(): array
'claude_plugins_repo_access_granted_at' => 'datetime',
'discord_role_granted_at' => 'datetime',
'discord_early_adopter_role_granted_at' => 'datetime',
'discord_plugin_developer_role_granted_at' => 'datetime',
'github_auth_type' => GitHubAuthType::class,
'github_token_expires_at' => 'datetime',
'github_app_migration_notified_at' => 'datetime',
Expand Down
21 changes: 19 additions & 2 deletions app/Support/DiscordApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public function __construct(
private ?string $botToken,
private ?string $guildId,
private ?string $ultraRoleId,
private ?string $earlyAdopterRoleId
private ?string $earlyAdopterRoleId,
private ?string $pluginDeveloperRoleId
) {}

public static function make(): static
Expand All @@ -22,7 +23,8 @@ public static function make(): static
config('services.discord.bot_token', ''),
config('services.discord.guild_id', ''),
config('services.discord.ultra_role_id', ''),
config('services.discord.early_adopter_role_id', '')
config('services.discord.early_adopter_role_id', ''),
config('services.discord.plugin_developer_role_id', '')
);
}

Expand Down Expand Up @@ -101,6 +103,21 @@ public function hasEarlyAdopterRole(string $discordUserId): bool
return $this->hasRole($discordUserId, $this->earlyAdopterRoleId);
}

public function assignPluginDeveloperRole(string $discordUserId): bool
{
return $this->assignRole($discordUserId, $this->pluginDeveloperRoleId, 'Plugin Developer');
}

public function removePluginDeveloperRole(string $discordUserId): bool
{
return $this->removeRole($discordUserId, $this->pluginDeveloperRoleId, 'Plugin Developer');
}

public function hasPluginDeveloperRole(string $discordUserId): bool
{
return $this->hasRole($discordUserId, $this->pluginDeveloperRoleId);
}

private function assignRole(string $discordUserId, ?string $roleId, string $roleName): bool
{
$response = Http::withToken($this->botToken, 'Bot')
Expand Down
1 change: 1 addition & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
'guild_id' => env('DISCORD_GUILD_ID'),
'ultra_role_id' => env('DISCORD_ULTRA_ROLE_ID'),
'early_adopter_role_id' => env('DISCORD_EARLY_ADOPTER_ROLE_ID'),
'plugin_developer_role_id' => env('DISCORD_PLUGIN_DEVELOPER_ROLE_ID'),
],

'turnstile' => [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->timestamp('discord_plugin_developer_role_granted_at')->nullable()->after('discord_early_adopter_role_granted_at');
});
}

public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('discord_plugin_developer_role_granted_at');
});
}
};
4 changes: 2 additions & 2 deletions resources/views/livewire/customer/integrations.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<div class="mt-4 prose dark:prose-invert prose-sm max-w-none">
<ul class="list-disc list-inside space-y-2">
<li><strong>GitHub:</strong> Max license holders can access the private <code>nativephp/mobile</code> repository. Plugin Dev Kit license holders and Ultra subscribers can access <code>nativephp/claude-code</code>.</li>
<li><strong>Discord:</strong> Max license holders and Ultra subscribers receive a special "Ultra" role in the NativePHP Discord server. Early Access Program customers receive the "Early Adopter" role.</li>
<li><strong>Discord:</strong> Max license holders and Ultra subscribers receive a special "Ultra" role in the NativePHP Discord server. Early Access Program customers receive the "Early Adopter" role. Developers who have had a plugin approved for the Marketplace can claim the "Plugin Developer" role, which unlocks a private channel just for plugin developers.</li>
</ul>
<p class="mt-4">
Need help? Join our <a href="https://discord.gg/nativephp" target="_blank" class="text-blue-600 hover:underline dark:text-blue-400">Discord community</a>.
Expand Down Expand Up @@ -130,7 +130,7 @@
<livewire:git-hub-access-banner :inline="true" />
@endif

@if(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess() || auth()->user()->isEapCustomer())
@if(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess() || auth()->user()->isEapCustomer() || auth()->user()->hasHadPluginApproved())
<livewire:discord-access-banner :inline="true" />
@endif
</div>
Expand Down
18 changes: 17 additions & 1 deletion resources/views/livewire/discord-access-banner.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@
Early Adopter Eligible
</span>
@endif

@if($hasPluginDeveloperRole)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Plugin Developer Active
</span>
@elseif(auth()->user()->hasHadPluginApproved())
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Plugin Developer Eligible
</span>
@endif
</div>
@endif
@else
Expand Down Expand Up @@ -74,7 +84,13 @@
<span wire:loading wire:target="requestEarlyAdopterRole">Requesting...</span>
</button>
@endif
@if(($hasUltraRole || !(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess())) && ($hasEarlyAdopterRole || !auth()->user()->isEapCustomer()))
@if(!$hasPluginDeveloperRole && auth()->user()->hasHadPluginApproved())
<button wire:click="requestPluginDeveloperRole" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
<span wire:loading.remove wire:target="requestPluginDeveloperRole">Claim Plugin Developer Role</span>
<span wire:loading wire:target="requestPluginDeveloperRole">Claiming...</span>
</button>
@endif
@if(($hasUltraRole || !(auth()->user()->hasMaxAccess() || auth()->user()->hasUltraAccess())) && ($hasEarlyAdopterRole || !auth()->user()->isEapCustomer()) && ($hasPluginDeveloperRole || !auth()->user()->hasHadPluginApproved()))
<a href="https://discord.gg/nativephp" target="_blank" rel="noopener noreferrer" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Open Discord
</a>
Expand Down
84 changes: 84 additions & 0 deletions tests/Feature/DiscordIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Tests\Feature;

use App\Livewire\DiscordAccessBanner;
use App\Models\License;
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
Expand All @@ -24,6 +26,7 @@ protected function setUp(): void
'services.discord.guild_id' => 'test-guild-id',
'services.discord.ultra_role_id' => 'ultra-role-id',
'services.discord.early_adopter_role_id' => 'early-adopter-role-id',
'services.discord.plugin_developer_role_id' => 'plugin-developer-role-id',
]);
}

Expand Down Expand Up @@ -188,4 +191,85 @@ public function disconnect_only_removes_early_adopter_role_when_max_role_was_not

Http::assertSentCount(1);
}

#[Test]
public function callback_does_not_assign_plugin_developer_role_automatically(): void
{
$user = User::factory()->create();

Plugin::factory()->for($user)->approved()->create();

Http::fake([
'discord.com/api/oauth2/token' => Http::response([
'access_token' => 'test-access-token',
]),
'discord.com/api/v10/users/@me' => Http::response([
'id' => '999888777',
'username' => 'plugindev',
]),
'discord.com/api/v10/guilds/test-guild-id/members/999888777' => Http::response([
'roles' => [],
], 200),
]);

$this->actingAs($user)
->get('/auth/discord/callback?code=test-code')
->assertRedirect(route('customer.integrations'));

$user->refresh();
$this->assertEquals('999888777', $user->discord_id);
$this->assertNull($user->discord_plugin_developer_role_granted_at);

Http::assertNotSent(fn ($request) => $request->method() === 'PUT');
}

#[Test]
public function disconnect_removes_plugin_developer_role(): void
{
$user = User::factory()->create([
'discord_id' => '999888777',
'discord_username' => 'testuser',
'discord_plugin_developer_role_granted_at' => now(),
]);

Http::fake([
'discord.com/api/v10/guilds/test-guild-id/members/999888777/roles/plugin-developer-role-id' => Http::response([], 204),
]);

$this->actingAs($user)
->delete('/dashboard/discord/disconnect')
->assertSessionHas('success', 'Discord account disconnected successfully.');

$user->refresh();
$this->assertNull($user->discord_id);
$this->assertNull($user->discord_plugin_developer_role_granted_at);

Http::assertSentCount(1);
}

#[Test]
public function integrations_page_shows_discord_banner_to_approved_plugin_developers(): void
{
$user = User::factory()->create();

Plugin::factory()->for($user)->approved()->create();

$this->actingAs($user)
->get(route('customer.integrations'))
->assertOk()
->assertSeeLivewire(DiscordAccessBanner::class);
}

#[Test]
public function integrations_page_hides_discord_banner_from_developers_without_an_approved_plugin(): void
{
$user = User::factory()->create();

Plugin::factory()->for($user)->pending()->create();

$this->actingAs($user)
->get(route('customer.integrations'))
->assertOk()
->assertDontSeeLivewire(DiscordAccessBanner::class);
}
}
Loading
Loading