Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/app/account/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ProtectedRoute>
<main className="container mx-auto px-4 py-12 sm:px-6 md:py-16 lg:px-8">
<div className="mx-auto max-w-3xl">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-3xl font-bold">Security Activity</h1>
<Link href="/account" className="btn btn-ghost min-h-11">
Back to Account
</Link>
</div>

<UserAuditTrail />
</div>
</main>
</ProtectedRoute>
);
}
9 changes: 9 additions & 0 deletions src/app/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export default function AccountPage() {
</div>

<AccountSettings />

<div className="divider" />

<Link
href="/account/audit"
className="btn btn-outline min-h-11 w-full"
>
View recent security activity
</Link>
</div>
</main>
</ProtectedRoute>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<UserAuditTrail />);
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(<UserAuditTrail />);
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(<UserAuditTrail />);
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()
);
});
});
48 changes: 48 additions & 0 deletions src/components/molecular/UserAuditTrail/UserAuditTrail.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof UserAuditTrail> = {
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<typeof meta>;

export const Default: Story = {
args: {},
};

export const SmallLimit: Story = {
args: {
limit: 5,
},
};
85 changes: 85 additions & 0 deletions src/components/molecular/UserAuditTrail/UserAuditTrail.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<UserAuditTrail />);
expect(container.firstChild).toBeInTheDocument();
});

it('queries the user audit log via RLS-scoped select (no client-side user_id filter)', async () => {
render(<UserAuditTrail limit={10} />);
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(<UserAuditTrail />);
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(<UserAuditTrail />);
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(<UserAuditTrail />);
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(<UserAuditTrail />);
expect(await screen.findByRole('alert')).toHaveTextContent('boom');
});

it('defaults to a limit of 25 when none is given', async () => {
render(<UserAuditTrail />);
await waitFor(() => expect(limitMock).toHaveBeenCalledWith(25));
});
});
Loading
Loading