Skip to content

Commit 8bb20db

Browse files
committed
Phases 234-240: 2FA + Webhooks + Audit Log UI — 29 tests passing
2FA: pragmarx/google2fa-laravel, TOTP setup/enable/disable/challenge/verify, encrypted secrets + 8 recovery codes, RequiresTwoFactor middleware. Webhooks: webhooks + webhook_deliveries tables, Webhook model with subscribesTo()/dispatch(), WebhookService with HMAC-SHA256 signing, Http::fake delivery tests, deliveries and test-ping views. Audit Log UI: enhanced index with user/date filters, new show page with old/new value diff (changed keys only). Sidebar: 2FA Setup + Webhooks links under Settings. 29/29 tests, 1915 total passing. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 1c59aa2 commit 8bb20db

27 files changed

Lines changed: 2426 additions & 63 deletions

erp/app/Http/Controllers/Admin/AuditLogController.php

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,32 +15,72 @@ public function index(Request $request): Response
1515
{
1616
$this->authorize('viewAny', User::class);
1717

18-
$logs = AuditLog::with('user')
19-
->where('tenant_id', auth()->user()->tenant_id)
20-
->when($request->event, fn ($q) => $q->where('event', $request->event))
21-
->when($request->model, fn ($q) => $q->where('auditable_type', 'like', "%{$request->model}%"))
22-
->latest()
18+
$query = AuditLog::with('user')
19+
->where('audit_logs.tenant_id', auth()->user()->tenant_id)
20+
->when($request->event, fn ($q) => $q->where('event', $request->event))
21+
->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id))
22+
->when($request->model, fn ($q) => $q->where('auditable_type', 'like', "%{$request->model}%"))
23+
->when($request->date_from, fn ($q) => $q->whereDate('created_at', '>=', $request->date_from))
24+
->when($request->date_to, fn ($q) => $q->whereDate('created_at', '<=', $request->date_to));
25+
26+
$logs = $query
27+
->latest('created_at')
2328
->paginate(50)
2429
->withQueryString()
2530
->through(fn ($log) => [
2631
'id' => $log->id,
2732
'event' => $log->event,
33+
'action' => $log->action ?? $log->event,
2834
'model' => class_basename($log->auditable_type),
2935
'model_id' => $log->auditable_id,
30-
'user' => $log->user?->name ?? 'System',
36+
'auditable_label'=> $log->auditable_label,
37+
'user' => $log->user ? ['name' => $log->user->name, 'email' => $log->user->email] : null,
38+
'user_name' => $log->user?->name ?? 'System',
3139
'old_values' => $log->old_values,
3240
'new_values' => $log->new_values,
3341
'ip_address' => $log->ip_address,
42+
'user_agent' => $log->user_agent,
43+
'module' => $log->module,
3444
'created_at' => $log->created_at->diffForHumans(),
3545
'created_at_raw' => $log->created_at->toDateTimeString(),
3646
]);
3747

