From f948efc06c73b667c444c37c75c3a77b800dfb52 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 12 Jul 2026 15:23:29 +0100 Subject: [PATCH 1/9] Refactor and show password --- .../components/map/filters/AddFilterPanel.tsx | 43 +++++++------ frontend/src/components/ui/AuthModal.test.tsx | 63 +++++++++++++++++++ frontend/src/components/ui/AuthModal.tsx | 51 ++++++++++----- 3 files changed, 121 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/ui/AuthModal.test.tsx diff --git a/frontend/src/components/map/filters/AddFilterPanel.tsx b/frontend/src/components/map/filters/AddFilterPanel.tsx index 8303070..06141b2 100644 --- a/frontend/src/components/map/filters/AddFilterPanel.tsx +++ b/frontend/src/components/map/filters/AddFilterPanel.tsx @@ -32,6 +32,28 @@ import { type PoiFilterName, } from '../../../lib/poi-distance-filter'; +// Each resolver maps a raw pinned feature name to the folded filter card that +// represents it in the browser, or returns null when it doesn't belong to that fold. +const FOLDED_FILTER_RESOLVERS: Array<(name: string) => string | null> = [ + (name) => (isSchoolFilterName(name) ? SCHOOL_FILTER_NAME : null), + (name) => (isSpecificCrimeFilterName(name) ? SPECIFIC_CRIMES_FILTER_NAME : null), + (name) => (isElectionVoteShareFilterName(name) ? ELECTION_VOTE_SHARE_FILTER_NAME : null), + (name) => (isEthnicityFilterName(name) ? ETHNICITIES_FILTER_NAME : null), + (name) => (isQualificationFilterName(name) ? QUALIFICATIONS_FILTER_NAME : null), + (name) => (isTenureFilterName(name) ? TENURE_FILTER_NAME : null), + (name) => (isCouncilFilterName(name) ? COUNCIL_FILTER_NAME : null), + (name) => (isPoiDistanceFilterName(name) ? (getPoiFilterName(name) ?? POI_DISTANCE_FILTER_NAME) : null), + (name) => (isCrimeSeverityFilterName(name) ? (getCrimeSeverityFilterName(name) ?? name) : null), +]; + +function resolveBrowserPinnedFeature(name: string): string { + for (const resolve of FOLDED_FILTER_RESOLVERS) { + const folded = resolve(name); + if (folded) return folded; + } + return name; +} + interface AddFilterPanelProps { collapsed: boolean; isLoggedIn: boolean; @@ -91,26 +113,7 @@ export function AddFilterPanel({ const { t } = useTranslation(); const { setContainer, registerGroup, onToggle: revealGroupOnToggle } = useRevealOnExpand(); - const browserPinnedFeature = - pinnedFeature && isSchoolFilterName(pinnedFeature) - ? SCHOOL_FILTER_NAME - : pinnedFeature && isSpecificCrimeFilterName(pinnedFeature) - ? SPECIFIC_CRIMES_FILTER_NAME - : pinnedFeature && isElectionVoteShareFilterName(pinnedFeature) - ? ELECTION_VOTE_SHARE_FILTER_NAME - : pinnedFeature && isEthnicityFilterName(pinnedFeature) - ? ETHNICITIES_FILTER_NAME - : pinnedFeature && isQualificationFilterName(pinnedFeature) - ? QUALIFICATIONS_FILTER_NAME - : pinnedFeature && isTenureFilterName(pinnedFeature) - ? TENURE_FILTER_NAME - : pinnedFeature && isCouncilFilterName(pinnedFeature) - ? COUNCIL_FILTER_NAME - : pinnedFeature && isPoiDistanceFilterName(pinnedFeature) - ? (getPoiFilterName(pinnedFeature) ?? POI_DISTANCE_FILTER_NAME) - : pinnedFeature && isCrimeSeverityFilterName(pinnedFeature) - ? (getCrimeSeverityFilterName(pinnedFeature) ?? pinnedFeature) - : pinnedFeature; + const browserPinnedFeature = pinnedFeature && resolveBrowserPinnedFeature(pinnedFeature); const handleTogglePin = (name: string) => { if (name === SCHOOL_FILTER_NAME) { diff --git a/frontend/src/components/ui/AuthModal.test.tsx b/frontend/src/components/ui/AuthModal.test.tsx new file mode 100644 index 0000000..c581544 --- /dev/null +++ b/frontend/src/components/ui/AuthModal.test.tsx @@ -0,0 +1,63 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/analytics', () => ({ trackEvent: () => {} })); + +vi.mock('react-i18next', async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key }), + Trans: ({ i18nKey }: { i18nKey: string }) => <>{i18nKey}, +})); + +import AuthModal from './AuthModal'; + +const noop = async () => {}; + +function renderModal(initialTab: 'login' | 'register' = 'login') { + return render( + {}} + onLogin={noop} + onRegister={noop} + onOAuthLogin={noop} + onForgotPassword={noop} + loading={false} + error={null} + onClearError={() => {}} + initialTab={initialTab} + /> + ); +} + +afterEach(cleanup); + +describe('AuthModal password visibility toggle', () => { + it('starts hidden and reveals the password when clicked', () => { + renderModal('login'); + + const input = screen.getByLabelText('auth.password') as HTMLInputElement; + expect(input.type).toBe('password'); + + // Hidden state exposes a "show" toggle. + const toggle = screen.getByRole('button', { name: 'auth.showPassword' }); + expect(toggle.getAttribute('type')).toBe('button'); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + + fireEvent.click(toggle); + + expect(input.type).toBe('text'); + const hideToggle = screen.getByRole('button', { name: 'auth.hidePassword' }); + expect(hideToggle.getAttribute('aria-pressed')).toBe('true'); + + fireEvent.click(hideToggle); + expect(input.type).toBe('password'); + }); + + it('offers the toggle on the register tab too', () => { + renderModal('register'); + const input = screen.getByLabelText('auth.password') as HTMLInputElement; + expect(input.type).toBe('password'); + fireEvent.click(screen.getByRole('button', { name: 'auth.showPassword' })); + expect(input.type).toBe('text'); + }); +}); diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index 0bcd2a6..353d730 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -3,6 +3,8 @@ import { Trans, useTranslation } from 'react-i18next'; import type { ParseKeys } from 'i18next'; import { CloseIcon } from './icons/CloseIcon'; import { GoogleIcon } from './icons/GoogleIcon'; +import { EyeIcon } from './icons/EyeIcon'; +import { EyeOffIcon } from './icons/EyeOffIcon'; import { trackEvent } from '../../lib/analytics'; import { useModalA11y } from '../../hooks/useModalA11y'; import type { AuthErrorAction } from '../../lib/auth-errors'; @@ -42,6 +44,7 @@ export default function AuthModal({ const [view, setView] = useState(initialTab); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); const [resetSent, setResetSent] = useState(false); const dialogRef = useModalA11y(); const fieldId = useId(); @@ -232,22 +235,38 @@ export default function AuthModal({ > {t('auth.password')} - setPassword(e.target.value)} - required - minLength={8} - autoComplete={view === 'register' ? 'new-password' : 'current-password'} - className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" - placeholder={ - view === 'register' - ? t('auth.passwordPlaceholderRegister') - : t('auth.passwordPlaceholderLogin') - } - /> +
+ setPassword(e.target.value)} + required + minLength={8} + autoComplete={view === 'register' ? 'new-password' : 'current-password'} + className="w-full pl-3 pr-10 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" + placeholder={ + view === 'register' + ? t('auth.passwordPlaceholderRegister') + : t('auth.passwordPlaceholderLogin') + } + /> + +
{view === 'login' && ( + + ) : done ? ( +
+

{t('auth.resetSuccessBody')}

+ +
+ ) : ( +
+
+ +
+ setPassword(e.target.value)} + required + minLength={8} + autoComplete="new-password" + autoFocus + className="w-full pl-3 pr-10 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" + placeholder={t('auth.newPasswordPlaceholder')} + /> + +
+
+ + {error &&

{error}

} + + + + +
+ )} + + + ); +} diff --git a/frontend/src/components/map/AiFilterInput.tsx b/frontend/src/components/map/AiFilterInput.tsx index fabb9e7..a472e1f 100644 --- a/frontend/src/components/map/AiFilterInput.tsx +++ b/frontend/src/components/map/AiFilterInput.tsx @@ -143,11 +143,11 @@ export default memo(function AiFilterInput({ if (!expanded) { return ( -
+
diff --git a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx index 8fc69a8..6ff3cbf 100644 --- a/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx +++ b/frontend/src/components/map/filters/EnumFeatureFilterCard.tsx @@ -35,7 +35,7 @@ export function EnumFeatureFilterCard({ return (
diff --git a/frontend/src/components/map/filters/EthnicityFilterCard.tsx b/frontend/src/components/map/filters/EthnicityFilterCard.tsx index fcea4aa..5d981bd 100644 --- a/frontend/src/components/map/filters/EthnicityFilterCard.tsx +++ b/frontend/src/components/map/filters/EthnicityFilterCard.tsx @@ -119,7 +119,7 @@ export function EthnicityFilterCard({ return (
-