diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..7f4d45c --- /dev/null +++ b/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/app/Http/Controllers/Auth/AuthenticatedSessionController.php b/app/Http/Controllers/Auth/AuthenticatedSessionController.php index d63878e..ebe9dc1 100755 --- a/app/Http/Controllers/Auth/AuthenticatedSessionController.php +++ b/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/app/Models/User.php b/app/Models/User.php index ff6872c..c529da4 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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']; } } diff --git a/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php b/database/migrations/2026_10_02_090000_add_disabled_at_to_users_table.php new file mode 100644 index 0000000..4763179 --- /dev/null +++ b/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/resources/views/admin/index.blade.php b/resources/views/admin/index.blade.php index e4d91e4..61766a6 100755 --- a/resources/views/admin/index.blade.php +++ b/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 contentSEO & searchSupport inbox
+
Manage releasesManage licencesManage usersManage contentSEO & searchSupport inbox
@endsection diff --git a/resources/views/admin/users/index.blade.php b/resources/views/admin/users/index.blade.php new file mode 100644 index 0000000..9b6d9ef --- /dev/null +++ b/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/resources/views/admin/users/show.blade.php b/resources/views/admin/users/show.blade.php new file mode 100644 index 0000000..825a9e1 --- /dev/null +++ b/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/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 7bd879d..99d2e8f 100755 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -229,6 +229,7 @@ @endphp Admin + Users Releases Support diff --git a/routes/web.php b/routes/web.php index cc02748..7ef77d8 100755 --- a/routes/web.php +++ b/routes/web.php @@ -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');