48+
$users = User::where('tenant_id', auth()->user()->tenant_id)
49+
->orderBy('name')
50+
->get(['id', 'name']);
51+
3852
return Inertia::render('Admin/AuditLog/Index', [
39-
'logs' => $logs,
40-
'filters' => $request->only(['event', 'model']),
41-
'breadcrumbs' => [
42-
['label' => 'Administration'],
43-
['label' => 'Audit Log', 'href' => route('admin.audit-log.index')],
53+
'logs' => $logs,
54+
'filters' => $request->only(['event', 'model', 'user_id', 'date_from', 'date_to']),
55+
'users' => $users,
56+
]);
57+
}
58+
59+
public function show(AuditLog $log): Response
60+
{
61+
$this->authorize('viewAny', User::class);
62+
$log->load('user');
63+
64+
return Inertia::render('Admin/AuditLog/Show', [
65+
'log' => [
66+
'id' => $log->id,
67+
'event' => $log->event,
68+
'action' => $log->action ?? $log->event,
69+
'auditable_type' => $log->auditable_type,
70+
'auditable_id' => $log->auditable_id,
71+
'auditable_label'=> $log->auditable_label,
72+
'old_values' => $log->old_values,
73+
'new_values' => $log->new_values,
74+
'ip_address' => $log->ip_address,
75+
'user_agent' => $log->user_agent,
76+
'url' => $log->url,
77+
'module' => $log->module,
78+
'user' => $log->user ? [
79+
'id' => $log->user->id,
80+
'name' => $log->user->name,
81+
'email' => $log->user->email,
82+
] : null,
83+
'created_at' => $log->created_at->toDateTimeString(),
4484
],
4585
]);
4686
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use Illuminate\Http\RedirectResponse;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Str;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
use PragmaRX\Google2FALaravel\Support\Authenticator;
11+
12+
class TwoFactorController extends Controller
13+
{
14+
/**
15+
* Show the 2FA setup page (generate secret, store in session).
16+
*/
17+
public function setup(Request $request): Response
18+
{
19+
$google2fa = app('pragmarx.google2fa');
20+
21+
// Generate a new secret only if none is in session
22+
if (! $request->session()->has('2fa_setup_secret')) {
23+
$secret = $google2fa->generateSecretKey();
24+
$request->session()->put('2fa_setup_secret', $secret);
25+
}
26+
27+
$secret = $request->session()->get('2fa_setup_secret');
28+
$user = $request->user();
29+
30+
$qrCodeUrl = $google2fa->getQRCodeUrl(
31+
config('app.name'),
32+
$user->email,
33+
$secret
34+
);
35+
36+
return Inertia::render('Auth/TwoFactor/Setup', [
37+
'qrCodeUrl' => $qrCodeUrl,
38+
'secret' => $secret,
39+
'enabled' => (bool) $user->two_factor_enabled,
40+
]);
41+
}
42+
43+
/**
44+
* Enable 2FA after verifying the TOTP code.
45+
*/
46+
public function enable(Request $request): RedirectResponse
47+
{
48+
$request->validate([
49+
'code' => ['required', 'string', 'size:6'],
50+
]);
51+
52+
$secret = $request->session()->get('2fa_setup_secret');
53+
54+
if (! $secret) {
55+
return back()->withErrors(['code' => 'Setup session expired. Please try again.']);
56+
}
57+
58+
$google2fa = app('pragmarx.google2fa');
59+
60+
if (! $google2fa->verifyKey($secret, $request->code)) {
61+
return back()->withErrors(['code' => 'Invalid verification code. Please try again.']);
62+
}
63+
64+
// Generate recovery codes
65+
$recoveryCodes = collect(range(1, 8))->map(fn () => Str::random(10))->all();
66+
67+
$request->user()->update([
68+
'two_factor_secret' => encrypt($secret),
69+
'two_factor_enabled' => true,
70+
'two_factor_recovery_codes' => encrypt(json_encode($recoveryCodes)),
71+
]);
72+
73+
$request->session()->forget('2fa_setup_secret');
74+
$request->session()->put('2fa_verified', true);
75+
76+
return redirect()->route('profile.edit')->with('success', 'Two-factor authentication enabled successfully.');
77+
}
78+
79+
/**
80+
* Disable 2FA after verifying the user's password.
81+
*/
82+
public function disable(Request $request): RedirectResponse
83+
{
84+
$request->validate([
85+
'password' => ['required', 'current_password'],
86+
]);
87+
88+
$request->user()->update([
89+
'two_factor_secret' => null,
90+
'two_factor_enabled' => false,
91+
'two_factor_recovery_codes' => null,
92+
]);
93+
94+
$request->session()->forget('2fa_verified');
95+
96+
return redirect()->route('profile.edit')->with('success', 'Two-factor authentication disabled.');
97+
}
98+
99+
/**
100+
* Show the 2FA challenge page (after login, if 2FA enabled).
101+
*/
102+
public function challenge(Request $request): Response|RedirectResponse
103+
{
104+
if (! $request->user()?->two_factor_enabled) {
105+
return redirect()->route('dashboard');
106+
}
107+
108+
if ($request->session()->get('2fa_verified')) {
109+
return redirect()->intended(route('dashboard'));
110+
}
111+
112+
return Inertia::render('Auth/TwoFactor/Challenge');
113+
}
114+
115+
/**
116+
* Verify the TOTP code during login challenge.
117+
*/
118+
public function verify(Request $request): RedirectResponse
119+
{
120+
$request->validate([
121+
'code' => ['required', 'string'],
122+
]);
123+
124+
$user = $request->user();
125+
126+
if (! $user?->two_factor_enabled || ! $user->two_factor_secret) {
127+
return redirect()->route('dashboard');
128+
}
129+
130+
$secret = decrypt($user->two_factor_secret);
131+
$google2fa = app('pragmarx.google2fa');
132+
$code = preg_replace('/\s/', '', $request->code);
133+
134+
// Try TOTP first
135+
if (strlen($code) === 6 && $google2fa->verifyKey($secret, $code)) {
136+
$request->session()->put('2fa_verified', true);
137+
return redirect()->intended(route('dashboard'));
138+
}
139+
140+
// Try recovery codes
141+
if (strlen($code) === 10 && $user->two_factor_recovery_codes) {
142+
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true) ?? [];
143+
if (in_array($code, $recoveryCodes, true)) {
144+
// Remove used recovery code
145+
$remaining = array_values(array_filter($recoveryCodes, fn ($c) => $c !== $code));
146+
$user->update(['two_factor_recovery_codes' => encrypt(json_encode($remaining))]);
147+
148+
$request->session()->put('2fa_verified', true);
149+
return redirect()->intended(route('dashboard'));
150+
}
151+
}
152+
153+
return back()->withErrors(['code' => 'Invalid code. Please try again.']);
154+
}
155+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\Webhook;
6+
use App\Services\WebhookService;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class WebhookController extends Controller
13+
{
14+
public const AVAILABLE_EVENTS = [
15+
'order.created',
16+
'invoice.paid',
17+
'lead.won',
18+
'ticket.resolved',
19+
'employee.created',
20+
'payment.received',
21+
];
22+
23+
public function index(): Response
24+
{
25+
$webhooks = Webhook::withoutGlobalScopes()
26+
->where('tenant_id', auth()->user()->tenant_id)
27+
->withCount('deliveries')
28+
->latest()
29+
->get();
30+
31+
return Inertia::render('Settings/Webhooks/Index', [
32+
'webhooks' => $webhooks,
33+
'availableEvents' => self::AVAILABLE_EVENTS,
34+
]);
35+
}
36+
37+
public function create(): Response
38+
{
39+
return Inertia::render('Settings/Webhooks/Create', [
40+
'availableEvents' => self::AVAILABLE_EVENTS,
41+
]);
42+
}
43+
44+
public function store(Request $request): RedirectResponse
45+
{
46+
$request->validate([
47+
'name' => ['required', 'string', 'max:255'],
48+
'url' => ['required', 'url', 'max:2048'],
49+
'events' => ['nullable', 'array'],
50+
'events.*' => ['string', 'in:' . implode(',', self::AVAILABLE_EVENTS)],
51+
'secret' => ['nullable', 'string', 'max:255'],
52+
'is_active' => ['boolean'],
53+
]);
54+
55+
Webhook::create([
56+
'tenant_id' => auth()->user()->tenant_id,
57+
'name' => $request->name,
58+
'url' => $request->url,
59+
'events' => $request->events ?? [],
60+
'secret' => $request->secret,
61+
'is_active' => $request->boolean('is_active', true),
62+
]);
63+
64+
return redirect()->route('webhooks.index')->with('success', 'Webhook created successfully.');
65+
}
66+
67+
public function edit(Webhook $webhook): Response
68+
{
69+
return Inertia::render('Settings/Webhooks/Edit', [
70+
'webhook' => $webhook,
71+
'availableEvents' => self::AVAILABLE_EVENTS,
72+
]);
73+
}
74+
75+
public function update(Request $request, Webhook $webhook): RedirectResponse
76+
{
77+
$request->validate([
78+
'name' => ['required', 'string', 'max:255'],
79+
'url' => ['required', 'url', 'max:2048'],
80+
'events' => ['nullable', 'array'],
81+
'events.*' => ['string', 'in:' . implode(',', self::AVAILABLE_EVENTS)],
82+
'secret' => ['nullable', 'string', 'max:255'],
83+
'is_active' => ['boolean'],
84+
]);
85+
86+
$webhook->update([
87+
'name' => $request->name,
88+
'url' => $request->url,
89+
'events' => $request->events ?? [],
90+
'secret' => $request->secret,
91+
'is_active' => $request->boolean('is_active', true),
92+
]);
93+
94+
return redirect()->route('webhooks.index')->with('success', 'Webhook updated successfully.');
95+
}
96+
97+
public function destroy(Webhook $webhook): RedirectResponse
98+
{
99+
$webhook->delete();
100+
return redirect()->route('webhooks.index')->with('success', 'Webhook deleted.');
101+
}
102+
103+
public function deliveries(Webhook $webhook): Response
104+
{
105+
$deliveries = $webhook->deliveries()
106+
->latest()
107+
->limit(50)
108+
->get();
109+
110+
return Inertia::render('Settings/Webhooks/Deliveries', [
111+
'webhook' => $webhook,
112+
'deliveries' => $deliveries,
113+
]);
114+
}
115+
116+
public function test(Webhook $webhook): RedirectResponse
117+
{
118+
WebhookService::send($webhook, 'ping', [
119+
'event' => 'ping',
120+
'message' => 'This is a test webhook delivery.',
121+
'timestamp' => now()->toIso8601String(),
122+
]);
123+
124+
return back()->with('success', 'Test ping sent to webhook.');
125+
}
126+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use Closure;
6+
use Illuminate\Http\Request;
7+
use Symfony\Component\HttpFoundation\Response;
8+
9+
class RequiresTwoFactor
10+
{
11+
public function handle(Request $request, Closure $next): Response
12+
{
13+
$user = $request->user();
14+
15+
if (
16+
$user
17+
&& $user->two_factor_enabled
18+
&& ! $request->session()->get('2fa_verified')
19+
&& ! $request->routeIs('2fa.*')
20+
&& ! $request->routeIs('logout')
21+
) {
22+
return redirect()->route('2fa.challenge');
23+
}
24+
25+
return $next($request);
26+
}
27+
}

0 commit comments

Comments
 (0)