Refactor the "God Component" (App.tsx) Your App.tsx has lower branch coverage (77%) and uncovered lines. This usually means it's doing too much: managing routing, auth state checks, theme toggling, and global error handling. Move Logic to "Initialization Hooks": Create a useAppInitialization hook that handles the OAuth token check, version check, and theme sync. Use Layouts for Routing: Move the "What's New" modal and "Anonymous Banner" into the MainLayout or a specialized AppGuard component, leaving App.tsx as a clean list of Routes.
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 56s
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 56s
This commit is contained in:
40
src/components/AnonymousUserBanner.test.tsx
Normal file
40
src/components/AnonymousUserBanner.test.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// src/components/AnonymousUserBanner.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AnonymousUserBanner } from './AnonymousUserBanner';
|
||||
|
||||
// Mock the icon to ensure it is rendered correctly
|
||||
vi.mock('./icons/InformationCircleIcon', () => ({
|
||||
InformationCircleIcon: (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg data-testid="info-icon" {...props} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe('AnonymousUserBanner', () => {
|
||||
it('should render the banner with the correct text content and accessibility role', () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
||||
|
||||
// Check for accessibility role
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/you're viewing as a guest/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/to save your flyers, create a watchlist, and access more features/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /sign up or log in/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('info-icon')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('info-icon')).toHaveClass('text-blue-500');
|
||||
});
|
||||
|
||||
it('should call onOpenProfile when the "sign up or log in" button is clicked', () => {
|
||||
const mockOnOpenProfile = vi.fn();
|
||||
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
|
||||
|
||||
const loginButton = screen.getByRole('button', { name: /sign up or log in/i });
|
||||
fireEvent.click(loginButton);
|
||||
|
||||
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
41
src/components/AnonymousUserBanner.tsx
Normal file
41
src/components/AnonymousUserBanner.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
// src/components/AnonymousUserBanner.tsx
|
||||
import React from 'react';
|
||||
import { InformationCircleIcon } from './icons/InformationCircleIcon';
|
||||
|
||||
interface AnonymousUserBannerProps {
|
||||
/**
|
||||
* A callback function to open the login/signup modal.
|
||||
*/
|
||||
onOpenProfile: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A banner displayed to anonymous users to encourage them to sign up or log in.
|
||||
*/
|
||||
export const AnonymousUserBanner: React.FC<AnonymousUserBannerProps> = ({ onOpenProfile }) => {
|
||||
return (
|
||||
<div
|
||||
className="bg-blue-100 dark:bg-blue-900/30 border-l-4 border-blue-500 text-blue-700 dark:text-blue-300 p-4 rounded-r-lg"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="py-1">
|
||||
<InformationCircleIcon className="h-6 w-6 text-blue-500 mr-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold">You're viewing as a guest.</p>
|
||||
<p className="text-sm">
|
||||
To save your flyers, create a watchlist, and access more features, please{' '}
|
||||
<button
|
||||
onClick={onOpenProfile}
|
||||
className="font-bold underline hover:text-blue-600 dark:hover:text-blue-200"
|
||||
>
|
||||
sign up or log in
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
93
src/components/AppGuard.test.tsx
Normal file
93
src/components/AppGuard.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/components/AppGuard.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AppGuard } from './AppGuard';
|
||||
import { useAppInitialization } from '../hooks/useAppInitialization';
|
||||
import { useModal } from '../hooks/useModal';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../hooks/useAppInitialization');
|
||||
vi.mock('../hooks/useModal');
|
||||
vi.mock('./WhatsNewModal', () => ({
|
||||
WhatsNewModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div data-testid="whats-new-modal-mock" /> : null,
|
||||
}));
|
||||
vi.mock('../config', () => ({
|
||||
default: {
|
||||
app: { version: '1.0.0', commitMessage: 'Test commit' },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
|
||||
const mockedUseModal = vi.mocked(useModal);
|
||||
|
||||
describe('AppGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mocks
|
||||
mockedUseAppInitialization.mockReturnValue({
|
||||
isDarkMode: false,
|
||||
unitSystem: 'imperial',
|
||||
});
|
||||
mockedUseModal.mockReturnValue({
|
||||
isModalOpen: vi.fn().mockReturnValue(false),
|
||||
openModal: vi.fn(),
|
||||
closeModal: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<AppGuard>
|
||||
<div>Child Content</div>
|
||||
</AppGuard>,
|
||||
);
|
||||
expect(screen.getByText('Child Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render WhatsNewModal when it is open', () => {
|
||||
mockedUseModal.mockReturnValue({
|
||||
...mockedUseModal(),
|
||||
isModalOpen: (modalId) => modalId === 'whatsNew',
|
||||
});
|
||||
render(
|
||||
<AppGuard>
|
||||
<div>Child</div>
|
||||
</AppGuard>,
|
||||
);
|
||||
expect(screen.getByTestId('whats-new-modal-mock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should set dark mode styles for toaster', async () => {
|
||||
mockedUseAppInitialization.mockReturnValue({
|
||||
isDarkMode: true,
|
||||
unitSystem: 'imperial',
|
||||
});
|
||||
render(
|
||||
<AppGuard>
|
||||
<div>Child</div>
|
||||
</AppGuard>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const styleTag = document.querySelector('style');
|
||||
expect(styleTag).not.toBeNull();
|
||||
expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563');
|
||||
expect(styleTag!.innerHTML).toContain('--toast-color: #F9FAFB');
|
||||
});
|
||||
});
|
||||
|
||||
it('should set light mode styles for toaster', async () => {
|
||||
render(
|
||||
<AppGuard>
|
||||
<div>Child</div>
|
||||
</AppGuard>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const styleTag = document.querySelector('style');
|
||||
expect(styleTag).not.toBeNull();
|
||||
expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF');
|
||||
expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937');
|
||||
});
|
||||
});
|
||||
});
|
||||
47
src/components/AppGuard.tsx
Normal file
47
src/components/AppGuard.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// src/components/AppGuard.tsx
|
||||
import React, { useCallback } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { useAppInitialization } from '../hooks/useAppInitialization';
|
||||
import { useModal } from '../hooks/useModal';
|
||||
import { WhatsNewModal } from './WhatsNewModal';
|
||||
import config from '../config';
|
||||
|
||||
interface AppGuardProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppGuard: React.FC<AppGuardProps> = ({ children }) => {
|
||||
// This hook handles OAuth tokens, version checks, and returns theme state.
|
||||
const { isDarkMode } = useAppInitialization();
|
||||
const { isModalOpen, closeModal } = useModal();
|
||||
|
||||
const handleCloseWhatsNew = useCallback(() => closeModal('whatsNew'), [closeModal]);
|
||||
|
||||
const appVersion = config.app.version;
|
||||
const commitMessage = config.app.commitMessage;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
|
||||
{/* Toaster component for displaying notifications. It's placed at the top level. */}
|
||||
<Toaster position="top-center" reverseOrder={false} />
|
||||
{/* Add CSS variables for toast theming based on dark mode */}
|
||||
<style>{`
|
||||
:root {
|
||||
--toast-bg: ${isDarkMode ? '#4B5563' : '#FFFFFF'};
|
||||
--toast-color: ${isDarkMode ? '#F9FAFB' : '#1F2937'};
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{appVersion && commitMessage && (
|
||||
<WhatsNewModal
|
||||
isOpen={isModalOpen('whatsNew')}
|
||||
onClose={handleCloseWhatsNew}
|
||||
version={appVersion}
|
||||
commitMessage={commitMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
93
src/components/PasswordInput.test.tsx
Normal file
93
src/components/PasswordInput.test.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/components/PasswordInput.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { PasswordInput } from './PasswordInput';
|
||||
// Mock the child PasswordStrengthIndicator component to isolate the test (relative to new location)
|
||||
vi.mock('./PasswordStrengthIndicator', () => ({
|
||||
PasswordStrengthIndicator: ({ password }: { password?: string }) => (
|
||||
<div data-testid="strength-indicator">{`Strength for: ${password}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('PasswordInput (in auth feature)', () => {
|
||||
it('should render as a password input by default', () => {
|
||||
render(<PasswordInput placeholder="Enter password" />);
|
||||
const input = screen.getByPlaceholderText('Enter password');
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('should toggle input type between password and text when the eye icon is clicked', () => {
|
||||
render(<PasswordInput placeholder="Enter password" />);
|
||||
const input = screen.getByPlaceholderText('Enter password');
|
||||
const toggleButton = screen.getByRole('button', { name: /show password/i });
|
||||
|
||||
// Initial state
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
|
||||
// Click to show
|
||||
fireEvent.click(toggleButton);
|
||||
expect(input).toHaveAttribute('type', 'text');
|
||||
expect(toggleButton).toHaveAttribute('aria-label', 'Hide password');
|
||||
|
||||
// Click to hide again
|
||||
fireEvent.click(toggleButton);
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
expect(toggleButton).toHaveAttribute('aria-label', 'Show password');
|
||||
});
|
||||
|
||||
it('should pass through standard input attributes', () => {
|
||||
const handleChange = vi.fn();
|
||||
render(
|
||||
<PasswordInput
|
||||
value="test"
|
||||
onChange={handleChange}
|
||||
placeholder="Enter password"
|
||||
className="extra-class"
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter password');
|
||||
expect(input).toHaveValue('test');
|
||||
expect(input).toHaveClass('extra-class');
|
||||
|
||||
fireEvent.change(input, { target: { value: 'new value' } });
|
||||
expect(handleChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not show strength indicator by default', () => {
|
||||
render(<PasswordInput value="some-password" onChange={() => {}} />);
|
||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show strength indicator when showStrength is true and there is a value', () => {
|
||||
render(<PasswordInput value="some-password" showStrength onChange={() => {}} />);
|
||||
const indicator = screen.getByTestId('strength-indicator');
|
||||
expect(indicator).toBeInTheDocument();
|
||||
expect(indicator).toHaveTextContent('Strength for: some-password');
|
||||
});
|
||||
|
||||
it('should not show strength indicator when showStrength is true but value is empty', () => {
|
||||
render(<PasswordInput value="" showStrength onChange={() => {}} />);
|
||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle undefined className gracefully', () => {
|
||||
render(<PasswordInput placeholder="No class" />);
|
||||
const input = screen.getByPlaceholderText('No class');
|
||||
expect(input.className).not.toContain('undefined');
|
||||
expect(input.className).toContain('block w-full');
|
||||
});
|
||||
|
||||
it('should not show strength indicator if value is undefined', () => {
|
||||
render(<PasswordInput showStrength onChange={() => {}} />);
|
||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show strength indicator if value is not a string', () => {
|
||||
// Force a non-string value to test the typeof check
|
||||
const props = { value: 12345, showStrength: true, onChange: () => {} } as any;
|
||||
render(<PasswordInput {...props} />);
|
||||
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
54
src/components/PasswordInput.tsx
Normal file
54
src/components/PasswordInput.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/pages/admin/components/PasswordInput.tsx
|
||||
// src/components/PasswordInput.tsx
|
||||
import React, { useState } from 'react';
|
||||
import { EyeIcon } from './icons/EyeIcon';
|
||||
import { EyeSlashIcon } from './icons/EyeSlashIcon';
|
||||
import { EyeIcon } from './icons/EyeIcon';
|
||||
import { EyeSlashIcon } from './icons/EyeSlashIcon';
|
||||
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
||||
|
||||
/**
|
||||
* Props for the PasswordInput component.
|
||||
* It extends standard HTML input attributes and adds a custom prop to show a strength indicator.
|
||||
*/
|
||||
interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
showStrength?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable password input component with a show/hide toggle
|
||||
* and an optional password strength indicator.
|
||||
*/
|
||||
export const PasswordInput: React.FC<PasswordInputProps> = ({
|
||||
showStrength = false,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<input
|
||||
{...props}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
// Combine passed classNames with default styling
|
||||
className={`block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm ${className || ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeSlashIcon className="h-5 w-5" /> : <EyeIcon className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{showStrength && typeof props.value === 'string' && props.value.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<PasswordStrengthIndicator password={props.value} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
src/components/PasswordStrengthIndicator.test.tsx
Normal file
109
src/components/PasswordStrengthIndicator.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// src/pages/admin/components/PasswordStrengthIndicator.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
||||
import zxcvbn from 'zxcvbn';
|
||||
|
||||
// Mock the zxcvbn library to control its output for testing
|
||||
vi.mock('zxcvbn');
|
||||
|
||||
describe('PasswordStrengthIndicator', () => {
|
||||
it('should render 5 gray bars when no password is provided', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({ score: -1, feedback: { warning: '', suggestions: [] } });
|
||||
const { container } = render(<PasswordStrengthIndicator password="" />);
|
||||
const bars = container.querySelectorAll('.h-1\\.5');
|
||||
expect(bars).toHaveLength(5);
|
||||
bars.forEach((bar) => {
|
||||
expect(bar).toHaveClass('bg-gray-200');
|
||||
});
|
||||
expect(screen.queryByText(/Very Weak/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ score: 0, label: 'Very Weak', color: 'bg-red-500', bars: 1 },
|
||||
{ score: 1, label: 'Weak', color: 'bg-red-500', bars: 2 },
|
||||
{ score: 2, label: 'Fair', color: 'bg-orange-500', bars: 3 },
|
||||
{ score: 3, label: 'Good', color: 'bg-yellow-500', bars: 4 },
|
||||
{ score: 4, label: 'Strong', color: 'bg-green-500', bars: 5 },
|
||||
])('should render correctly for score $score ($label)', ({ score, label, color, bars }) => {
|
||||
(zxcvbn as Mock).mockReturnValue({ score, feedback: { warning: '', suggestions: [] } });
|
||||
const { container } = render(<PasswordStrengthIndicator password="some-password" />);
|
||||
|
||||
// Check the label
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
|
||||
// Check the bar colors
|
||||
const barElements = container.querySelectorAll('.h-1\\.5');
|
||||
expect(barElements).toHaveLength(5);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (i < bars) {
|
||||
expect(barElements[i]).toHaveClass(color);
|
||||
} else {
|
||||
expect(barElements[i]).toHaveClass('bg-gray-200');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should display a warning from zxcvbn', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({
|
||||
score: 1,
|
||||
feedback: {
|
||||
warning: 'This is a very common password',
|
||||
suggestions: [],
|
||||
},
|
||||
});
|
||||
render(<PasswordStrengthIndicator password="password" />);
|
||||
expect(screen.getByText(/this is a very common password/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display a suggestion from zxcvbn', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({
|
||||
score: 1,
|
||||
feedback: {
|
||||
warning: '',
|
||||
suggestions: ['Add another word or two'],
|
||||
},
|
||||
});
|
||||
render(<PasswordStrengthIndicator password="pass" />);
|
||||
expect(screen.getByText(/add another word or two/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should prioritize warning over suggestions', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({
|
||||
score: 1,
|
||||
feedback: { warning: 'A warning here', suggestions: ['A suggestion here'] },
|
||||
});
|
||||
render(<PasswordStrengthIndicator password="password" />);
|
||||
expect(screen.getByText(/a warning here/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/a suggestion here/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use default empty string if password prop is undefined', () => {
|
||||
(zxcvbn as Mock).mockReturnValue({ score: 0, feedback: { warning: '', suggestions: [] } });
|
||||
const { container } = render(<PasswordStrengthIndicator />);
|
||||
const bars = container.querySelectorAll('.h-1\\.5');
|
||||
expect(bars).toHaveLength(5);
|
||||
bars.forEach((bar) => {
|
||||
expect(bar).toHaveClass('bg-gray-200');
|
||||
});
|
||||
expect(screen.queryByText(/Very Weak/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle out-of-range scores gracefully (defensive)', () => {
|
||||
// Mock a score that isn't 0-4 to hit default switch cases
|
||||
(zxcvbn as Mock).mockReturnValue({ score: 99, feedback: { warning: '', suggestions: [] } });
|
||||
const { container } = render(<PasswordStrengthIndicator password="test" />);
|
||||
|
||||
// Check bars - should hit default case in getBarColor which returns gray
|
||||
const bars = container.querySelectorAll('.h-1\\.5');
|
||||
bars.forEach((bar) => {
|
||||
expect(bar).toHaveClass('bg-gray-200');
|
||||
});
|
||||
|
||||
// Check label - should hit default case in getStrengthLabel which returns empty string
|
||||
const labelSpan = container.querySelector('span.font-bold');
|
||||
expect(labelSpan).toHaveTextContent('');
|
||||
});
|
||||
});
|
||||
93
src/components/PasswordStrengthIndicator.tsx
Normal file
93
src/components/PasswordStrengthIndicator.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// src/pages/admin/components/PasswordStrengthIndicator.tsx
|
||||
// src/components/PasswordStrengthIndicator.tsx
|
||||
import React from 'react';
|
||||
import zxcvbn from 'zxcvbn';
|
||||
|
||||
interface PasswordStrengthIndicatorProps {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component that visually indicates the strength of a password using zxcvbn.
|
||||
* It displays a colored bar and provides feedback to the user.
|
||||
*/
|
||||
export const PasswordStrengthIndicator: React.FC<PasswordStrengthIndicatorProps> = ({
|
||||
password = '',
|
||||
}) => {
|
||||
const result = zxcvbn(password);
|
||||
const score = result.score; // Score from 0 (worst) to 4 (best)
|
||||
|
||||
// Function to determine the color of each segment of the strength bar
|
||||
const getBarColor = (index: number) => {
|
||||
if (password.length === 0) return 'bg-gray-200 dark:bg-gray-600';
|
||||
if (index > score) return 'bg-gray-200 dark:bg-gray-600';
|
||||
switch (score) {
|
||||
case 0:
|
||||
return 'bg-red-500';
|
||||
case 1:
|
||||
return 'bg-red-500';
|
||||
case 2:
|
||||
return 'bg-orange-500';
|
||||
case 3:
|
||||
return 'bg-yellow-500';
|
||||
case 4:
|
||||
return 'bg-green-500';
|
||||
default:
|
||||
return 'bg-gray-200 dark:bg-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
// Function to get a human-readable strength label
|
||||
const getStrengthLabel = () => {
|
||||
switch (score) {
|
||||
case 0:
|
||||
return 'Very Weak';
|
||||
case 1:
|
||||
return 'Weak';
|
||||
case 2:
|
||||
return 'Fair';
|
||||
case 3:
|
||||
return 'Good';
|
||||
case 4:
|
||||
return 'Strong';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex space-x-1">
|
||||
{/* Create 5 segments for the strength bar */}
|
||||
{Array.from(Array(5).keys()).map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`h-1.5 flex-1 rounded-full ${getBarColor(index)} transition-colors`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
{password.length > 0 && (
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span
|
||||
className={`font-bold ${
|
||||
score < 2 ? 'text-red-500' : score < 3 ? 'text-orange-500' : 'text-green-500'
|
||||
}`}
|
||||
>
|
||||
{getStrengthLabel()}
|
||||
</span>
|
||||
{/* Display feedback from zxcvbn if available */}
|
||||
{(result.feedback.warning || result.feedback.suggestions.length > 0) && (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-right">
|
||||
{/* Prioritize the warning over suggestions. */}
|
||||
{result.feedback.warning ? (
|
||||
<span>{result.feedback.warning}</span>
|
||||
) : (
|
||||
<span>{result.feedback.suggestions[0]}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user