Skip to content
This repository was archived by the owner on Nov 22, 2025. It is now read-only.

Commit a8cf827

Browse files
authored
feat: admin user tweaks (#84)
* added ip address tracking * wip * wip * style: automated changes to code style * ci: ignoring commit change in git blame * wi * style: automated changes to code style * ci: ignoring commit change in git blame * wip * wip * style: automated changes to code style * ci: ignoring commit change in git blame * add tests * formatting fixes * style: automated changes to code style * ci: ignoring commit change in git blame
1 parent c0a5795 commit a8cf827

File tree

16 files changed

+758
-1
lines changed

16 files changed

+758
-1
lines changed

.git-blame-ignore-revs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,7 @@ f26384c845185f89eefe706ce86189db2e2df007
6161
01d579b4bfd961bae5bed89af9ab8f08f47de8ea
6262
ff0b8927c28de9ee385aa26dd8e2e6af3e3ed340
6363
4816c3429a3b5f8911b857f256353a80a2512e56
64+
fa8af380eaf2bc4532f46b086c175814eb22d979
65+
c989840817392e32aa2baaed4d62fb783f383201
66+
589857d3bc0f31aa3ca6aa100d2fb22a432f07ec
67+
894fca5f8d873f09a36853ca71e1286b99d73715

app/Http/Controllers/Connections/ConnectionsController.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ protected function signInWithExistingConnection(UserConnection $userConnection):
5656
if ($user instanceof User) {
5757
Auth::login($user);
5858

59+
$user->forceFill([
60+
'most_recent_login_ip' => request()->ip(),
61+
'last_login_at' => now(),
62+
]);
63+
5964
return redirect()->route('overview');
6065
}
6166

@@ -73,6 +78,12 @@ protected function registerOrSignInUser(string $provider, SocialiteUser $sociali
7378

7479
if ($user instanceof User) {
7580
$this->createConnection($user, $provider, $socialiteUser);
81+
82+
$user->forceFill([
83+
'most_recent_login_ip' => request()->ip(),
84+
'last_login_at' => now(),
85+
]);
86+
7687
Auth::login($user);
7788
Toaster::success(ucfirst($provider) . ' account successfully linked and authenticated.');
7889

@@ -82,11 +93,18 @@ protected function registerOrSignInUser(string $provider, SocialiteUser $sociali
8293
$user = User::create([
8394
'name' => $socialiteUser->getName(),
8495
'email' => $socialiteUser->getEmail(),
96+
'registration_ip' => request()->ip(),
97+
'most_recent_login_ip' => request()->ip(),
98+
'last_login_at' => now(),
8599
]);
86100

87101
$this->createConnection($user, $provider, $socialiteUser);
88102
Auth::login($user);
89103

104+
$user->forceFill([
105+
'most_recent_login_ip' => request()->ip(),
106+
]);
107+
90108
Toaster::success('Account created and authenticated via ' . ucfirst($provider) . '.');
91109

92110
return redirect()->route('overview');
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Livewire\Admin\IPChecker;
6+
7+
use App\Models\User;
8+
use Illuminate\Database\Eloquent\Builder;
9+
use Illuminate\Database\Eloquent\Collection;
10+
use Illuminate\View\View;
11+
use Livewire\Component;
12+
13+
/**
14+
* IP Checker Tool
15+
*
16+
* This component provides administrators with a tool to search users
17+
* by IP address.
18+
*/
19+
class IPCheckerPage extends Component
20+
{
21+
/**
22+
* The IP address to search for
23+
*/
24+
public ?string $ipAddress = null;
25+
26+
/**
27+
* Whether a search has been performed
28+
*/
29+
public bool $checked = false;
30+
31+
/**
32+
* Collection of users matching the search criteria
33+
*
34+
* @var array<int|string, mixed>
35+
*/
36+
public array $results = [];
37+
38+
/**
39+
* Number of users matching the search
40+
*/
41+
public int $totalMatches = 0;
42+
43+
/**
44+
* Type of search to perform: 'registration', 'login', or 'both'
45+
*/
46+
public string $searchType = 'both';
47+
48+
/**
49+
* Initialize the component and perform search if IP provided in URL
50+
*/
51+
public function mount(?string $ipAddress = null): void
52+
{
53+
$this->ipAddress = $ipAddress;
54+
55+
if (! $this->ipAddress) {
56+
return;
57+
}
58+
59+
$this->check();
60+
}
61+
62+
/**
63+
* Render the component view and enforce admin-only access
64+
*/
65+
public function render(): View
66+
{
67+
$user = request()->user();
68+
69+
if (! $user || ! $user->isAdmin()) {
70+
abort(404);
71+
}
72+
73+
return view('livewire.admin.ip-checker.page');
74+
}
75+
76+
/**
77+
* Perform IP address search based on current criteria
78+
*/
79+
public function check(): void
80+
{
81+
$this->validate([
82+
'ipAddress' => ['required', 'string', 'ipv4'],
83+
], [
84+
'ipAddress.required' => __('Please enter an IP address to analyze.'),
85+
]);
86+
87+
$query = User::query();
88+
$this->applySearchFilters($query);
89+
90+
$matchedUsers = $query->get();
91+
$this->totalMatches = $matchedUsers->count();
92+
$this->results = $this->formatResults($matchedUsers);
93+
$this->checked = true;
94+
95+
$this->updateUrlParameter();
96+
}
97+
98+
/**
99+
* Update the search type and maintain current state
100+
*/
101+
public function updateSearchType(string $type): void
102+
{
103+
$this->searchType = $type;
104+
}
105+
106+
/**
107+
* Reset all search parameters and clear results
108+
*/
109+
public function clear(): void
110+
{
111+
$this->ipAddress = null;
112+
$this->checked = false;
113+
$this->results = [];
114+
$this->totalMatches = 0;
115+
$this->searchType = 'both';
116+
$this->resetValidation();
117+
118+
$this->updateUrlParameter();
119+
}
120+
121+
/**
122+
* Apply IP address filters to query based on selected search type
123+
*
124+
* @param Builder<User> $builder
125+
*/
126+
private function applySearchFilters(Builder $builder): void
127+
{
128+
if ($this->searchType === 'registration') {
129+
$builder->where('registration_ip', $this->ipAddress);
130+
131+
return;
132+
}
133+
134+
if ($this->searchType === 'login') {
135+
$builder->where('most_recent_login_ip', $this->ipAddress);
136+
137+
return;
138+
}
139+
140+
// Default case: search both
141+
$builder->where('registration_ip', $this->ipAddress)
142+
->orWhere('most_recent_login_ip', $this->ipAddress);
143+
}
144+
145+
/**
146+
* Format user data for display in results
147+
*
148+
* @param Collection<int, User> $matchedUsers
149+
* @return array<int, array<string, mixed>>
150+
*/
151+
private function formatResults(Collection $matchedUsers): array
152+
{
153+
return $matchedUsers->map(function ($user): array {
154+
return [
155+
'id' => $user->getAttribute('id'),
156+
'gravatar' => $user->gravatar(60),
157+
'name' => $user->getAttribute('name'),
158+
'email' => $user->getAttribute('email'),
159+
'registration_match' => $user->getAttribute('registration_ip') === $this->ipAddress,
160+
'login_match' => $user->getAttribute('most_recent_login_ip') === $this->ipAddress,
161+
'created_at' => $user->getAttribute('created_at')->diffForHumans(),
162+
'last_login' => $user->getAttribute('last_login_at')
163+
? $user->getAttribute('last_login_at')->diffForHumans()
164+
: 'Never',
165+
];
166+
})->toArray();
167+
}
168+
169+
/**
170+
* Update browser URL to reflect current search state
171+
*/
172+
private function updateUrlParameter(): void
173+
{
174+
$url = $this->ipAddress
175+
? route('admin.ip-checker', ['ipAddress' => $this->ipAddress])
176+
: route('admin.ip-checker');
177+
178+
$this->dispatch('urlChanged', ['url' => $url]);
179+
}
180+
}

app/Livewire/Forms/LoginForm.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ public function authenticate(): void
3131
]);
3232
}
3333

34+
$user = Auth::user();
35+
$user?->forceFill([
36+
'most_recent_login_ip' => request()->ip(),
37+
'last_login_at' => now(),
38+
]);
39+
$user?->save();
40+
3441
RateLimiter::clear($this->throttleKey());
3542
}
3643

