SEO additions
This commit is contained in:
@@ -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
@@ -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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user