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 && ( +
+
+ )} + + {!error && entries !== null && entries.length === 0 && ( +

No recent activity to show.

+ )} + + {!error && entries !== null && entries.length > 0 && ( +
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + ))} + +
Your recent security events
EventWhenResultIP address
{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';