Skip to content
Open
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
33 changes: 33 additions & 0 deletions frontend/cntr/ErrorBoundary/ErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ErrorBoundary } from './ErrorBoundary';

const Bomb = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) throw new Error('Test error');
return <div>OK</div>;
};

beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}); });

describe('ErrorBoundary', () => {
it('renders children when no error', () => {
render(<ErrorBoundary><Bomb shouldThrow={false} /></ErrorBoundary>);
expect(screen.getByText('OK')).toBeInTheDocument();
});

it('renders default fallback UI on error', () => {
render(<ErrorBoundary><Bomb shouldThrow={true} /></ErrorBoundary>);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});

it('renders custom fallback when provided', () => {
render(<ErrorBoundary fallback={<div>Custom error</div>}><Bomb shouldThrow={true} /></ErrorBoundary>);
expect(screen.getByText('Custom error')).toBeInTheDocument();
});

it('resets on Try again click', () => {
render(<ErrorBoundary><Bomb shouldThrow={true} /></ErrorBoundary>);
fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
});
});
43 changes: 43 additions & 0 deletions frontend/cntr/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';

interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}

interface State {
hasError: boolean;
}

export class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false };

static getDerivedStateFromError(): State {
return { hasError: true };
}

componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[ErrorBoundary]', error, info);
}

reset = () => this.setState({ hasError: false });

render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex flex-col items-center justify-center py-16 text-center px-4">
<p className="text-lg font-semibold text-gray-700 mb-2">Something went wrong</p>
<p className="text-sm text-gray-500 mb-4">An unexpected error occurred. Please try again.</p>
<button
onClick={this.reset}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
30 changes: 30 additions & 0 deletions frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { PasswordStrengthMeter } from './PasswordStrengthMeter';

describe('PasswordStrengthMeter', () => {
it('renders nothing for empty password', () => {
const { container } = render(<PasswordStrengthMeter password="" />);
expect(container.firstChild).toBeNull();
});

it('shows Weak for short simple password', () => {
render(<PasswordStrengthMeter password="abc" />);
expect(screen.getByText('Weak')).toBeInTheDocument();
});

it('shows Fair for moderate password', () => {
render(<PasswordStrengthMeter password="abcABC12" />);
expect(screen.getByText(/fair|good|strong/i)).toBeInTheDocument();
});

it('shows Strong for complex password', () => {
render(<PasswordStrengthMeter password="Abcdef1!" />);
expect(screen.getByText('Strong')).toBeInTheDocument();
});

it('renders 4 segments', () => {
const { container } = render(<PasswordStrengthMeter password="Test1!" />);
expect(container.querySelectorAll('.flex-1').length).toBe(4);
});
});
43 changes: 43 additions & 0 deletions frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';

interface Props { password: string; }

const LEVELS = [
{ label: 'Weak', colour: 'bg-red-500' },
{ label: 'Fair', colour: 'bg-orange-400' },
{ label: 'Good', colour: 'bg-yellow-400' },
{ label: 'Strong', colour: 'bg-green-500' },
];

function getScore(password: string): number {
let score = 0;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password)) score++;
if (/[a-z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 1) return 0;
if (score === 2) return 1;
if (score === 3) return 2;
return 3;
}

export const PasswordStrengthMeter: React.FC<Props> = ({ password }) => {
if (!password) return null;
const level = getScore(password);
const { label, colour } = LEVELS[level];

return (
<div className="space-y-1">
<div className="flex gap-1">
{LEVELS.map((l, i) => (
<div
key={l.label}
className={`h-1.5 flex-1 rounded ${i <= level ? colour : 'bg-gray-200'}`}
/>
))}
</div>
<p className="text-xs text-gray-600">{label}</p>
</div>
);
};
50 changes: 50 additions & 0 deletions frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SessionTimeoutModal } from './SessionTimeoutModal';

beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });

