SEO additions

This commit is contained in:
Mark Gregory
2026-10-02 09:39:45 +01:00
parent fa6ffa5cd9
commit 9a6687096a
9 changed files with 283 additions and 4 deletions
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\Rule;
final class UserController extends Controller
{
public function index(Request $request)
{
$query = User::query()->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);
}
}
@@ -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();
+2 -2
View File
@@ -11,12 +11,12 @@ class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable;
protected $fillable = ['name', 'email', 'password', 'is_admin'];
protected $fillable = ['name', 'email', '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'];
}
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'disabled_at')) {
Schema::table('users', fn (Blueprint $table) => $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'));
}
}
};
+1 -1
View File
@@ -2,5 +2,5 @@
@section('content')
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn" href="{{ route('admin.users.index') }}">Manage users</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.seo.edit') }}">SEO & search</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
@endsection
@@ -0,0 +1,31 @@
@extends('layouts.app')
@section('content')
<section class="portal-header">
<div><span class="eyebrow">Admin</span><h1>Users</h1><p class="lead compact">Manage portal accounts, access and customer licences.</p></div>
<a class="btn btn-secondary" href="{{ route('admin.index') }}">Admin dashboard</a>
</section>
<form method="get" class="card" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin-bottom:18px">
<label style="flex:1;min-width:240px">Search<input name="q" value="{{ request('q') }}" placeholder="Name or email"></label>
<label>Status<select name="status"><option value="">All users</option><option value="admin" @selected(request('status')==='admin')>Administrators</option><option value="suspended" @selected(request('status')==='suspended')>Suspended</option><option value="unverified" @selected(request('status')==='unverified')>Unverified</option></select></label>
<button>Search</button>
@if(request()->hasAny(['q','status']))<a class="btn btn-secondary" href="{{ route('admin.users.index') }}">Clear</a>@endif
</form>
<div class="admin-table-card">
<div class="admin-table-title"><div><h2>Portal users</h2><p>{{ $users->total() }} account{{ $users->total() === 1 ? '' : 's' }}</p></div></div>
<div class="table-scroll"><table class="pro-table"><thead><tr><th>User</th><th>Access</th><th>Email</th><th>Licences</th><th>Joined</th><th></th></tr></thead><tbody>
@forelse($users as $user)
<tr>
<td><strong>{{ $user->name }}</strong><small>#{{ $user->id }}</small></td>
<td>@if($user->disabled_at)<span class="status-chip muted">Suspended</span>@elseif($user->is_admin)<span class="status-chip">Admin</span>@else<span class="status-chip success">Active</span>@endif</td>
<td><strong>{{ $user->email }}</strong><small>{{ $user->hasVerifiedEmail() ? 'Verified' : 'Not verified' }}</small></td>
<td>{{ $licenseCounts[strtolower($user->email)] ?? 0 }}</td>
<td>{{ $user->created_at?->format('d M Y') }}</td>
<td><div class="table-actions"><a class="btn btn-secondary" href="{{ route('admin.users.show',$user) }}">Manage</a></div></td>
</tr>
@empty<tr><td colspan="6" class="empty-cell">No users found.</td></tr>@endforelse
</tbody></table></div>
</div>
<div style="margin-top:18px">{{ $users->links() }}</div>
@endsection
@@ -0,0 +1,57 @@
@extends('layouts.app')
@section('content')
<section class="portal-header">
<div><span class="eyebrow">Admin · User</span><h1>{{ $user->name }}</h1><p class="lead compact">{{ $user->email }}</p></div>
<a class="btn btn-secondary" href="{{ route('admin.users.index') }}">Back to users</a>
</section>
@if($errors->any())<div class="card" style="border-color:#fda29b;margin-bottom:18px"><strong>Could not save changes</strong><ul>@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>@endif
<div class="release-editor">
<div class="editor-main">
<div class="editor-card">
<div class="editor-card-head"><span>01</span><div><h2>Account details</h2><p>Edit the portal identity and administrator access.</p></div></div>
<form method="post" action="{{ route('admin.users.update',$user) }}">@csrf @method('PUT')
<div class="form-grid"><label>Name<input name="name" value="{{ old('name',$user->name) }}" required></label><label>Email<input type="email" name="email" value="{{ old('email',$user->email) }}" required></label></div>
<label class="toggle-row"><input type="checkbox" name="is_admin" value="1" @checked($user->is_admin) @disabled(auth()->user()->is($user))><span><strong>Administrator</strong><small>Allows access to SonarDesk administration.</small></span></label>
@if(auth()->user()->is($user))<input type="hidden" name="is_admin" value="1"><p><small>Your own administrator access is protected.</small></p>@endif
<button>Save user</button>
</form>
</div>
<div class="editor-card">
<div class="editor-card-head"><span>02</span><div><h2>Licences</h2><p>Licences linked to this user's email address.</p></div></div>
@forelse($licenses as $license)
<div style="padding:18px 0;border-top:1px solid #eef2f6">
<div style="display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap">
<div><strong>{{ $license->edition }} · •••• {{ $license->license_key_last4 }}</strong><small style="display:block;color:#667085;margin-top:4px">{{ $license->subscription_status ?: 'No subscription status' }} · {{ $license->payment_provider ?: 'manual' }}</small></div>
<div><a class="btn btn-secondary" href="{{ route('admin.license',$license) }}">Full licence</a></div>
</div>
<form method="post" action="{{ route('admin.users.licences.update',[$user,$license]) }}" style="display:flex;gap:10px;align-items:end;margin-top:14px;flex-wrap:wrap">@csrf @method('PATCH')<label>Activation limit<input type="number" min="1" max="1000" name="activation_limit" value="{{ $license->activation_limit }}" style="max-width:120px"></label><button>Update limit</button></form>
@if($license->activations->isNotEmpty())
<div class="table-scroll" style="margin-top:14px"><table class="pro-table"><thead><tr><th>Device</th><th>Platform</th><th>Version</th><th>Status</th><th></th></tr></thead><tbody>
@foreach($license->activations as $activation)<tr><td>{{ $activation->installation_name ?: $activation->installation_id }}</td><td>{{ $activation->platform ?: '—' }}</td><td>{{ $activation->app_version ?: '—' }}</td><td>{{ $activation->deactivated_at ? 'Released' : 'Active' }}</td><td>@if(!$activation->deactivated_at)<form method="post" action="{{ route('admin.users.activations.destroy',[$user,$license,$activation]) }}">@csrf @method('DELETE')<button class="btn btn-secondary">Release</button></form>@endif</td></tr>@endforeach
</tbody></table></div>
@endif
</div>
@empty<p>No licences are linked to this email address.</p>@endforelse
</div>
</div>
<aside class="editor-main">
<div class="editor-card sticky-card">
<div class="editor-card-head"><span>03</span><div><h2>Account actions</h2><p>Security and access controls.</p></div></div>
<p><strong>Email</strong><br><span class="status-chip {{ $user->hasVerifiedEmail() ? 'success' : 'muted' }}">{{ $user->hasVerifiedEmail() ? 'Verified' : 'Not verified' }}</span></p>
@if(!$user->hasVerifiedEmail())
<form method="post" action="{{ route('admin.users.verification-email',$user) }}" style="margin-bottom:10px">@csrf<button class="btn btn-secondary" style="width:100%">Send verification email</button></form>
<form method="post" action="{{ route('admin.users.verify',$user) }}" style="margin-bottom:10px">@csrf<button class="btn btn-secondary" style="width:100%">Mark verified</button></form>
@endif
<form method="post" action="{{ route('admin.users.password-reset',$user) }}" style="margin-bottom:10px">@csrf<button class="btn btn-secondary" style="width:100%">Send password reset</button></form>
@unless(auth()->user()->is($user))
@if($user->disabled_at)<form method="post" action="{{ route('admin.users.restore',$user) }}" style="margin-bottom:10px">@csrf<button style="width:100%">Restore account</button></form>@else<form method="post" action="{{ route('admin.users.suspend',$user) }}" style="margin-bottom:10px">@csrf<button class="btn btn-secondary" style="width:100%">Suspend account</button></form>@endif
<form method="post" action="{{ route('admin.users.destroy',$user) }}" onsubmit="return confirm('Delete this portal user? Their licences will be retained.')">@csrf @method('DELETE')<button class="danger" style="width:100%">Delete user</button></form>
@endunless
</div>
</aside>
</div>
@endsection
+1
View File
@@ -229,6 +229,7 @@
@endphp
<a href="{{ route('admin.index') }}">Admin</a>
<a href="{{ route('admin.users.index') }}">Users</a>
<a href="{{ route('admin.releases.index') }}">Releases</a>
<a href="{{ route('admin.support.index') }}">
Support
+12
View File
@@ -5,6 +5,7 @@ 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;
@@ -64,6 +65,17 @@ Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name
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');