diff --git a/frontend/cntr/ErrorBoundary/ErrorBoundary.test.tsx b/frontend/cntr/ErrorBoundary/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..44f7831
--- /dev/null
+++ b/frontend/cntr/ErrorBoundary/ErrorBoundary.test.tsx
@@ -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
OK
;
+};
+
+beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}); });
+
+describe('ErrorBoundary', () => {
+ it('renders children when no error', () => {
+ render();
+ expect(screen.getByText('OK')).toBeInTheDocument();
+ });
+
+ it('renders default fallback UI on error', () => {
+ render();
+ expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
+ });
+
+ it('renders custom fallback when provided', () => {
+ render(Custom error}>);
+ expect(screen.getByText('Custom error')).toBeInTheDocument();
+ });
+
+ it('resets on Try again click', () => {
+ render();
+ fireEvent.click(screen.getByRole('button', { name: /try again/i }));
+ expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/frontend/cntr/ErrorBoundary/ErrorBoundary.tsx b/frontend/cntr/ErrorBoundary/ErrorBoundary.tsx
new file mode 100644
index 0000000..d8c7841
--- /dev/null
+++ b/frontend/cntr/ErrorBoundary/ErrorBoundary.tsx
@@ -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 {
+ 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 (
+
+
Something went wrong
+
An unexpected error occurred. Please try again.
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
\ No newline at end of file
diff --git a/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.test.tsx b/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.test.tsx
new file mode 100644
index 0000000..e29e70a
--- /dev/null
+++ b/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.test.tsx
@@ -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();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('shows Weak for short simple password', () => {
+ render();
+ expect(screen.getByText('Weak')).toBeInTheDocument();
+ });
+
+ it('shows Fair for moderate password', () => {
+ render();
+ expect(screen.getByText(/fair|good|strong/i)).toBeInTheDocument();
+ });
+
+ it('shows Strong for complex password', () => {
+ render();
+ expect(screen.getByText('Strong')).toBeInTheDocument();
+ });
+
+ it('renders 4 segments', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('.flex-1').length).toBe(4);
+ });
+});
\ No newline at end of file
diff --git a/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.tsx b/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.tsx
new file mode 100644
index 0000000..c058637
--- /dev/null
+++ b/frontend/cntr/PasswordStrengthMeter/PasswordStrengthMeter.tsx
@@ -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 = ({ password }) => {
+ if (!password) return null;
+ const level = getScore(password);
+ const { label, colour } = LEVELS[level];
+
+ return (
+
+
+ {LEVELS.map((l, i) => (
+
+ ))}
+
+
{label}
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.test.tsx b/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.test.tsx
new file mode 100644
index 0000000..3aa2302
--- /dev/null
+++ b/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.test.tsx
@@ -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();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('does not render when more than 5 minutes remain', () => {
+ const expiresAt = Date.now() + 10 * 60 * 1000;
+ render();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('renders modal when ≤ 5 minutes remain', () => {
+ const expiresAt = Date.now() + 3 * 60 * 1000;
+ render();
+ 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();
+ 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();
+ screen.getByText('Sign out').click();
+ expect(onSignOut).toHaveBeenCalledTimes(1);
+ });
+
+ it('countdown decrements every second', () => {
+ const expiresAt = Date.now() + 2 * 60 * 1000; // 2 min
+ render();
+ const before = screen.getByText(/^\d+:\d+$/).textContent;
+ act(() => { vi.advanceTimersByTime(1000); });
+ const after = screen.getByText(/^\d+:\d+$/).textContent;
+ expect(before).not.toBe(after);
+ });
+});
\ No newline at end of file
diff --git a/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.tsx b/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.tsx
new file mode 100644
index 0000000..cff8d21
--- /dev/null
+++ b/frontend/cntr/SessionTimeoutModal/SessionTimeoutModal.tsx
@@ -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 = ({ 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 (
+
+
+
Session expiring soon
+
Your session will expire in
+
{display}
+
+
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/cntr/SessionTimeoutModal/useSessionTimer.ts b/frontend/cntr/SessionTimeoutModal/useSessionTimer.ts
new file mode 100644
index 0000000..72c004c
--- /dev/null
+++ b/frontend/cntr/SessionTimeoutModal/useSessionTimer.ts
@@ -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 };
+}
\ No newline at end of file
diff --git a/frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx b/frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx
new file mode 100644
index 0000000..fbb0d35
--- /dev/null
+++ b/frontend/cntr/Skeletons/InvoiceRowSkeleton.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+export const InvoiceRowSkeleton: React.FC = () => (
+
+);
\ No newline at end of file
diff --git a/frontend/cntr/Skeletons/MemberRowSkeleton.tsx b/frontend/cntr/Skeletons/MemberRowSkeleton.tsx
new file mode 100644
index 0000000..e9962e9
--- /dev/null
+++ b/frontend/cntr/Skeletons/MemberRowSkeleton.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+export const MemberRowSkeleton: React.FC = () => (
+
+);
\ No newline at end of file
diff --git a/frontend/cntr/Skeletons/Skeletons.test.tsx b/frontend/cntr/Skeletons/Skeletons.test.tsx
new file mode 100644
index 0000000..5cc9954
--- /dev/null
+++ b/frontend/cntr/Skeletons/Skeletons.test.tsx
@@ -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();
+ expect(container.firstChild).toHaveClass('animate-pulse');
+ });
+ it('InvoiceRowSkeleton renders with animate-pulse', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('animate-pulse');
+ });
+ it('MemberRowSkeleton renders with animate-pulse', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('animate-pulse');
+ });
+ it('TableRowSkeleton renders default 4 columns', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveClass('animate-pulse');
+ expect(container.querySelectorAll('.flex-1').length).toBe(4);
+ });
+ it('TableRowSkeleton renders custom column count', () => {
+ const { container } = render();
+ expect(container.querySelectorAll('.flex-1').length).toBe(6);
+ });
+});
\ No newline at end of file
diff --git a/frontend/cntr/Skeletons/TableRowSkeleton.tsx b/frontend/cntr/Skeletons/TableRowSkeleton.tsx
new file mode 100644
index 0000000..f41869d
--- /dev/null
+++ b/frontend/cntr/Skeletons/TableRowSkeleton.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+interface Props { cols?: number; }
+export const TableRowSkeleton: React.FC = ({ cols = 4 }) => (
+
+ {Array.from({ length: cols }).map((_, i) => (
+
+ ))}
+
+);
\ No newline at end of file
diff --git a/frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx b/frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx
new file mode 100644
index 0000000..fd48618
--- /dev/null
+++ b/frontend/cntr/Skeletons/WorkspaceCardSkeleton.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+export const WorkspaceCardSkeleton: React.FC = () => (
+
+);
\ No newline at end of file