diff --git a/src/app/account/audit/page.tsx b/src/app/account/audit/page.tsx
new file mode 100644
index 00000000..402510b3
--- /dev/null
+++ b/src/app/account/audit/page.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import type { Metadata } from 'next';
+import Link from 'next/link';
+import ProtectedRoute from '@/components/auth/ProtectedRoute';
+import UserAuditTrail from '@/components/molecular/UserAuditTrail';
+
+export const metadata: Metadata = {
+ title: 'Security Activity - ScriptHammer',
+ description: 'Review recent security activity on your account',
+ robots: {
+ index: false,
+ follow: false,
+ googleBot: {
+ index: false,
+ follow: false,
+ },
+ },
+};
+
+/**
+ * /account/audit — the user-facing security-activity dashboard (#23, feature 005).
+ * Behind ProtectedRoute; UserAuditTrail reads the caller's own audit log via RLS.
+ */
+export default function AccountAuditPage() {
+ return (
+
+
+
+
+
Security Activity
+
+ Back to Account
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx
index fb772006..3c4e708b 100644
--- a/src/app/account/page.tsx
+++ b/src/app/account/page.tsx
@@ -30,6 +30,15 @@ export default function AccountPage() {
+
+
+
+
+ View recent security activity
+
diff --git a/src/components/molecular/UserAuditTrail/UserAuditTrail.accessibility.test.tsx b/src/components/molecular/UserAuditTrail/UserAuditTrail.accessibility.test.tsx
new file mode 100644
index 00000000..81343086
--- /dev/null
+++ b/src/components/molecular/UserAuditTrail/UserAuditTrail.accessibility.test.tsx
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, waitFor } from '@testing-library/react';
+import { axe, toHaveNoViolations } from 'jest-axe';
+
+const limitMock = vi.fn();
+const orderMock = vi.fn(() => ({ limit: limitMock }));
+const selectMock = vi.fn(() => ({ order: orderMock }));
+const fromMock = vi.fn((_table: string) => ({ select: selectMock }));
+
+vi.mock('@/lib/supabase/client', () => ({
+ supabase: { from: (table: string) => fromMock(table) },
+}));
+
+import UserAuditTrail from './UserAuditTrail';
+
+expect.extend(toHaveNoViolations);
+
+const ROW = {
+ id: '00000000-0000-0000-0000-000000000001',
+ event_type: 'sign_in_success',
+ success: true,
+ ip_address: '203.0.113.7',
+ user_agent: 'Mozilla/5.0',
+ created_at: '2026-06-01T10:00:00.000Z',
+};
+
+describe('UserAuditTrail Accessibility', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ limitMock.mockResolvedValue({ data: [ROW], error: null });
+ });
+
+ it('should have no accessibility violations (populated table)', async () => {
+ const { container, findByText } = render();
+ await findByText('Signed in'); // wait for data render
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should have no accessibility violations (empty state)', async () => {
+ limitMock.mockResolvedValue({ data: [], error: null });
+ const { container, findByText } = render();
+ await findByText('No recent activity to show.');
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should label the region and table for screen readers', async () => {
+ const { container, findByText } = render();
+ await findByText('Signed in');
+ // section is labelled by its heading
+ const section = container.querySelector('section[aria-labelledby]');
+ expect(section).toBeInTheDocument();
+ // table has a caption
+ await waitFor(() =>
+ expect(container.querySelector('table caption')).toBeInTheDocument()
+ );
+ });
+});
diff --git a/src/components/molecular/UserAuditTrail/UserAuditTrail.stories.tsx b/src/components/molecular/UserAuditTrail/UserAuditTrail.stories.tsx
new file mode 100644
index 00000000..68ff532a
--- /dev/null
+++ b/src/components/molecular/UserAuditTrail/UserAuditTrail.stories.tsx
@@ -0,0 +1,48 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+import UserAuditTrail from './UserAuditTrail';
+
+/**
+ * UserAuditTrail shows the signed-in user's own recent auth audit events
+ * (sign-ins, password changes, verification, etc.), read RLS-scoped from
+ * `auth_audit_logs`. In Storybook there is no authenticated Supabase session,
+ * so the live query resolves to an empty/error state — the stories below
+ * exercise the component's own loading and empty rendering. See the unit tests
+ * for populated-table coverage (the data layer is mocked there).
+ */
+const meta: Meta = {
+ title: 'Components/Molecular/UserAuditTrail',
+ component: UserAuditTrail,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ "UserAuditTrail component for the molecular category — a user-facing 'recent security activity' view.",
+ },
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ limit: {
+ control: 'number',
+ description: 'How many recent events to show (default 25)',
+ },
+ className: {
+ control: 'text',
+ description: 'Additional CSS classes',
+ },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {},
+};
+
+export const SmallLimit: Story = {
+ args: {
+ limit: 5,
+ },
+};
diff --git a/src/components/molecular/UserAuditTrail/UserAuditTrail.test.tsx b/src/components/molecular/UserAuditTrail/UserAuditTrail.test.tsx
new file mode 100644
index 00000000..4868fcd8
--- /dev/null
+++ b/src/components/molecular/UserAuditTrail/UserAuditTrail.test.tsx
@@ -0,0 +1,85 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Chainable supabase mock: .from().select().order().limit() resolves to { data, error }.
+const limitMock = vi.fn();
+const orderMock = vi.fn(() => ({ limit: limitMock }));
+const selectMock = vi.fn(() => ({ order: orderMock }));
+const fromMock = vi.fn((_table: string) => ({ select: selectMock }));
+
+vi.mock('@/lib/supabase/client', () => ({
+ supabase: {
+ from: (table: string) => fromMock(table),
+ },
+}));
+
+import UserAuditTrail from './UserAuditTrail';
+
+const ROW = {
+ id: '00000000-0000-0000-0000-000000000001',
+ event_type: 'sign_in_success',
+ success: true,
+ ip_address: '203.0.113.7',
+ user_agent: 'Mozilla/5.0',
+ created_at: '2026-06-01T10:00:00.000Z',
+};
+
+describe('UserAuditTrail', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ limitMock.mockResolvedValue({ data: [ROW], error: null });
+ });
+
+ it('renders without crashing', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ it('queries the user audit log via RLS-scoped select (no client-side user_id filter)', async () => {
+ render();
+ await waitFor(() =>
+ expect(fromMock).toHaveBeenCalledWith('auth_audit_logs')
+ );
+ expect(orderMock).toHaveBeenCalledWith('created_at', { ascending: false });
+ expect(limitMock).toHaveBeenCalledWith(10);
+ });
+
+ it('renders a humanized event label + success badge for each entry', async () => {
+ render();
+ expect(await screen.findByText('Signed in')).toBeInTheDocument();
+ expect(screen.getByText('Success')).toBeInTheDocument();
+ expect(screen.getByText('203.0.113.7')).toBeInTheDocument();
+ });
+
+ it('shows a failed badge for unsuccessful events', async () => {
+ limitMock.mockResolvedValue({
+ data: [{ ...ROW, event_type: 'sign_in_failed', success: false }],
+ error: null,
+ });
+ render();
+ expect(await screen.findByText('Failed sign-in')).toBeInTheDocument();
+ expect(screen.getByText('Failed')).toBeInTheDocument();
+ });
+
+ it('shows an empty state when there are no entries', async () => {
+ limitMock.mockResolvedValue({ data: [], error: null });
+ render();
+ expect(
+ await screen.findByText('No recent activity to show.')
+ ).toBeInTheDocument();
+ });
+
+ it('shows an error alert when the query fails', async () => {
+ limitMock.mockResolvedValue({
+ data: null,
+ error: { message: 'boom' },
+ });
+ render();
+ expect(await screen.findByRole('alert')).toHaveTextContent('boom');
+ });
+
+ it('defaults to a limit of 25 when none is given', async () => {
+ render();
+ await waitFor(() => expect(limitMock).toHaveBeenCalledWith(25));
+ });
+});
diff --git a/src/components/molecular/UserAuditTrail/UserAuditTrail.tsx b/src/components/molecular/UserAuditTrail/UserAuditTrail.tsx
new file mode 100644
index 00000000..2afcfd83
--- /dev/null
+++ b/src/components/molecular/UserAuditTrail/UserAuditTrail.tsx
@@ -0,0 +1,182 @@
+'use client';
+
+import React, { useEffect, useState } from 'react';
+import { supabase } from '@/lib/supabase/client';
+
+/**
+ * A single auth audit row, as the signed-in user can see their OWN entries.
+ * Shape mirrors the `auth_audit_logs` table (the columns RLS exposes to the user).
+ */
+export interface UserAuditEntry {
+ id: string;
+ event_type: string;
+ success: boolean;
+ ip_address: string | null;
+ user_agent: string | null;
+ created_at: string;
+}
+
+export interface UserAuditTrailProps {
+ /** How many recent events to show (default 25). */
+ limit?: number;
+ /** Additional CSS classes */
+ className?: string;
+}
+
+/**
+ * Human labels for the auth event types stored in `auth_audit_logs`.
+ */
+const EVENT_LABELS: Record = {
+ sign_up: 'Account created',
+ sign_in: 'Signed in',
+ sign_in_success: 'Signed in',
+ sign_in_failed: 'Failed sign-in',
+ sign_out: 'Signed out',
+ password_change: 'Password changed',
+ password_reset_request: 'Password reset requested',
+ password_reset_complete: 'Password reset completed',
+ email_verification: 'Email verification',
+ email_verification_sent: 'Verification email sent',
+ email_verification_complete: 'Email verified',
+ token_refresh: 'Session refreshed',
+ account_delete: 'Account deleted',
+ oauth_link: 'Linked a sign-in provider',
+ oauth_unlink: 'Unlinked a sign-in provider',
+};
+
+function labelFor(eventType: string): string {
+ return EVENT_LABELS[eventType] ?? eventType;
+}
+
+function formatWhen(iso: string): string {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return iso;
+ return d.toLocaleString(undefined, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ });
+}
+
+/**
+ * UserAuditTrail — a user-facing "recent security activity" view (#23, feature 005).
+ *
+ * Renders the signed-in user's own auth audit log (sign-ins, password changes,
+ * verification, etc.). Security comes from RLS: the `auth_audit_logs` policy
+ * "Users can view own audit logs" (`auth.uid() = user_id`) means a plain
+ * authenticated SELECT returns ONLY this user's rows — no client-side user_id
+ * filter is trusted for isolation. Mirrors the admin AdminAuditTrail organism
+ * but intentionally simpler (no cross-user views, no burst grouping).
+ *
+ * @category molecular
+ */
+export default function UserAuditTrail({
+ limit = 25,
+ className = '',
+}: UserAuditTrailProps) {
+ const [entries, setEntries] = useState(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ async function load() {
+ // RLS scopes this to the caller's own rows (auth.uid() = user_id).
+ const { data, error: queryError } = await supabase
+ .from('auth_audit_logs')
+ .select('id, event_type, success, ip_address, user_agent, created_at')
+ .order('created_at', { ascending: false })
+ .limit(limit);
+
+ if (cancelled) return;
+
+ if (queryError) {
+ setError(queryError.message);
+ setEntries([]);
+ return;
+ }
+ // Cast via `unknown`: the generated src/lib/supabase/types.ts is stale —
+ // its `auth_audit_logs.Row` omits the `success` / `error_message` columns
+ // that exist in the live table + the monolithic migration, so the query
+ // builder mis-infers a SelectQueryError. The runtime row has `success`.
+ // (Regenerating the Supabase types is tracked separately, out of #23 scope.)
+ setEntries((data ?? []) as unknown as UserAuditEntry[]);
+ }
+
+ void load();
+ return () => {
+ cancelled = true;
+ };
+ }, [limit]);
+
+ return (
+
+
+ Recent security activity
+
+
+ Your account's recent sign-ins and security events. Entries are
+ kept for 90 days.
+
+
+ {error && (
+
+ Could not load your activity: {error}
+
+ )}
+
+ {!error && entries === null && (
+
+
+ Loading your activity…
+
+ )}
+
+ {!error && entries !== null && entries.length === 0 && (
+ No recent activity to show.
+ )}
+
+ {!error && entries !== null && entries.length > 0 && (
+
+
+ Your recent security events
+
+
+ | Event |
+ When |
+ Result |
+ IP address |
+
+
+
+ {entries.map((entry) => (
+
+ | {labelFor(entry.event_type)} |
+
+
+ |
+
+
+ {entry.success ? 'Success' : 'Failed'}
+
+ |
+
+ {entry.ip_address ?? '—'}
+ |
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/molecular/UserAuditTrail/index.tsx b/src/components/molecular/UserAuditTrail/index.tsx
new file mode 100644
index 00000000..a83c6a7d
--- /dev/null
+++ b/src/components/molecular/UserAuditTrail/index.tsx
@@ -0,0 +1,2 @@
+export { default } from './UserAuditTrail';
+export type { UserAuditTrailProps, UserAuditEntry } from './UserAuditTrail';