The audit log in the J+P Autodíly admin panel records business events: order created, document status changed, customer updated. Each event has a type, an entity, a timestamp and context. Clean — until you start dumping logins into the same table.
Logins are high-frequency. A customer signs in, signs out, signs in from their phone, signs in from their tablet. Every successful authentication is one row. In a table full of business events, login events would drown everything else within a week — the audit log becomes a login log with the occasional order on top.
The fix is simple: LoginEvent is its own model, its own table, its own index.
model LoginEvent {
id String @id @default(cuid())
customerId String
ip String
userAgent String?
createdAt DateTime @default(now())
@@index([customerId, createdAt])
}
No polymorphic entityType / entityId, no shared schema. The login event has exactly the fields it needs — nothing more, nothing less.
Off the critical path
The login record is telemetry. It must not affect the login itself. If the database insert fails, the customer still has to get in. If the insert takes 200 ms, the customer must not feel it.
So the record runs via afterResponse() — outside the request/response cycle. NextAuth authorize() returns the result, the HTTP response goes out, and only then does the row get written in the background.
authorize() → verify password → return user → HTTP 200
↓ (after response)
loginEvent.create()
If the insert fails, an empty catch swallows it. A lost row beats a lost login.
Rate limit in the right place
The rate limit runs inside authorize(), not in the server action. The reason: authorize() is the single choke point every login passes through — both the server action and a direct POST to /api/auth/callback/credentials end up there. A rate limit in the server action would be bypassed by the HTTP endpoint.
5 attempts / 60 s / IP
And when the account doesn’t exist, a bcrypt hash of equal cost runs anyway. The response time is identical — an attacker cannot tell from latency whether the email exists in the system.
Visibility in the admin
Login history appears in two places:
- Customer card — a Logins tab with a table of IP, user agent and timestamp, paginated via the URL parameter
?ln=. - Staff management — the same table for all admin accounts, same design, same pagination.
Tab state lives in the URL (?tab=logins), so F5 keeps the context and a link to a specific view works. The same pattern as the rest of the admin panel — the URL is the state.
Why not one audit log
The alternative would be: one audit log, action = "login", filter in the UI. It works, but:
- Business events get lost in a flood of logins
- Indexes have to serve two completely different access patterns (time window vs. entity)
- Retention differs — you want to purge login events after 90 days, not the business audit
- A query for a customer’s history drags in unnecessary joins
A separate table is boring. But it’s the right kind of boring.