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')
Manage licences, activations and customer support.Licensing administration
Licences
Not revoked
Active installations
Portal users
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 +