{{ $plan['name'] }}
--
-
- 7-day free trial -
- {{ $plan['activation_limit'] }} active Mac{{ $plan['activation_limit'] === 1 ? '' : 's' }} -
- Secure online activation -
- Licence management portal -
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') +
+ 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 @@ + + + + + + ++ 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 @@ + + + + + + +Override global defaults only where this page needs specific search or social metadata.
Manage licences, activations and customer support.
Licences
Not revoked
Active installations
Portal users
Control how SonarDesk appears in search engines and when pages are shared.
Manage portal accounts, access and customer licences.
{{ $users->total() }} account{{ $users->total() === 1 ? '' : 's' }}
| User | Access | Licences | Joined | ||
|---|---|---|---|---|---|
| {{ $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. | |||||
{{ $user->email }}
Edit the portal identity and administrator access.
Licences linked to this user's email address.
| Device | Platform | Version | Status | |
|---|---|---|---|---|
| {{ $activation->installation_name ?: $activation->installation_id }} | {{ $activation->platform ?: '—' }} | {{ $activation->app_version ?: '—' }} | {{ $activation->deactivated_at ? 'Released' : 'Active' }} | @if(!$activation->deactivated_at)@endif |
No licences are linked to this email address.
@endforelse +Every subscription starts with a 7-day free trial. Cancel before the trial ends and you won't be charged.
- @if($paddleEnvironment === 'sandbox')+ Every subscription starts with a 7-day free trial. + Cancel before the trial ends and you won't be charged. +
-Paddle is confirming your subscription. Your SonarDesk licence key is generated by our licensing server and emailed to the address used at checkout. You can then activate it in SonarDesk → Settings → Licence.
-Delivery normally follows the subscription webhook within moments. Check your junk folder if needed. You can also create a portal account with the same email address and resend the key from My licences.
- Create portal account -{{ $page->excerpt }}
@endif