describe('SessionTimeoutModal', () => {
it('does not render when expiresAt is null', () => {
render(<SessionTimeoutModal expiresAt={null} onExtend={vi.fn()} onSignOut={vi.fn()} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('does not render when more than 5 minutes remain', () => {
const expiresAt = Date.now() + 10 * 60 * 1000;
render(<SessionTimeoutModal expiresAt={expiresAt} onExtend={vi.fn()} onSignOut={vi.fn()} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('renders modal when ≤ 5 minutes remain', () => {
const expiresAt = Date.now() + 3 * 60 * 1000;
render(<SessionTimeoutModal expiresAt={expiresAt} onExtend={vi.fn()} onSignOut={vi.fn()} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('calls onExtend when Stay signed in clicked', () => {
const onExtend = vi.fn();
const expiresAt = Date.now() + 2 * 60 * 1000;
render(<SessionTimeoutModal expiresAt={expiresAt} onExtend={onExtend} onSignOut={vi.fn()} />);
screen.getByText('Stay signed in').click();
expect(onExtend).toHaveBeenCalledTimes(1);
});

it('calls onSignOut when Sign out clicked', () => {
const onSignOut = vi.fn();
const expiresAt = Date.now() + 2 * 60 * 1000;
render(<SessionTimeoutModal expiresAt={expiresAt} onExtend={vi.fn()} onSignOut={onSignOut} />);
screen.getByText('Sign out').click();
expect(onSignOut).toHaveBeenCalledTimes(1);
});

it('countdown decrements every second', () => {
const expiresAt = Date.now() + 2 * 60 * 1000; // 2 min
render(<SessionTimeoutModal expiresAt={expiresAt} onExtend={vi.fn()} onSignOut={vi.fn()} />);
const before = screen.getByText(/^\d+:\d+$/).textContent;
act(() => { vi.advanceTimersByTime(1000); });
const after = screen.getByText(/^\d+:\d+$/).textContent;
expect(before).not.toBe(after);
});
});
36 changes: 36 additions & 0 deletions frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import { useSessionTimer } from './useSessionTimer';

interface Props {
expiresAt: number | null;
onExtend: () => void;
onSignOut: () => void;
}

export const SessionTimeoutModal: React.FC<Props> = ({ expiresAt, onExtend, onSignOut }) => {
const { secondsLeft, showWarning } = useSessionTimer(expiresAt);

if (!showWarning) return null;

const mins = Math.floor(secondsLeft / 60);
const secs = secondsLeft % 60;
const display = `${mins}:${String(secs).padStart(2, '0')}`;

return (
<div role="dialog" aria-modal="true" aria-label="Session timeout warning" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-white rounded-lg shadow-xl p-6 w-80 text-center space-y-4">
<h2 className="text-lg font-semibold text-gray-800">Session expiring soon</h2>
<p className="text-sm text-gray-600">Your session will expire in</p>
<p className="text-3xl font-mono font-bold text-red-600" aria-live="polite">{display}</p>
<div className="flex gap-3 justify-center">
<button onClick={onExtend} className="px-4 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700">
Stay signed in
</button>
<button onClick={onSignOut} className="px-4 py-2 border border-gray-300 text-sm rounded hover:bg-gray-50">
Sign out
</button>
</div>
</div>
</div>
);
};
22 changes: 22 additions & 0 deletions frontend/cntr/SessionTimeoutModal/useSessionTimer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';

const WARN_BEFORE_SECONDS = 5 * 60; // 5 minutes

export function useSessionTimer(expiresAt: number | null): { secondsLeft: number; showWarning: boolean } {
const [secondsLeft, setSecondsLeft] = useState(0);

useEffect(() => {
if (!expiresAt) return;

const tick = () => {
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
setSecondsLeft(remaining);
};

tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [expiresAt]);

return { secondsLeft, showWarning: secondsLeft > 0 && secondsLeft <= WARN_BEFORE_SECONDS };
}
9 changes: 9 additions & 0 deletions frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react';
export const InvoiceRowSkeleton: React.FC = () => (
<div className="animate-pulse flex items-center gap-4 px-4 py-3 border-b border-gray-100">
<div className="h-4 bg-gray-200 rounded w-24" />
<div className="h-4 bg-gray-200 rounded w-32 flex-1" />
<div className="h-4 bg-gray-200 rounded w-20" />
<div className="h-6 bg-gray-200 rounded w-16" />
</div>
);
11 changes: 11 additions & 0 deletions frontend/cntr/Skeletons/MemberRowSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from 'react';
export const MemberRowSkeleton: React.FC = () => (
<div className="animate-pulse flex items-center gap-4 px-4 py-3 border-b border-gray-100">
<div className="h-9 w-9 bg-gray-200 rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-4 bg-gray-200 rounded w-40" />
<div className="h-3 bg-gray-200 rounded w-56" />
</div>
<div className="h-6 bg-gray-200 rounded w-20" />
</div>
);
30 changes: 30 additions & 0 deletions frontend/cntr/Skeletons/Skeletons.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { WorkspaceCardSkeleton } from './WorkspaceCardSkeleton';
import { InvoiceRowSkeleton } from './InvoiceRowSkeleton';
import { MemberRowSkeleton } from './MemberRowSkeleton';
import { TableRowSkeleton } from './TableRowSkeleton';

describe('Skeleton components', () => {
it('WorkspaceCardSkeleton renders with animate-pulse', () => {
const { container } = render(<WorkspaceCardSkeleton />);
expect(container.firstChild).toHaveClass('animate-pulse');
});
it('InvoiceRowSkeleton renders with animate-pulse', () => {
const { container } = render(<InvoiceRowSkeleton />);
expect(container.firstChild).toHaveClass('animate-pulse');
});
it('MemberRowSkeleton renders with animate-pulse', () => {
const { container } = render(<MemberRowSkeleton />);
expect(container.firstChild).toHaveClass('animate-pulse');
});
it('TableRowSkeleton renders default 4 columns', () => {
const { container } = render(<TableRowSkeleton />);
expect(container.firstChild).toHaveClass('animate-pulse');
expect(container.querySelectorAll('.flex-1').length).toBe(4);
});
it('TableRowSkeleton renders custom column count', () => {
const { container } = render(<TableRowSkeleton cols={6} />);
expect(container.querySelectorAll('.flex-1').length).toBe(6);
});
});
9 changes: 9 additions & 0 deletions frontend/cntr/Skeletons/TableRowSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import React from 'react';
interface Props { cols?: number; }
export const TableRowSkeleton: React.FC<Props> = ({ cols = 4 }) => (
<div className="animate-pulse flex items-center gap-4 px-4 py-3 border-b border-gray-100">
{Array.from({ length: cols }).map((_, i) => (
<div key={i} className="h-4 bg-gray-200 rounded flex-1" />
))}
</div>
);
12 changes: 12 additions & 0 deletions frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from 'react';
export const WorkspaceCardSkeleton: React.FC = () => (
<div className="animate-pulse rounded-lg border border-gray-200 p-4 space-y-3">
<div className="h-32 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
<div className="flex gap-2">
<div className="h-6 bg-gray-200 rounded w-16" />
<div className="h-6 bg-gray-200 rounded w-16" />
</div>
</div>
);
Loading