diff --git a/sonar-desk-licensing/.gitignore b/sonar-desk-licensing/.gitignore index 7be55e2..5fa2e97 100755 --- a/sonar-desk-licensing/.gitignore +++ b/sonar-desk-licensing/.gitignore @@ -25,3 +25,4 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db +/.scannerwork/ diff --git a/sonar-desk-licensing/Http/Controllers/Admin/CmsPageController.php b/sonar-desk-licensing/Http/Controllers/Admin/CmsPageController.php new file mode 100644 index 0000000..e12651d --- /dev/null +++ b/sonar-desk-licensing/Http/Controllers/Admin/CmsPageController.php @@ -0,0 +1,45 @@ + CmsPage::latest()->paginate(25)]); } + public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); } + public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); } + public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); } + public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); } + public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); } + + private function validated(Request $request, ?CmsPage $page = null): array + { + $request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]); + $data = $request->validate([ + 'title' => ['required','string','max:160'], + 'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)], + 'excerpt' => ['nullable','string','max:1000'], + 'body' => ['required','string'], + 'meta_title' => ['nullable','string','max:160'], + 'meta_description' => ['nullable','string','max:320'], + 'meta_keywords' => ['nullable','string','max:500'], + 'canonical_url' => ['nullable','url:http,https','max:2048'], + 'og_title' => ['nullable','string','max:160'], + 'og_description' => ['nullable','string','max:320'], + 'og_image' => ['nullable','url:http,https','max:2048'], + 'sitemap_priority' => ['required','numeric','min:0','max:1'], + 'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])], + ]); + $data['robots_index'] = $request->boolean('robots_index'); + $data['robots_follow'] = $request->boolean('robots_follow'); + $data['include_in_sitemap'] = $request->boolean('include_in_sitemap'); + $data['is_published'] = $request->boolean('is_published'); + $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; + return $data; + } +} diff --git a/sonar-desk-licensing/Http/Controllers/Admin/SeoSettingsController.php b/sonar-desk-licensing/Http/Controllers/Admin/SeoSettingsController.php new file mode 100644 index 0000000..a7f920d --- /dev/null +++ b/sonar-desk-licensing/Http/Controllers/Admin/SeoSettingsController.php @@ -0,0 +1,50 @@ + collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]), + 'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true), + ]); + } + + public function update(Request $request): RedirectResponse + { + $data = $request->validate([ + 'seo_site_name' => ['required', 'string', 'max:80'], + 'seo_title_suffix' => ['nullable', 'string', 'max:80'], + 'seo_default_title' => ['required', 'string', 'max:160'], + 'seo_default_description' => ['required', 'string', 'max:320'], + 'seo_default_keywords' => ['nullable', 'string', 'max:500'], + 'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'], + 'seo_organization_name' => ['nullable', 'string', 'max:120'], + 'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_google_site_verification' => ['nullable', 'string', 'max:255'], + ]); + + foreach (self::KEYS as $key) { + SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null); + } + SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0'); + + return back()->with('status', 'SEO settings updated.'); + } +} diff --git a/sonar-desk-licensing/Http/Controllers/SeoController.php b/sonar-desk-licensing/Http/Controllers/SeoController.php new file mode 100644 index 0000000..59603f3 --- /dev/null +++ b/sonar-desk-licensing/Http/Controllers/SeoController.php @@ -0,0 +1,53 @@ + 'home', 'priority' => '1.0', 'frequency' => 'weekly'], + ['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'], + ['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'], + ['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'], + ])->filter(fn (array $item) => Route::has($item['route']))->values(); + + $pages = CmsPage::query() + ->where('is_published', true) + ->where('include_in_sitemap', true) + ->where('robots_index', true) + ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) + ->orderBy('slug') + ->get(); + + return response() + ->view('seo.sitemap', compact('static', 'pages')) + ->header('Content-Type', 'application/xml; charset=UTF-8') + ->header('Cache-Control', 'public, max-age=3600'); + } + + public function robots(): Response + { + $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true); + + $body = $indexable + ? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n" + : "User-agent: *\nDisallow: /\n"; + + return response($body, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=3600', + ]); + } +} diff --git a/sonar-desk-licensing/Models/CmsPage.php b/sonar-desk-licensing/Models/CmsPage.php new file mode 100644 index 0000000..3209346 --- /dev/null +++ b/sonar-desk-licensing/Models/CmsPage.php @@ -0,0 +1,22 @@ + 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean', + 'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1', + ]; + } +} diff --git a/sonar-desk-licensing/Models/SiteSetting.php b/sonar-desk-licensing/Models/SiteSetting.php new file mode 100644 index 0000000..872204e --- /dev/null +++ b/sonar-desk-licensing/Models/SiteSetting.php @@ -0,0 +1,32 @@ + static::query()->find($key)?->value ?? $default); + } + + public static function booleanFor(string $key, bool $default = false): bool + { + $value = static::valueFor($key); + return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + public static function put(string $key, ?string $value): void + { + static::query()->updateOrCreate(['key' => $key], ['value' => $value]); + Cache::forget('sonardesk.setting.'.$key); + } +} diff --git a/sonar-desk-licensing/app/Console/Commands/SonarDeskAccountsBackfill.php b/sonar-desk-licensing/app/Console/Commands/SonarDeskAccountsBackfill.php index ecd3464..62798b2 100644 --- a/sonar-desk-licensing/app/Console/Commands/SonarDeskAccountsBackfill.php +++ b/sonar-desk-licensing/app/Console/Commands/SonarDeskAccountsBackfill.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Models\License; +use Modules\Licensing\Models\License; use App\Models\User; use App\Services\Accounts\PurchasedAccountProvisioner; use Illuminate\Console\Command; diff --git a/sonar-desk-licensing/app/Http/Controllers/Admin/AdminController.php b/sonar-desk-licensing/app/Http/Controllers/Admin/AdminController.php index 6c2bb25..2609d01 100755 --- a/sonar-desk-licensing/app/Http/Controllers/Admin/AdminController.php +++ b/sonar-desk-licensing/app/Http/Controllers/Admin/AdminController.php @@ -3,8 +3,8 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; -use App\Models\License; -use App\Models\LicenseActivation; +use Modules\Licensing\Models\License; +use Modules\Licensing\Models\LicenseActivation; use App\Models\User; use Illuminate\Http\Request; diff --git a/sonar-desk-licensing/app/Http/Controllers/Admin/CmsPageController.php b/sonar-desk-licensing/app/Http/Controllers/Admin/CmsPageController.php index b0d53ab..e12651d 100644 --- a/sonar-desk-licensing/app/Http/Controllers/Admin/CmsPageController.php +++ b/sonar-desk-licensing/app/Http/Controllers/Admin/CmsPageController.php @@ -27,7 +27,17 @@ final class CmsPageController extends Controller 'body' => ['required','string'], 'meta_title' => ['nullable','string','max:160'], 'meta_description' => ['nullable','string','max:320'], + 'meta_keywords' => ['nullable','string','max:500'], + 'canonical_url' => ['nullable','url:http,https','max:2048'], + 'og_title' => ['nullable','string','max:160'], + 'og_description' => ['nullable','string','max:320'], + 'og_image' => ['nullable','url:http,https','max:2048'], + 'sitemap_priority' => ['required','numeric','min:0','max:1'], + 'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])], ]); + $data['robots_index'] = $request->boolean('robots_index'); + $data['robots_follow'] = $request->boolean('robots_follow'); + $data['include_in_sitemap'] = $request->boolean('include_in_sitemap'); $data['is_published'] = $request->boolean('is_published'); $data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null; return $data; diff --git a/sonar-desk-licensing/app/Http/Controllers/Admin/SeoSettingsController.php b/sonar-desk-licensing/app/Http/Controllers/Admin/SeoSettingsController.php new file mode 100644 index 0000000..a7f920d --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Admin/SeoSettingsController.php @@ -0,0 +1,50 @@ + collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]), + 'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true), + ]); + } + + public function update(Request $request): RedirectResponse + { + $data = $request->validate([ + 'seo_site_name' => ['required', 'string', 'max:80'], + 'seo_title_suffix' => ['nullable', 'string', 'max:80'], + 'seo_default_title' => ['required', 'string', 'max:160'], + 'seo_default_description' => ['required', 'string', 'max:320'], + 'seo_default_keywords' => ['nullable', 'string', 'max:500'], + 'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'], + 'seo_organization_name' => ['nullable', 'string', 'max:120'], + 'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'], + 'seo_google_site_verification' => ['nullable', 'string', 'max:255'], + ]); + + foreach (self::KEYS as $key) { + SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null); + } + SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0'); + + return back()->with('status', 'SEO settings updated.'); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Admin/UserController.php b/sonar-desk-licensing/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..f37a764 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,156 @@ +latest(); + + if ($search = trim((string) $request->query('q'))) { + $query->where(fn ($q) => $q + ->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%")); + } + + if ($request->query('status') === 'admin') { + $query->where('is_admin', true); + } elseif ($request->query('status') === 'suspended') { + $query->whereNotNull('disabled_at'); + } elseif ($request->query('status') === 'unverified') { + $query->whereNull('email_verified_at'); + } + + $users = $query->paginate(50)->withQueryString(); + $emails = $users->getCollection()->pluck('email')->map(fn ($email) => strtolower($email))->all(); + $licenseCounts = License::query() + ->selectRaw('LOWER(customer_email) as email_key, COUNT(*) as aggregate') + ->whereIn(DB::raw('LOWER(customer_email)'), $emails) + ->groupBy(DB::raw('LOWER(customer_email)')) + ->pluck('aggregate', 'email_key'); + + return view('admin.users.index', compact('users', 'licenseCounts')); + } + + public function show(User $user) + { + $licenses = License::with(['product', 'activations']) + ->whereRaw('LOWER(customer_email) = ?', [strtolower($user->email)]) + ->latest() + ->get(); + + return view('admin.users.show', compact('user', 'licenses')); + } + + public function update(Request $request, User $user) + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)], + 'is_admin' => ['nullable', 'boolean'], + ]); + + $oldEmail = strtolower($user->email); + $newEmail = strtolower(trim($data['email'])); + $isSelf = $request->user()->is($user); + + DB::transaction(function () use ($user, $data, $oldEmail, $newEmail, $isSelf): void { + $user->name = trim($data['name']); + $user->email = $newEmail; + if (! $isSelf) { + $user->is_admin = (bool) ($data['is_admin'] ?? false); + } + $user->save(); + + if ($oldEmail !== $newEmail) { + License::whereRaw('LOWER(customer_email) = ?', [$oldEmail]) + ->update(['customer_email' => $newEmail]); + } + }); + + return redirect()->route('admin.users.show', $user)->with('status', 'User updated.'); + } + + public function verify(User $user) + { + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + } + + return back()->with('status', 'Email marked as verified.'); + } + + public function resendVerification(User $user) + { + if (! $user->hasVerifiedEmail()) { + $user->sendEmailVerificationNotification(); + } + + return back()->with('status', 'Verification email sent.'); + } + + public function sendPasswordReset(User $user) + { + $status = Password::sendResetLink(['email' => $user->email]); + + return $status === Password::RESET_LINK_SENT + ? back()->with('status', 'Password reset link sent.') + : back()->withErrors(['email' => __($status)]); + } + + public function suspend(Request $request, User $user) + { + abort_if($request->user()->is($user), 422, 'You cannot suspend your own administrator account.'); + $user->forceFill(['disabled_at' => now(), 'remember_token' => null])->save(); + + return back()->with('status', 'User suspended.'); + } + + public function restore(User $user) + { + $user->forceFill(['disabled_at' => null])->save(); + + return back()->with('status', 'User restored.'); + } + + public function destroy(Request $request, User $user) + { + abort_if($request->user()->is($user), 422, 'You cannot delete your own administrator account.'); + $user->delete(); + + return redirect()->route('admin.users.index')->with('status', 'User deleted. Licences were retained.'); + } + + public function updateLicense(Request $request, User $user, License $license) + { + $this->ensureOwnsLicense($user, $license); + $data = $request->validate(['activation_limit' => ['required', 'integer', 'min:1', 'max:1000']]); + $license->update(['activation_limit' => $data['activation_limit']]); + + return back()->with('status', 'Activation limit updated.'); + } + + public function deactivate(User $user, License $license, LicenseActivation $activation) + { + $this->ensureOwnsLicense($user, $license); + abort_unless((string) $activation->license_id === (string) $license->id, 404); + $activation->update(['deactivated_at' => now()]); + + return back()->with('status', 'Device activation released.'); + } + + private function ensureOwnsLicense(User $user, License $license): void + { + abort_unless(strtolower((string) $license->customer_email) === strtolower($user->email), 404); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/sonar-desk-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php index d63878e..ebe9dc1 100755 --- a/sonar-desk-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/sonar-desk-licensing/app/Http/Controllers/Auth/AuthenticatedSessionController.php @@ -16,7 +16,7 @@ final class AuthenticatedSessionController extends Controller public function store(Request $request) { $credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]); - if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) { + if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password'], 'disabled_at' => null], $request->boolean('remember'))) { return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email'); } $request->session()->regenerate(); diff --git a/sonar-desk-licensing/app/Http/Controllers/SeoController.php b/sonar-desk-licensing/app/Http/Controllers/SeoController.php new file mode 100644 index 0000000..59603f3 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/SeoController.php @@ -0,0 +1,53 @@ + 'home', 'priority' => '1.0', 'frequency' => 'weekly'], + ['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'], + ['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'], + ['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'], + ['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'], + ['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'], + ])->filter(fn (array $item) => Route::has($item['route']))->values(); + + $pages = CmsPage::query() + ->where('is_published', true) + ->where('include_in_sitemap', true) + ->where('robots_index', true) + ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) + ->orderBy('slug') + ->get(); + + return response() + ->view('seo.sitemap', compact('static', 'pages')) + ->header('Content-Type', 'application/xml; charset=UTF-8') + ->header('Cache-Control', 'public, max-age=3600'); + } + + public function robots(): Response + { + $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true); + + $body = $indexable + ? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n" + : "User-agent: *\nDisallow: /\n"; + + return response($body, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=3600', + ]); + } +} diff --git a/sonar-desk-licensing/app/Models/CmsPage.php b/sonar-desk-licensing/app/Models/CmsPage.php index 180a18e..3209346 100644 --- a/sonar-desk-licensing/app/Models/CmsPage.php +++ b/sonar-desk-licensing/app/Models/CmsPage.php @@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\Model; final class CmsPage extends Model { - protected $fillable = ['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'is_published', 'published_at']; + protected $fillable = [ + 'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords', + 'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow', + 'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at', + ]; protected function casts(): array { - return ['is_published' => 'boolean', 'published_at' => 'datetime']; + return [ + 'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean', + 'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1', + ]; } } diff --git a/sonar-desk-licensing/app/Models/SiteSetting.php b/sonar-desk-licensing/app/Models/SiteSetting.php index 83af974..872204e 100644 --- a/sonar-desk-licensing/app/Models/SiteSetting.php +++ b/sonar-desk-licensing/app/Models/SiteSetting.php @@ -18,6 +18,12 @@ final class SiteSetting extends Model return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default); } + public static function booleanFor(string $key, bool $default = false): bool + { + $value = static::valueFor($key); + return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + public static function put(string $key, ?string $value): void { static::query()->updateOrCreate(['key' => $key], ['value' => $value]); diff --git a/sonar-desk-licensing/app/Models/User.php b/sonar-desk-licensing/app/Models/User.php index ff6872c..f593607 100755 --- a/sonar-desk-licensing/app/Models/User.php +++ b/sonar-desk-licensing/app/Models/User.php @@ -11,12 +11,19 @@ class User extends Authenticatable implements MustVerifyEmail { use HasFactory, Notifiable; - protected $fillable = ['name', 'email', 'password', 'is_admin']; + protected $fillable = [ + 'name', + 'email', + 'email_verified_at', + 'password', + 'is_admin', + 'disabled_at', + ]; protected $hidden = ['password', 'remember_token']; protected function casts(): array { - return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean']; + return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', 'disabled_at' => 'datetime']; } } diff --git a/sonar-desk-licensing/app/Providers/AppServiceProvider.php b/sonar-desk-licensing/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..66ffb66 --- /dev/null +++ b/sonar-desk-licensing/app/Providers/AppServiceProvider.php @@ -0,0 +1,17 @@ +by($request->ip()), - Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), - ]; - }); - - RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5) - ->by(strtolower((string) $request->input('email')).'|'.$request->ip())); - - RateLimiter::for('registration', fn (Request $request) => Limit::perHour(5)->by($request->ip())); - } -} diff --git a/sonar-desk-licensing/app/Services/Accounts/PurchasedAccountProvisioner.php b/sonar-desk-licensing/app/Services/Accounts/PurchasedAccountProvisioner.php index 2498f89..96ec7d4 100644 --- a/sonar-desk-licensing/app/Services/Accounts/PurchasedAccountProvisioner.php +++ b/sonar-desk-licensing/app/Services/Accounts/PurchasedAccountProvisioner.php @@ -28,7 +28,6 @@ final class PurchasedAccountProvisioner * Paddle can deliver several related webhook events concurrently, so * this lookup alone cannot guarantee that another request will not * create the user between this SELECT and our INSERT. - * enure this is pushed */ $user = $this->findByEmail($email); diff --git a/sonar-desk-licensing/bootstrap/providers.php b/sonar-desk-licensing/bootstrap/providers.php index cfe2c04..38b258d 100755 --- a/sonar-desk-licensing/bootstrap/providers.php +++ b/sonar-desk-licensing/bootstrap/providers.php @@ -1,7 +1,5 @@ =7.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.16", + "laravel/framework": "^8.0", + "mockery/mockery": "~1.0", + "orchestra/testbench": "^6.0", + "phpstan/phpstan": "^0.12.14", + "phpunit/phpunit": "^8.5", + "spatie/phpunit-snapshot-assertions": "^2.1.0" + }, + "default-branch": true, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "mpba\\Modules\\LaravelModulesServiceProvider" + ], + "aliases": { + "Module": "mpba\\Modules\\Facades\\Module" + } + }, + "branch-alias": { + "dev-master": "8.0-dev" + } + }, + "autoload": { + "psr-4": { + "mpba\\Modules\\": "src" + }, + "files": [ + "src/helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "mpba\\Modules\\Tests\\": "tests", + "Modules\\Recipe\\": "tests/stubs/valid/Recipe" + } + }, + "scripts": { + "update-snapshots": [ + "./vendor/bin/phpunit --no-coverage -d --update-snapshots" + ], + "test": [ + "vendor/bin/phpunit" + ], + "test-coverage": [ + "vendor/bin/phpunit --debug --coverage-html coverage" + ], + "pcf": [ + "vendor/bin/php-cs-fixer fix --verbose" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Gregory", + "email": "mark.gregory@mpba.co.uk", + "homepage": "https://mpba.co.uk", + "role": "Developer" + } + ], + "description": "Laravel Module management", + "keywords": [ + "laravel", + "module", + "modules", + "mpba", + "rad" + ], + "support": { + "source": "https://github.com/MPBA-Ltd/laravel_modules/tree/main", + "issues": "https://github.com/MPBA-Ltd/laravel_modules/issues" + }, + "time": "2026-09-30T13:50:27+00:00" + }, { "name": "nesbot/carbon", "version": "3.13.2", @@ -4875,16 +4970,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.37.0", + "version": "v1.43.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", "shasum": "" }, "require": { @@ -4935,7 +5030,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" }, "funding": [ { @@ -4955,7 +5050,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T16:19:22+00:00" + "time": "2026-09-25T15:50:05+00:00" }, { "name": "symfony/polyfill-php82", @@ -8709,7 +8804,9 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": { + "mpba/laravel-modules": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/sonar-desk-licensing/config/sonardesk.php b/sonar-desk-licensing/config/sonardesk.php index d56d04b..188be39 100644 --- a/sonar-desk-licensing/config/sonardesk.php +++ b/sonar-desk-licensing/config/sonardesk.php @@ -28,21 +28,6 @@ return [ 'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL), ], - 'paddle' => [ - 'api_key' => env('PADDLE_API_KEY'), - 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), - 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'), - 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), - 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), - 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), - - // SonarDesk is one Paddle product with four recurring prices. - 'product_id' => env('PADDLE_PRODUCT_ID'), - 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'), - 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'), - 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'), - 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'), - ], 'lemon_squeezy' => [ 'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'), diff --git a/sonar-desk-licensing/database/factories/UserFactory.php b/sonar-desk-licensing/database/factories/UserFactory.php new file mode 100644 index 0000000..c411c07 --- /dev/null +++ b/sonar-desk-licensing/database/factories/UserFactory.php @@ -0,0 +1,34 @@ + + */ +class UserFactory extends Factory +{ + protected static ?string $password; + + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + 'is_admin' => false, + ]; + } + + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/sonar-desk-licensing/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php b/sonar-desk-licensing/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php new file mode 100644 index 0000000..5633841 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_10_01_100000_add_professional_seo_to_cms_pages.php @@ -0,0 +1,35 @@ +string('meta_keywords', 500)->nullable()->after('meta_description'); + $table->string('canonical_url', 2048)->nullable()->after('meta_keywords'); + $table->string('og_title')->nullable()->after('canonical_url'); + $table->string('og_description', 320)->nullable()->after('og_title'); + $table->string('og_image', 2048)->nullable()->after('og_description'); + $table->boolean('robots_index')->default(true)->after('og_image'); + $table->boolean('robots_follow')->default(true)->after('robots_index'); + $table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index(); + $table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap'); + $table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority'); + }); + } + + public function down(): void + { + Schema::table('cms_pages', function (Blueprint $table): void { + $table->dropIndex(['include_in_sitemap']); + $table->dropColumn([ + 'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image', + 'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority', + 'sitemap_change_frequency', + ]); + }); + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php b/sonar-desk-licensing/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php new file mode 100644 index 0000000..4763179 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php @@ -0,0 +1,22 @@ + $table->timestamp('disabled_at')->nullable()->index()->after('is_admin')); + } + } + + public function down(): void + { + if (Schema::hasTable('users') && Schema::hasColumn('users', 'disabled_at')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('disabled_at')); + } + } +}; diff --git a/sonar-desk-licensing/database/migrations/2026_10_02_100000_create_failed_jobs_table.php b/sonar-desk-licensing/database/migrations/2026_10_02_100000_create_failed_jobs_table.php new file mode 100644 index 0000000..d0cebcc --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_10_02_100000_create_failed_jobs_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + public function down(): void + { + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/sonar-desk-licensing/deploy.php b/sonar-desk-licensing/deploy.php index dd96cd1..c7db5c1 100755 --- a/sonar-desk-licensing/deploy.php +++ b/sonar-desk-licensing/deploy.php @@ -19,13 +19,13 @@ add('writable_dirs', ['storage', 'bootstrap/cache']); // Host Definitions host('main') - ->set('hostname', 'sonardesk.absdev.net') + ->set('hostname', '217.154.47.197') ->set('remote_user', 'mag') ->set('branch', 'main') - ->set('deploy_path', '/var/www/sonardesk/main'); + ->set('deploy_path', '/var/www/sonardesk/prod'); host('stage') - ->set('hostname', 'sonardesk.stage.absdev.net') + ->set('hostname', '192.168.1.2') ->set('remote_user', 'mag') ->set('branch', 'stage') ->set('deploy_path', '/var/www/sonardesk/stage'); diff --git a/sonar-desk-licensing/AGENTS.md b/sonar-desk-licensing/docs/AGENTS.md similarity index 100% rename from sonar-desk-licensing/AGENTS.md rename to sonar-desk-licensing/docs/AGENTS.md diff --git a/sonar-desk-licensing/CLAUDE.md b/sonar-desk-licensing/docs/CLAUDE.md similarity index 100% rename from sonar-desk-licensing/CLAUDE.md rename to sonar-desk-licensing/docs/CLAUDE.md diff --git a/sonar-desk-licensing/sonar_desk b/sonar-desk-licensing/modules/Licensing/Config/.gitkeep similarity index 100% rename from sonar-desk-licensing/sonar_desk rename to sonar-desk-licensing/modules/Licensing/Config/.gitkeep diff --git a/sonar-desk-licensing/modules/Licensing/Config/config.php b/sonar-desk-licensing/modules/Licensing/Config/config.php new file mode 100644 index 0000000..3b94eda --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Config/config.php @@ -0,0 +1,8 @@ + 'Licensing', + 'features' => + [ + ], +]; diff --git a/sonar-desk-licensing/modules/Licensing/Console/.gitkeep b/sonar-desk-licensing/modules/Licensing/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseDeliver.php b/sonar-desk-licensing/modules/Licensing/Console/SonarDeskLicenseDeliver.php similarity index 86% rename from sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseDeliver.php rename to sonar-desk-licensing/modules/Licensing/Console/SonarDeskLicenseDeliver.php index 68d5efe..67e5050 100644 --- a/sonar-desk-licensing/app/Console/Commands/SonarDeskLicenseDeliver.php +++ b/sonar-desk-licensing/modules/Licensing/Console/SonarDeskLicenseDeliver.php @@ -1,8 +1,8 @@ id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('edition')->default('Professional'); + $table->unsignedInteger('activation_limit')->default(1); + $table->unsignedInteger('max_major_version')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_product_id')->nullable(); + $table->string('external_price_id')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->unique(['payment_provider', 'external_price_id']); + }); + + Schema::create('licenses', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignId('license_product_id')->constrained('license_products'); + $table->string('license_key_hash', 64)->unique(); + $table->string('license_key_last4', 4); + $table->string('customer_name')->nullable(); + $table->string('customer_email')->nullable()->index(); + $table->string('edition'); + $table->unsignedInteger('activation_limit'); + $table->unsignedInteger('max_major_version')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->string('revoke_reason')->nullable(); + $table->string('payment_provider')->nullable(); + $table->string('external_order_id')->nullable(); + $table->string('external_customer_id')->nullable(); + $table->timestamps(); + $table->unique(['payment_provider', 'external_order_id']); + }); + + Schema::create('license_delivery_outbox', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id')->unique(); + $table->text('encrypted_license_key'); + $table->string('recipient_email')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamps(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + }); + + Schema::create('license_activations', function (Blueprint $table) { + $table->id(); + $table->uuid('license_id'); + $table->uuid('installation_id'); + $table->string('platform', 32)->nullable(); + $table->string('app_version', 32)->nullable(); + $table->timestamp('activated_at'); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamp('deactivated_at')->nullable(); + $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete(); + $table->unique(['license_id', 'installation_id']); + $table->index(['license_id', 'deactivated_at']); + }); + + Schema::create('license_webhook_events', function (Blueprint $table) { + $table->id(); + $table->string('provider'); + $table->string('external_event_id'); + $table->string('event_type'); + $table->string('payload_sha256', 64); + $table->json('payload'); + $table->timestamp('processed_at')->nullable(); + $table->text('processing_error')->nullable(); + $table->timestamps(); + $table->unique(['provider', 'external_event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('license_webhook_events'); + Schema::dropIfExists('license_activations'); + Schema::dropIfExists('license_delivery_outbox'); + Schema::dropIfExists('licenses'); + Schema::dropIfExists('license_products'); + } +}; diff --git a/sonar-desk-licensing/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/sonar-desk-licensing/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php new file mode 100755 index 0000000..b302e12 --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php @@ -0,0 +1,30 @@ + $table->boolean('is_admin')->default(false)->index()); + } + + if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id')); + } + } + + public function down(): void + { + if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) { + Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name')); + } + + if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) { + Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin')); + } + } +}; diff --git a/sonar-desk-licensing/modules/Licensing/Database/Seeders/.gitkeep b/sonar-desk-licensing/modules/Licensing/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php b/sonar-desk-licensing/modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php new file mode 100644 index 0000000..fbb0b58 --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php @@ -0,0 +1,21 @@ +call("OthersTableSeeder"); + } +} diff --git a/sonar-desk-licensing/modules/Licensing/Database/factories/.gitkeep b/sonar-desk-licensing/modules/Licensing/Database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Helpers/.gitkeep b/sonar-desk-licensing/modules/Licensing/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Helpers/Helper.php b/sonar-desk-licensing/modules/Licensing/Helpers/Helper.php new file mode 100644 index 0000000..7607c60 --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Helpers/Helper.php @@ -0,0 +1,12 @@ +external_price_id; if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) { diff --git a/sonar-desk-licensing/modules/Licensing/Http/Middleware/.gitkeep b/sonar-desk-licensing/modules/Licensing/Http/Middleware/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Http/Requests/.gitkeep b/sonar-desk-licensing/modules/Licensing/Http/Requests/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Models/.gitkeep b/sonar-desk-licensing/modules/Licensing/Models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/app/Models/License.php b/sonar-desk-licensing/modules/Licensing/Models/License.php similarity index 97% rename from sonar-desk-licensing/app/Models/License.php rename to sonar-desk-licensing/modules/Licensing/Models/License.php index c3dcc56..86e5c6a 100755 --- a/sonar-desk-licensing/app/Models/License.php +++ b/sonar-desk-licensing/modules/Licensing/Models/License.php @@ -1,6 +1,6 @@ Limit::perMinute(5) + ->by(strtolower((string) $request->input('email')).'|'.$request->ip())); + + RateLimiter::for('registration', fn (Request $request) => + Limit::perHour(5)->by($request->ip()) + ); + + RateLimiter::for('license-activation', function (Request $request): array { + return [ + Limit::perMinute(12)->by($request->ip()), + Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))), + ]; + }); + $this->registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->registerHelpers(); + $this->registerCommands(); + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register(): void + { + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register the helpers. + * + * @return void + */ + protected function registerHelpers(): void + { + foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { + require_once $filename; + } + } + + /** + * Dynamically Register Commands. + * + * @return void + */ + public function registerCommands(): void + { + if (!$this->app->runningInConsole()) { + return; + } + $commandPath = __DIR__ . '/../Console'; + $commands = collect(File::allFiles($commandPath)) + ->map(function ($file) { + $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); + return $class; + }) + ->filter(function ($class) { + return class_exists($class); + }) + ->values() + ->all(); + $this->commands($commands); + } + + /** + * Register config. + * + * @return void + */ + protected function registerConfig(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), + ], 'config'); + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() + ); + } + + /** + * Register views. + * + * @return void + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $this->publishes([ + $sourcePath => $viewPath + ], ['views', ModuleInfo::nameLower() . '-module-views']); + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); + } + + /** + * Register translations. + * + * @return void + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + } else { + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + } + } + + /** + * Regsiter View Paths + * @return array + */ + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (Config::get('view.paths') as $path) { + if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) { + $paths[] = $path . '/modules/' . ModuleInfo::nameLower(); + } + } + return $paths; + } +} diff --git a/sonar-desk-licensing/modules/Licensing/Providers/RouteServiceProvider.php b/sonar-desk-licensing/modules/Licensing/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..c691ba8 --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Providers/RouteServiceProvider.php @@ -0,0 +1,69 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->moduleNamespace) + ->group(module_path('Licensing', '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('Licensing', '/Routes/api.php')); + } +} diff --git a/sonar-desk-licensing/modules/Licensing/Resources/assets/.gitkeep b/sonar-desk-licensing/modules/Licensing/Resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Resources/assets/js/app.js b/sonar-desk-licensing/modules/Licensing/Resources/assets/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Resources/assets/sass/app.scss b/sonar-desk-licensing/modules/Licensing/Resources/assets/sass/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Resources/lang/.gitkeep b/sonar-desk-licensing/modules/Licensing/Resources/lang/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Resources/views/.gitkeep b/sonar-desk-licensing/modules/Licensing/Resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Resources/views/index.blade.php b/sonar-desk-licensing/modules/Licensing/Resources/views/index.blade.php new file mode 100644 index 0000000..4277b06 --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Resources/views/index.blade.php @@ -0,0 +1,9 @@ +@extends('licensing::layouts.master') + +@section('content') +

