Security Event Log

Highlights risky account changes from other devices and groups a takeover-shaped burst, with a "This wasn't me" path the user can review.

StopsAccount takeover buried in the activity log

Installation

pnpm dlx shadcn@latest add https://bollard-ui.vercel.app/r/security-event-log.json

The files land in your project. They're yours to edit.

Usage

security-event-log-usage.tsx
import { SecurityEventLog, type SecurityEvent } from "@/components/ui/security-event-log"

<SecurityEventLog
  events={events}
  timeZone="Asia/Kolkata"
  now={NOW}
  locale="en-IN"
  limit={6}
  requiredRemedies={["sign_out_all"]}
  onReport={(list, remedies) => new Promise<void>((resolve) => setTimeout(() => { setLog(`Secured: ${list.length} event(s) reported · ${remedies.map((r) => r.key).join(", ")}`); resolve() }, 600))}
  onConfirm={(list) => setLog(`Confirmed ${list.length} event(s)`)}
/>

What it catches

  • Risky changes from devices other than the current one
  • A burst of risky changes within windowMinutes, named as a likely takeover
  • Remedies listed before acting (sign out device, re-enable 2-step, restore recovery)
  • Required remedies that can't be unticked
  • Day grouping in the account's time zone

Props

SecurityEventLog props
PropTypeDefault
events* (required)

Security events, any order. Entries without an id or a real time are skipped.

SecurityEvent[]—
timeZone

The account's time zone. Days and times follow it, not the browser. Defaults to the device zone after mount.

string—
now

"Now" in epoch ms, for deterministic rendering. Read from the clock every minute when omitted.

number—
locale

Locale for dates and times.

string—
windowMinutes

Risky changes this close together read as one incident.

number60
onReport* (required)

"This wasn't me": secure the account for these events with the steps the user kept ticked. May return a Promise; a rejection shows a retry.

(events: SecurityEvent[], remedies: SecurityRemedy[]) => void | Promise<void>—
requiredRemedies

Remedy keys that are always done and can't be unticked, e.g. ["sign_out_all"].

string[][]
onConfirm

"It was me".

(events: SecurityEvent[]) => void | Promise<void>—
limit

Events shown before "Show older".

number8
messages

Any visible string.

Partial<SecurityEventMessages>—

* required. Also accepts the native props of <section> (className, aria-*, data-*…), passed through to it.

Accessibility

  • Remedy list is a group labelled by its title; Escape cancels
  • Remedy checkboxes have <label>s; "choose one" note is polite, errors are role=alert
  • Focus moves to the secure button on open and back to the row's report button on cancel
  • Times use <time> with a machine-readable dateTime

Only what the source does. Test with your own screen reader and content before shipping.

Limitations

This is a UX guard rail. Enforce on the server: Run the remedies server-side (revoke sessions, restore factors) and require re-authentication or a recovery check before doing so.

  • Risk and grouping are heuristics over the events you pass
  • Location comes from IP and is approximate
  • Remedies run through your onConfirm; nothing is secured until the server does it

What it stops

Account takeover buried in the activity log

Also known as

security activity · login history · recent devices · account takeover · 2fa · recovery email · audit log · this wasn't me

Field type

Dependencies

Pairs well with