app/Models/User.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ class User extends Authenticatable implements TwoFactorAuthenticatable
5454
'last_two_factor_at',
5555
'last_two_factor_ip',
5656
'two_factor_verified_token',
57+
'registration_ip',
58+
'most_recent_login_ip',
59+
'last_login_at',
5760
];
5861

5962
protected $hidden = [
@@ -402,6 +405,7 @@ protected function casts(): array
402405
'weekly_summary_opt_in_at' => 'datetime',
403406
'last_two_factor_at' => 'datetime',
404407
'quiet_until' => 'datetime',
408+
'last_login_at' => 'datetime',
405409
];
406410
}
407411

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::table('users', function (Blueprint $table) {
12+
$table->ipAddress('registration_ip')->nullable()->after('remember_token');
13+
$table->ipAddress('most_recent_login_ip')->nullable()->after('remember_token');
14+
});
15+
}
16+
17+
public function down(): void
18+
{
19+
Schema::table('users', function (Blueprint $table) {
20+
//
21+
});
22+
}
23+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::table('users', function (Blueprint $table) {
12+
$table->dateTime('last_login_at')->nullable();
13+
});
14+
}
15+
16+
public function down(): void
17+
{
18+
Schema::table('users', function (Blueprint $table) {
19+
$table->dropColumn('last_login_at');
20+
});
21+
}
22+
};

0 commit comments

Comments
 (0)