Hello World

+ +

+ This view is loaded from module: {!! config('licensing.name') !!} +

+@endsection diff --git a/sonar-desk-licensing/modules/Licensing/Resources/views/layouts/master.blade.php b/sonar-desk-licensing/modules/Licensing/Resources/views/layouts/master.blade.php new file mode 100644 index 0000000..1e51f1c --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Resources/views/layouts/master.blade.php @@ -0,0 +1,19 @@ + + + + + + + Module Licensing + + {{-- Laravel Mix - CSS File --}} + {{-- --}} + + + + @yield('content') + + {{-- Laravel Mix - JS File --}} + {{-- --}} + + diff --git a/sonar-desk-licensing/modules/Licensing/Routes/.gitkeep b/sonar-desk-licensing/modules/Licensing/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Licensing/Routes/api.php b/sonar-desk-licensing/modules/Licensing/Routes/api.php new file mode 100644 index 0000000..a923714 --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Routes/api.php @@ -0,0 +1,12 @@ +group(function (): void { + Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); + Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); + Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); + Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']); +}); diff --git a/sonar-desk-licensing/modules/Licensing/Routes/web.php b/sonar-desk-licensing/modules/Licensing/Routes/web.php new file mode 100644 index 0000000..cfe4d88 --- /dev/null +++ b/sonar-desk-licensing/modules/Licensing/Routes/web.php @@ -0,0 +1,13 @@ +prefix('portal')->name('portal.')->group(function (): void { + Route::get('/', [PortalController::class, 'index'])->name('index'); + Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show'); + Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade'); + Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel'); + Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend'); + Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate'); +}); diff --git a/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php b/sonar-desk-licensing/modules/Licensing/Services/LicenseDeliveryService.php similarity index 94% rename from sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php rename to sonar-desk-licensing/modules/Licensing/Services/LicenseDeliveryService.php index 0c1e584..053600d 100755 --- a/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php +++ b/sonar-desk-licensing/modules/Licensing/Services/LicenseDeliveryService.php @@ -1,8 +1,8 @@ 'Paddle', + 'api_key' => env('PADDLE_API_KEY'), + 'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'), + 'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'), + 'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'), + 'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'), + 'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300), + 'product_id' => env('PADDLE_PRODUCT_ID'), + 'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'), + 'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'), + 'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'), + 'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'), +]; diff --git a/sonar-desk-licensing/modules/Paddle/Console/.gitkeep b/sonar-desk-licensing/modules/Paddle/Console/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Database/Migrations/.gitkeep b/sonar-desk-licensing/modules/Paddle/Database/Migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php b/sonar-desk-licensing/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php new file mode 100755 index 0000000..ef60b76 --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Database/Migrations/2026_09_09_100000_add_paddle_subscription_fields.php @@ -0,0 +1,34 @@ +string('external_subscription_id')->nullable()->after('external_customer_id'); + $table->string('external_price_id')->nullable()->after('external_subscription_id'); + $table->string('subscription_status')->nullable()->after('external_price_id'); + $table->timestamp('current_period_ends_at')->nullable()->after('subscription_status'); + $table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at'); + $table->unique(['payment_provider', 'external_subscription_id']); + }); + } + + public function down(): void + { + Schema::table('licenses', function (Blueprint $table) { + $table->dropUnique(['payment_provider', 'external_subscription_id']); + $table->dropColumn([ + 'external_subscription_id', + 'external_price_id', + 'subscription_status', + 'current_period_ends_at', + 'cancel_scheduled_at', + ]); + }); + } +}; diff --git a/sonar-desk-licensing/modules/Paddle/Database/Seeders/.gitkeep b/sonar-desk-licensing/modules/Paddle/Database/Seeders/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/database/seeders/PaddleCatalogSeeder.php b/sonar-desk-licensing/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php similarity index 61% rename from sonar-desk-licensing/database/seeders/PaddleCatalogSeeder.php rename to sonar-desk-licensing/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php index 1c23d60..203ffe5 100755 --- a/sonar-desk-licensing/database/seeders/PaddleCatalogSeeder.php +++ b/sonar-desk-licensing/modules/Paddle/Database/Seeders/PaddleCatalogSeeder.php @@ -1,8 +1,8 @@ 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.personal_monthly_price_id')], - ['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.personal_annual_price_id')], - ['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.professional_monthly_price_id')], - ['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.professional_annual_price_id')], + ['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.personal_monthly_price_id')], + ['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.personal_annual_price_id')], + ['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.professional_monthly_price_id')], + ['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.professional_annual_price_id')], ]; foreach ($products as $data) { diff --git a/sonar-desk-licensing/modules/Paddle/Database/Seeders/PaddleDatabaseSeeder.php b/sonar-desk-licensing/modules/Paddle/Database/Seeders/PaddleDatabaseSeeder.php new file mode 100644 index 0000000..b413999 --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Database/Seeders/PaddleDatabaseSeeder.php @@ -0,0 +1,21 @@ +call("OthersTableSeeder"); + } +} diff --git a/sonar-desk-licensing/modules/Paddle/Database/factories/.gitkeep b/sonar-desk-licensing/modules/Paddle/Database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Helpers/.gitkeep b/sonar-desk-licensing/modules/Paddle/Helpers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Helpers/Helper.php b/sonar-desk-licensing/modules/Paddle/Helpers/Helper.php new file mode 100644 index 0000000..4e6aad8 --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Helpers/Helper.php @@ -0,0 +1,12 @@ +registerTranslations(); + $this->registerConfig(); + $this->registerViews(); + $this->registerHelpers(); + $this->registerCommands(); + $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations')); + } + + /** + * Register the service provider. + * + * @return void + */ + public function register(): void + { + $this->app->register(RouteServiceProvider::class); + } + + /** + * Register the helpers. + * + * @return void + */ + protected function registerHelpers(): void + { + foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) { + require_once $filename; + } + } + + /** + * Dynamically Register Commands. + * + * @return void + */ + public function registerCommands(): void + { + if (!$this->app->runningInConsole()) { + return; + } + $commandPath = __DIR__ . '/../Console'; + $commands = collect(File::allFiles($commandPath)) + ->map(function ($file) { + $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension(); + return $class; + }) + ->filter(function ($class) { + return class_exists($class); + }) + ->values() + ->all(); + $this->commands($commands); + } + + /** + * Register config. + * + * @return void + */ + protected function registerConfig(): void + { + $this->publishes([ + module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'), + ], 'config'); + $this->mergeConfigFrom( + module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower() + ); + } + + /** + * Register views. + * + * @return void + */ + public function registerViews(): void + { + $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower()); + $sourcePath = module_path(ModuleInfo::name(), 'Resources/views'); + $this->publishes([ + $sourcePath => $viewPath + ], ['views', ModuleInfo::nameLower() . '-module-views']); + $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower()); + } + + /** + * Register translations. + * + * @return void + */ + public function registerTranslations(): void + { + $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower()); + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower()); + } else { + $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower()); + } + } + + /** + * Regsiter View Paths + * @return array + */ + private function getPublishableViewPaths(): array + { + $paths = []; + foreach (Config::get('view.paths') as $path) { + if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) { + $paths[] = $path . '/modules/' . ModuleInfo::nameLower(); + } + } + return $paths; + } +} diff --git a/sonar-desk-licensing/modules/Paddle/Providers/RouteServiceProvider.php b/sonar-desk-licensing/modules/Paddle/Providers/RouteServiceProvider.php new file mode 100644 index 0000000..3ef031c --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Providers/RouteServiceProvider.php @@ -0,0 +1,69 @@ +mapApiRoutes(); + + $this->mapWebRoutes(); + } + + /** + * Define the "web" routes for the application. + * + * These routes all receive session state, CSRF protection, etc. + * + * @return void + */ + protected function mapWebRoutes() + { + Route::middleware('web') + ->namespace($this->moduleNamespace) + ->group(module_path('Paddle', '/Routes/web.php')); + } + + /** + * Define the "api" routes for the application. + * + * These routes are typically stateless. + * + * @return void + */ + protected function mapApiRoutes() + { + Route::prefix('api') + ->middleware('api') + ->namespace($this->moduleNamespace) + ->group(module_path('Paddle', '/Routes/api.php')); + } +} diff --git a/sonar-desk-licensing/modules/Paddle/Resources/assets/.gitkeep b/sonar-desk-licensing/modules/Paddle/Resources/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Resources/assets/js/app.js b/sonar-desk-licensing/modules/Paddle/Resources/assets/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Resources/assets/sass/app.scss b/sonar-desk-licensing/modules/Paddle/Resources/assets/sass/app.scss new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Resources/lang/.gitkeep b/sonar-desk-licensing/modules/Paddle/Resources/lang/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Resources/views/.gitkeep b/sonar-desk-licensing/modules/Paddle/Resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Resources/views/index.blade.php b/sonar-desk-licensing/modules/Paddle/Resources/views/index.blade.php new file mode 100644 index 0000000..658045d --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Resources/views/index.blade.php @@ -0,0 +1,9 @@ +@extends('paddle::layouts.master') + +@section('content') +

Hello World

+ +

+ This view is loaded from module: {!! config('paddle.name') !!} +

+@endsection diff --git a/sonar-desk-licensing/modules/Paddle/Resources/views/layouts/master.blade.php b/sonar-desk-licensing/modules/Paddle/Resources/views/layouts/master.blade.php new file mode 100644 index 0000000..b42fb10 --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Resources/views/layouts/master.blade.php @@ -0,0 +1,19 @@ + + + + + + + Module Paddle + + {{-- Laravel Mix - CSS File --}} + {{-- --}} + + + + @yield('content') + + {{-- Laravel Mix - JS File --}} + {{-- --}} + + diff --git a/sonar-desk-licensing/modules/Paddle/Routes/.gitkeep b/sonar-desk-licensing/modules/Paddle/Routes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Routes/api.php b/sonar-desk-licensing/modules/Paddle/Routes/api.php new file mode 100644 index 0000000..dbc50ef --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Routes/api.php @@ -0,0 +1,18 @@ +get('/paddle', function (Request $request) { + return $request->user(); +}); \ No newline at end of file diff --git a/sonar-desk-licensing/modules/Paddle/Routes/web.php b/sonar-desk-licensing/modules/Paddle/Routes/web.php new file mode 100644 index 0000000..a623b34 --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Routes/web.php @@ -0,0 +1,6 @@ +name('buy'); diff --git a/sonar-desk-licensing/app/Services/Payments/PaddleClient.php b/sonar-desk-licensing/modules/Paddle/Services/PaddleClient.php similarity index 93% rename from sonar-desk-licensing/app/Services/Payments/PaddleClient.php rename to sonar-desk-licensing/modules/Paddle/Services/PaddleClient.php index f2f23aa..3585df4 100755 --- a/sonar-desk-licensing/app/Services/Payments/PaddleClient.php +++ b/sonar-desk-licensing/modules/Paddle/Services/PaddleClient.php @@ -1,17 +1,17 @@ withToken( - (string) config('sonardesk.paddle.api_key') + (string) config('paddle.api_key') ) ->acceptJson() ->timeout(15); @@ -131,7 +131,7 @@ final class PaddleClient { if ( trim( - (string) config('sonardesk.paddle.api_key') + (string) config('paddle.api_key') ) === '' ) { throw new RuntimeException( diff --git a/sonar-desk-licensing/app/Services/Payments/PaddleWebhookVerifier.php b/sonar-desk-licensing/modules/Paddle/Services/PaddleWebhookVerifier.php similarity index 93% rename from sonar-desk-licensing/app/Services/Payments/PaddleWebhookVerifier.php rename to sonar-desk-licensing/modules/Paddle/Services/PaddleWebhookVerifier.php index 477bcb7..5ba1ba2 100755 --- a/sonar-desk-licensing/app/Services/Payments/PaddleWebhookVerifier.php +++ b/sonar-desk-licensing/modules/Paddle/Services/PaddleWebhookVerifier.php @@ -1,6 +1,8 @@ header('Paddle-Signature')); if ($secret === '') { @@ -55,7 +57,7 @@ final class PaddleWebhookVerifier implements WebhookVerifier $tolerance = max( 1, - (int) config('sonardesk.paddle.timestamp_tolerance', 300) + (int) config('paddle.timestamp_tolerance', 300) ); $age = abs(time() - $timestamp); diff --git a/sonar-desk-licensing/app/Services/Payments/PurchaseProvisioner.php b/sonar-desk-licensing/modules/Paddle/Services/PurchaseProvisioner.php similarity index 97% rename from sonar-desk-licensing/app/Services/Payments/PurchaseProvisioner.php rename to sonar-desk-licensing/modules/Paddle/Services/PurchaseProvisioner.php index 03b231b..2209f9c 100755 --- a/sonar-desk-licensing/app/Services/Payments/PurchaseProvisioner.php +++ b/sonar-desk-licensing/modules/Paddle/Services/PurchaseProvisioner.php @@ -1,12 +1,12 @@ getDriverName() !== 'mysql') { + return $callback(); + } + $lockName = 'sd:'.substr(hash('sha256', $name), 0, 61); $row = DB::selectOne( diff --git a/sonar-desk-licensing/modules/Paddle/Support/.gitkeep b/sonar-desk-licensing/modules/Paddle/Support/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/modules/Paddle/Support/ModuleInfo.php b/sonar-desk-licensing/modules/Paddle/Support/ModuleInfo.php new file mode 100644 index 0000000..a64f0c0 --- /dev/null +++ b/sonar-desk-licensing/modules/Paddle/Support/ModuleInfo.php @@ -0,0 +1,26 @@ +Slug -
+
+
Search appearance

Page SEO

Override global defaults only where this page needs specific search or social metadata.

Global SEO settings
+
+ + +
+ +
+
+ +
@if($page->exists && $page->is_published)View page@endif
diff --git a/sonar-desk-licensing/resources/views/admin/index.blade.php b/sonar-desk-licensing/resources/views/admin/index.blade.php index f4198c6..61766a6 100755 --- a/sonar-desk-licensing/resources/views/admin/index.blade.php +++ b/sonar-desk-licensing/resources/views/admin/index.blade.php @@ -2,5 +2,5 @@ @section('content')
Admin

Licensing administration

Manage licences, activations and customer support.

{{ $licenseCount }}

Licences

{{ $activeCount }}

Not revoked

{{ $activationCount }}

Active installations

{{ $userCount }}

Portal users

-
Manage releasesManage licencesManage contentSupport inbox
+
Manage releasesManage licencesManage usersManage contentSEO & searchSupport inbox
@endsection diff --git a/sonar-desk-licensing/resources/views/admin/seo/edit.blade.php b/sonar-desk-licensing/resources/views/admin/seo/edit.blade.php new file mode 100644 index 0000000..67fcb35 --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/seo/edit.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.app') +@section('content') +
+
Search & discovery

SEO settings

Control how SonarDesk appears in search engines and when pages are shared.

+
View sitemapView robots.txt
+
+
@csrf @method('PUT') +
+
Search appearance

Site defaults

Fallback metadata used whenever a page does not provide its own SEO values.

Global
+
+ + +
+ + + + +
+ +
+
Social sharing

Open Graph & social

Professional defaults for link previews across social networks and messaging applications.

+ +
+ +
+ +
+
Google Search Console

Domain verification

Use a Domain property in Google Search Console. Google will provide a TXT value that you add at your DNS provider.

DNS TXT
+
Recommended verification methodAdd Google’s google-site-verification=… TXT record to the root domain. DNS verification covers all protocols and subdomains.
+ +
+ +
+
Crawler control

Indexing

Production crawler controls. Non-production environments remain blocked automatically.

+ +
+
Search configurationChanges apply to public metadata immediately.
+
+@endsection diff --git a/sonar-desk-licensing/resources/views/admin/users/index.blade.php b/sonar-desk-licensing/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..9b6d9ef --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/users/index.blade.php @@ -0,0 +1,31 @@ +@extends('layouts.app') +@section('content') +
+
Admin

Users

Manage portal accounts, access and customer licences.

+ Admin dashboard +
+ +
+ + + + @if(request()->hasAny(['q','status']))Clear@endif +
+ +
+

Portal users

{{ $users->total() }} account{{ $users->total() === 1 ? '' : 's' }}

+
+ @forelse($users as $user) + + + + + + + + + @empty@endforelse +
UserAccessEmailLicencesJoined
{{ $user->name }}#{{ $user->id }}@if($user->disabled_at)Suspended@elseif($user->is_admin)Admin@elseActive@endif{{ $user->email }}{{ $user->hasVerifiedEmail() ? 'Verified' : 'Not verified' }}{{ $licenseCounts[strtolower($user->email)] ?? 0 }}{{ $user->created_at?->format('d M Y') }}
No users found.
+
+
{{ $users->links() }}
+@endsection diff --git a/sonar-desk-licensing/resources/views/admin/users/show.blade.php b/sonar-desk-licensing/resources/views/admin/users/show.blade.php new file mode 100644 index 0000000..825a9e1 --- /dev/null +++ b/sonar-desk-licensing/resources/views/admin/users/show.blade.php @@ -0,0 +1,57 @@ +@extends('layouts.app') +@section('content') +
+
Admin · User

{{ $user->name }}

{{ $user->email }}

+ Back to users +
+ +@if($errors->any())
Could not save changes
@endif + +
+
+
+
01

Account details

Edit the portal identity and administrator access.

+
@csrf @method('PUT') +
+ + @if(auth()->user()->is($user))

Your own administrator access is protected.

@endif + +
+
+ +
+
02

Licences

Licences linked to this user's email address.

+ @forelse($licenses as $license) +
+
+
{{ $license->edition }} · •••• {{ $license->license_key_last4 }}{{ $license->subscription_status ?: 'No subscription status' }} · {{ $license->payment_provider ?: 'manual' }}
+ +
+
@csrf @method('PATCH')
+ @if($license->activations->isNotEmpty()) +
+ @foreach($license->activations as $activation)@endforeach +
DevicePlatformVersionStatus
{{ $activation->installation_name ?: $activation->installation_id }}{{ $activation->platform ?: '—' }}{{ $activation->app_version ?: '—' }}{{ $activation->deactivated_at ? 'Released' : 'Active' }}@if(!$activation->deactivated_at)
@csrf @method('DELETE')
@endif
+ @endif +
+ @empty

No licences are linked to this email address.

@endforelse +
+
+ + +
+@endsection diff --git a/sonar-desk-licensing/resources/views/checkout/buy.blade.php b/sonar-desk-licensing/resources/views/checkout/buy.blade.php index cb36c03..525fc4c 100755 --- a/sonar-desk-licensing/resources/views/checkout/buy.blade.php +++ b/sonar-desk-licensing/resources/views/checkout/buy.blade.php @@ -1,91 +1,301 @@ @extends('layouts.app') + @section('content') -
- SonarDesk -

Choose your licence

-

Every subscription starts with a 7-day free trial. Cancel before the trial ends and you won't be charged.

- @if($paddleEnvironment === 'sandbox')
Sandbox checkout — no real payment is taken
@endif -
+
+ SonarDesk +

Choose your licence

+

+ Every subscription starts with a 7-day free trial. + Cancel before the trial ends and you won't be charged. +

-
-@foreach($plans as $code => $plan) -
-
-
-
-

{{ $plan['name'] }}

-
{{ $plan['billing'] }} billing
-
- @if($plan['billing'] === 'Annual')Best value@endif + @if($paddleEnvironment === 'sandbox') +
+ Sandbox checkout — no real payment is taken
-
{{ $plan['display_price'] }}
-
    -
  • 7-day free trial
  • -
  • {{ $plan['activation_limit'] }} active Mac{{ $plan['activation_limit'] === 1 ? '' : 's' }}
  • -
  • Secure online activation
  • -
  • Licence management portal
  • -
-
- -
-@endforeach -
+ @endif +
- +
+ @foreach($plans as $code => $plan) +
+
+
+
+

{{ $plan['name'] }}

+
{{ $plan['billing'] }} billing
+
- - + + + + try { + if (environment === 'sandbox') { + Paddle.Environment.set('sandbox'); + } + + Paddle.Initialize({ + token: token, + eventCallback: logPaddleEvent + }); + + console.log('Paddle initialized successfully.'); + } catch (error) { + console.error('PADDLE INITIALIZATION ERROR:', error); + + showCheckoutError( + error?.message || 'Paddle could not be initialized.' + ); + + return; + } + + const priceElements = new Map( + Array.from( + document.querySelectorAll('[data-price-display]') + ).map(element => [ + element.dataset.priceDisplay, + element + ]) + ); + + const previewItems = Array.from(priceElements.keys()).map(priceId => ({ + priceId: priceId, + quantity: 1 + })); + + console.log('PADDLE PRICE PREVIEW ITEMS:', previewItems); + + Paddle.PricePreview({ + items: previewItems + }) + .then(result => { + console.log('PADDLE PRICE PREVIEW RESULT:', result); + + for (const item of (result?.data?.details?.lineItems ?? [])) { + const element = priceElements.get(item?.price?.id); + + const formatted = + item?.formattedTotals?.total ?? + item?.formattedUnitTotals?.total; + + if (!element || !formatted) { + continue; + } + + const suffix = + element.dataset.billing === 'annual' + ? ' / year' + : ' / month'; + + element.textContent = formatted + suffix; + } + }) + .catch(error => { + console.error('PADDLE PRICE PREVIEW ERROR:', error); + + // Server-rendered GBP price remains visible. + }); + + document + .querySelectorAll('.checkout-button') + .forEach(button => { + button.addEventListener('click', () => { + const priceId = button.dataset.priceId; + const plan = button.dataset.plan; + + console.log('OPENING PADDLE CHECKOUT:', { + environment: environment, + priceId: priceId, + plan: plan + }); + + errorPanel.hidden = true; + + try { + Paddle.Checkout.open({ + items: [ + { + priceId: priceId, + quantity: 1 + } + ], + + settings: { + displayMode: 'overlay', + theme: window.matchMedia( + '(prefers-color-scheme: dark)' + ).matches + ? 'dark' + : 'light', + allowLogout: false + }, + + customData: { + source: 'sonardesk-buy-page', + plan: plan + } + }); + } catch (error) { + console.error( + 'PADDLE CHECKOUT OPEN ERROR:', + error + ); + + showCheckoutError( + error?.message || + 'Paddle could not open the checkout.' + ); + } + }); + }); + })(); + @endsection diff --git a/sonar-desk-licensing/resources/views/cms/show.blade.php b/sonar-desk-licensing/resources/views/cms/show.blade.php index 909e3cf..35da777 100644 --- a/sonar-desk-licensing/resources/views/cms/show.blade.php +++ b/sonar-desk-licensing/resources/views/cms/show.blade.php @@ -1,4 +1,14 @@ -@extends('layouts.app', ['title' => $page->meta_title ?: $page->title.' — SonarDesk', 'description' => $page->meta_description ?: $page->excerpt]) +@extends('layouts.app', [ + 'title' => $page->meta_title ?: $page->title.' — '.\App\Models\SiteSetting::valueFor('seo_site_name', 'SonarDesk'), + 'description' => $page->meta_description ?: $page->excerpt, + 'keywords' => $page->meta_keywords, + 'canonical' => $page->canonical_url ?: null, + 'ogTitle' => $page->og_title ?: null, + 'ogDescription' => $page->og_description ?: null, + 'ogImage' => $page->og_image ?: null, + 'robotsIndex' => $page->robots_index, + 'robotsFollow' => $page->robots_follow, +]) @section('content') @endsection diff --git a/sonar-desk-licensing/resources/views/components/layouts/app.blade.php b/sonar-desk-licensing/resources/views/components/layouts/app.blade.php new file mode 100644 index 0000000..8f13014 --- /dev/null +++ b/sonar-desk-licensing/resources/views/components/layouts/app.blade.php @@ -0,0 +1,9 @@ +@props(['title' => null]) + +@extends('layouts.app') + +@section('title', $title ?? config('app.name')) + +@section('content') + {{ $slot }} +@endsection diff --git a/sonar-desk-licensing/resources/views/downloads/appcast.blade.php b/sonar-desk-licensing/resources/views/downloads/appcast.blade.php index ca21258..a7060d9 100644 --- a/sonar-desk-licensing/resources/views/downloads/appcast.blade.php +++ b/sonar-desk-licensing/resources/views/downloads/appcast.blade.php @@ -1,2 +1,2 @@ -{!! '' !!} +@php echo ''; @endphp SonarDesk Updates{{ route('updates.appcast') }}SonarDesk stable release feed@foreach($releases as $release)SonarDesk {{ $release->version }}{{ $release->published_at?->toRfc2822String() }}{{ $release->build_number }}{{ $release->version }}@if($release->minimum_macos){{ $release->minimum_macos }}@endifrelease_notes ?: 'SonarDesk update')) !!}]]>sparkle_signature) sparkle:edSignature="{{ $release->sparkle_signature }}"@endif />@endforeach diff --git a/sonar-desk-licensing/resources/views/layouts/app.blade.php b/sonar-desk-licensing/resources/views/layouts/app.blade.php index 4dbe278..99d2e8f 100755 --- a/sonar-desk-licensing/resources/views/layouts/app.blade.php +++ b/sonar-desk-licensing/resources/views/layouts/app.blade.php @@ -6,26 +6,45 @@ @php - $pageTitle = $title ?? 'SonarDesk'; - $pageDescription = $description ?? 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.'; - $canonicalUrl = url()->current(); - $socialImage = url('/icons/og-sonardesk.png'); + $seoSiteName = \App\Models\SiteSetting::valueFor('seo_site_name', 'SonarDesk'); + $seoDefaultTitle = \App\Models\SiteSetting::valueFor('seo_default_title', 'SonarDesk — Native SonarQube Monitoring for macOS'); + $seoDefaultDescription = \App\Models\SiteSetting::valueFor('seo_default_description', 'Native SonarQube monitoring for macOS. Track projects, quality gates, bugs, vulnerabilities and code smells from one professional desktop app.'); + $pageTitle = $title ?? $seoDefaultTitle; + $pageDescription = $description ?? $seoDefaultDescription; + $pageKeywords = $keywords ?? \App\Models\SiteSetting::valueFor('seo_default_keywords'); + $canonicalBase = rtrim(\App\Models\SiteSetting::valueFor('seo_canonical_base_url', config('app.url')), '/'); + $canonicalUrl = $canonical ?? ($canonicalBase . request()->getPathInfo()); + $socialImage = $ogImage ?? \App\Models\SiteSetting::valueFor('seo_default_og_image', url('/icons/og-sonardesk.png')); + $ogTitle = $ogTitle ?? $pageTitle; + $ogDescription = $ogDescription ?? $pageDescription; + $robotsIndex = $robotsIndex ?? true; + $robotsFollow = $robotsFollow ?? true; + $productionIndexing = app()->environment('production') && \App\Models\SiteSetting::booleanFor('seo_robots_enabled', true); + $robots = $productionIndexing ? (($robotsIndex ? 'index' : 'noindex').','.($robotsFollow ? 'follow' : 'nofollow').',max-image-preview:large,max-snippet:-1,max-video-preview:-1') : 'noindex,nofollow'; + $twitterSite = \App\Models\SiteSetting::valueFor('seo_twitter_site'); + $googleVerification = \App\Models\SiteSetting::valueFor('seo_google_site_verification'); + $organizationName = \App\Models\SiteSetting::valueFor('seo_organization_name', $seoSiteName); + $organizationLogo = \App\Models\SiteSetting::valueFor('seo_organization_logo', url('/icons/sonardesk-icon-512.png')); + $schema = [ + '@context' => 'https://schema.org', '@type' => 'SoftwareApplication', 'name' => $seoSiteName, + 'applicationCategory' => 'DeveloperApplication', 'operatingSystem' => 'macOS', 'url' => route('home'), + 'description' => $seoDefaultDescription, 'publisher' => ['@type' => 'Organization', 'name' => $organizationName, 'logo' => ['@type' => 'ImageObject', 'url' => $organizationLogo]], + ]; @endphp {{ $pageTitle }} + @if($pageKeywords)@endif + + + @if($googleVerification)@endif - - + + - @if(app()->environment('production')) - - @else - - @endif @@ -36,17 +55,18 @@ - - - + + + - - - - - + + + @if($twitterSite)@endif + + + @stack('head') @@ -207,6 +229,7 @@ @endphp Admin + Users Releases Support diff --git a/sonar-desk-licensing/resources/views/seo/sitemap.blade.php b/sonar-desk-licensing/resources/views/seo/sitemap.blade.php new file mode 100644 index 0000000..517eb1c --- /dev/null +++ b/sonar-desk-licensing/resources/views/seo/sitemap.blade.php @@ -0,0 +1,18 @@ +{!! '' !!} + +@foreach($static as $item) + + {{ route($item['route']) }} + {{ $item['frequency'] }} + {{ $item['priority'] }} + +@endforeach +@foreach($pages as $page) + + {{ route('cms.show', $page->slug) }} + {{ $page->updated_at->toAtomString() }} + {{ $page->sitemap_change_frequency }} + {{ number_format((float) $page->sitemap_priority, 1, '.', '') }} + +@endforeach + diff --git a/sonar-desk-licensing/routes/api.php b/sonar-desk-licensing/routes/api.php index 9548279..deb587a 100755 --- a/sonar-desk-licensing/routes/api.php +++ b/sonar-desk-licensing/routes/api.php @@ -1,12 +1,3 @@ group(function () { - Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation'); - Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation'); - Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation'); - Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']); -}); +// Module-owned API routes are loaded by their RouteServiceProviders. diff --git a/sonar-desk-licensing/routes/console.php b/sonar-desk-licensing/routes/console.php index 6fff514..d790415 100755 --- a/sonar-desk-licensing/routes/console.php +++ b/sonar-desk-licensing/routes/console.php @@ -6,5 +6,5 @@ use Illuminate\Support\Facades\Schedule; Schedule::command('model:prune')->dailyAt('03:20')->withoutOverlapping(); Schedule::call(function (): void { - app(\App\Services\Licensing\LicenseDeliveryService::class)->deliverPending(50); + app(\Modules\Licensing\Services\LicenseDeliveryService::class)->deliverPending(50); })->name('license-delivery')->everyMinute()->withoutOverlapping(); diff --git a/sonar-desk-licensing/routes/web.php b/sonar-desk-licensing/routes/web.php index 0438c31..a3e6d6f 100755 --- a/sonar-desk-licensing/routes/web.php +++ b/sonar-desk-licensing/routes/web.php @@ -4,20 +4,23 @@ use App\Http\Controllers\Admin\AdminController; use App\Http\Controllers\Admin\SupportTicketController; use App\Http\Controllers\Admin\ReleaseController as AdminReleaseController; use App\Http\Controllers\Admin\CmsPageController as AdminCmsPageController; +use App\Http\Controllers\Admin\SeoSettingsController; +use App\Http\Controllers\Admin\UserController; +use App\Http\Controllers\SeoController; use App\Http\Controllers\CmsPageController; use App\Http\Controllers\Auth\AuthenticatedSessionController; use App\Http\Controllers\Auth\EmailVerificationController; use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\RegisteredUserController; -use App\Http\Controllers\CheckoutController; use App\Http\Controllers\ReleaseController; -use App\Http\Controllers\Portal\PortalController; use App\Http\Controllers\Support\SupportController; use App\Http\Middleware\AdminOnly; use Illuminate\Support\Facades\Route; +Route::get('/sitemap.xml', [SeoController::class, 'sitemap'])->name('sitemap'); +Route::get('/robots.txt', [SeoController::class, 'robots'])->name('robots'); + Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\SonarDeskRelease::published()->where('channel', 'stable')->latest('published_at')->first()]))->name('home'); -Route::get('/buy', CheckoutController::class)->name('buy'); Route::view('/features', 'features')->name('features'); Route::get('/release', [ReleaseController::class, 'index'])->name('release'); Route::get('/download', [ReleaseController::class, 'latest'])->name('download.latest'); @@ -45,20 +48,25 @@ Route::middleware('auth')->group(function () { Route::get('/verify-email/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); Route::post('/email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); }); -Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function () { - Route::get('/', [PortalController::class, 'index'])->name('index'); - Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show'); - Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade'); - Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel'); - Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend'); - Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate'); -}); Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name('admin.')->group(function () { Route::get('/', [AdminController::class, 'index'])->name('index'); Route::get('/licences', [AdminController::class, 'licenses'])->name('licenses'); Route::get('/licences/{license}', [AdminController::class, 'show'])->name('license'); Route::post('/licences/{license}/revoke', [AdminController::class, 'revoke'])->name('revoke'); Route::post('/licences/{license}/restore', [AdminController::class, 'restore'])->name('restore'); + Route::get('/users', [UserController::class, 'index'])->name('users.index'); + Route::get('/users/{user}', [UserController::class, 'show'])->name('users.show'); + Route::put('/users/{user}', [UserController::class, 'update'])->name('users.update'); + Route::post('/users/{user}/verify', [UserController::class, 'verify'])->name('users.verify'); + Route::post('/users/{user}/verification-email', [UserController::class, 'resendVerification'])->name('users.verification-email'); + Route::post('/users/{user}/password-reset', [UserController::class, 'sendPasswordReset'])->name('users.password-reset'); + Route::post('/users/{user}/suspend', [UserController::class, 'suspend'])->name('users.suspend'); + Route::post('/users/{user}/restore', [UserController::class, 'restore'])->name('users.restore'); + Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('users.destroy'); + Route::patch('/users/{user}/licences/{license}', [UserController::class, 'updateLicense'])->name('users.licences.update'); + Route::delete('/users/{user}/licences/{license}/activations/{activation}', [UserController::class, 'deactivate'])->name('users.activations.destroy'); + Route::get('/seo', [SeoSettingsController::class, 'edit'])->name('seo.edit'); + Route::put('/seo', [SeoSettingsController::class, 'update'])->name('seo.update'); Route::get('/cms', [AdminCmsPageController::class, 'index'])->name('cms.index'); Route::get('/cms/create', [AdminCmsPageController::class, 'create'])->name('cms.create'); Route::post('/cms', [AdminCmsPageController::class, 'store'])->name('cms.store'); diff --git a/sonar-desk-licensing/scripts/scan.sh b/sonar-desk-licensing/scripts/scan.sh new file mode 100644 index 0000000..e708714 --- /dev/null +++ b/sonar-desk-licensing/scripts/scan.sh @@ -0,0 +1,5 @@ +sonar-scanner \ + -Dsonar.projectKey=SonarDesk \ + -Dsonar.sources=. \ + -Dsonar.host.url=https://sonarcube.absdev.net \ + -Dsonar.token=sqp_b215d121d857a0014bdad688bc76f31986a882c5 diff --git a/sonar-desk-licensing/tests/Feature/CheckoutPageTest.php b/sonar-desk-licensing/tests/Feature/CheckoutPageTest.php index 1fd31b8..b8dff77 100755 --- a/sonar-desk-licensing/tests/Feature/CheckoutPageTest.php +++ b/sonar-desk-licensing/tests/Feature/CheckoutPageTest.php @@ -2,18 +2,20 @@ namespace Tests\Feature; +use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; final class CheckoutPageTest extends TestCase { + use RefreshDatabase; public function test_buy_page_contains_all_four_configured_paddle_prices(): void { - config()->set('sonardesk.paddle.client_side_token', 'test_example'); - config()->set('sonardesk.paddle.environment', 'sandbox'); - config()->set('sonardesk.paddle.personal_monthly_price_id', 'pri_personal_monthly'); - config()->set('sonardesk.paddle.personal_annual_price_id', 'pri_personal_annual'); - config()->set('sonardesk.paddle.professional_monthly_price_id', 'pri_professional_monthly'); - config()->set('sonardesk.paddle.professional_annual_price_id', 'pri_professional_annual'); + config()->set('paddle.client_side_token', 'test_example'); + config()->set('paddle.environment', 'sandbox'); + config()->set('paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('paddle.personal_annual_price_id', 'pri_personal_annual'); + config()->set('paddle.professional_monthly_price_id', 'pri_professional_monthly'); + config()->set('paddle.professional_annual_price_id', 'pri_professional_annual'); $this->get('/buy') ->assertOk() diff --git a/sonar-desk-licensing/tests/Feature/LicenseActivationTest.php b/sonar-desk-licensing/tests/Feature/LicenseActivationTest.php index 7d81a87..ebb0b4e 100755 --- a/sonar-desk-licensing/tests/Feature/LicenseActivationTest.php +++ b/sonar-desk-licensing/tests/Feature/LicenseActivationTest.php @@ -2,9 +2,9 @@ namespace Tests\Feature; -use App\Models\LicenseActivation; -use App\Models\LicenseProduct; -use App\Services\Licensing\LicenseIssuer; +use Modules\Licensing\Models\LicenseActivation; +use Modules\Licensing\Models\LicenseProduct; +use Modules\Licensing\Services\LicenseIssuer; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; diff --git a/sonar-desk-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php b/sonar-desk-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php index a2c1f07..1c062e1 100755 --- a/sonar-desk-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php +++ b/sonar-desk-licensing/tests/Feature/PaddleSubscriptionProvisioningTest.php @@ -2,10 +2,10 @@ namespace Tests\Feature; -use App\Models\License; -use App\Models\LicenseProduct; +use Modules\Licensing\Models\License; +use Modules\Licensing\Models\LicenseProduct; use App\Models\User; -use App\Services\Payments\PurchaseProvisioner; +use Modules\Paddle\Services\PurchaseProvisioner; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -84,7 +84,7 @@ class PaddleSubscriptionProvisioningTest extends TestCase $this->product('pri_personal_monthly', 'Personal', 1); app(PurchaseProvisioner::class)->provision('paddle', $this->subscription( 'subscription.updated', 'active', 'pri_personal_monthly', 'pro_personal', - ['data' => ['scheduled_change' => ['action' => 'cancel', 'effective_at' => '2026-10-09T00:00:00Z']]] + ['scheduled_change' => ['action' => 'cancel', 'effective_at' => '2026-10-09T00:00:00Z']] )); $license = License::firstOrFail(); @@ -97,7 +97,7 @@ class PaddleSubscriptionProvisioningTest extends TestCase $this->product('pri_personal_monthly', 'Personal', 1); $service = app(PurchaseProvisioner::class); $service->provision('paddle', $this->subscription('subscription.created', 'active', 'pri_personal_monthly', 'pro_personal')); - $service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['data' => ['current_billing_period' => null]])); + $service->provision('paddle', $this->subscription('subscription.canceled', 'canceled', 'pri_personal_monthly', 'pro_personal', ['current_billing_period' => null])); $license = License::firstOrFail(); $this->assertNotNull($license->revoked_at); diff --git a/sonar-desk-licensing/tests/Feature/PortalSubscriptionManagementTest.php b/sonar-desk-licensing/tests/Feature/PortalSubscriptionManagementTest.php index 5fd08de..8e23027 100755 --- a/sonar-desk-licensing/tests/Feature/PortalSubscriptionManagementTest.php +++ b/sonar-desk-licensing/tests/Feature/PortalSubscriptionManagementTest.php @@ -2,11 +2,12 @@ namespace Tests\Feature; -use App\Models\License; -use App\Models\LicenseProduct; +use Modules\Licensing\Models\License; +use Modules\Licensing\Models\LicenseProduct; use App\Models\User; -use App\Services\Payments\PaddleClient; +use Modules\Paddle\Services\PaddleClient; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; use Mockery\MockInterface; use Tests\TestCase; @@ -31,8 +32,10 @@ class PortalSubscriptionManagementTest extends TestCase public function test_personal_subscription_is_upgraded_in_place(): void { - config()->set('sonardesk.paddle.personal_monthly_price_id', 'pri_personal_monthly'); - config()->set('sonardesk.paddle.professional_monthly_price_id', 'pri_professional_monthly'); + $this->withoutMiddleware(PreventRequestForgery::class); + + config()->set('paddle.personal_monthly_price_id', 'pri_personal_monthly'); + config()->set('paddle.professional_monthly_price_id', 'pri_professional_monthly'); $personal = $this->product('personal-monthly', 'Personal', 'pri_personal_monthly', 1); $professional = $this->product('professional-monthly', 'Professional', 'pri_professional_monthly', 3); diff --git a/sonar-desk-licensing/tests/TestCase.php b/sonar-desk-licensing/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/sonar-desk-licensing/tests/TestCase.php @@ -0,0 +1,10 @@ +