From 783e61c091a5bc9fe5b48ae92a18dc9458415d9e Mon Sep 17 00:00:00 2001 From: Justice Date: Fri, 28 Aug 2026 14:45:10 +0100 Subject: [PATCH 1/3] fix(auth): add explicit token expiration time and TTL notice to password reset emails (#354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Introduce password reset email template generator with relative TTL and absolute UTC deadline formatting • Add explicit expiration and stale link instructions to HTML and plaintext email templates • Implement HTML entity escaping and URL protocol sanitization against XSS • Update English and French localization message catalogs to maintain 100% parity • Add comprehensive unit tests covering formatting, edge cases, and injection prevention --- messages/en.json | 12 + messages/fr.json | 12 + src/lib/email/passwordResetTemplate.test.ts | 176 +++++++++ src/lib/email/passwordResetTemplate.ts | 388 ++++++++++++++++++++ 4 files changed, 588 insertions(+) create mode 100644 src/lib/email/passwordResetTemplate.test.ts create mode 100644 src/lib/email/passwordResetTemplate.ts diff --git a/messages/en.json b/messages/en.json index 40bfb6e4..1c2d4073 100644 --- a/messages/en.json +++ b/messages/en.json @@ -336,5 +336,17 @@ "calcFormula": "expected return = investment × (credit + green) ÷ 200 — read it in the contract ↗", "investCta": "Invest in the pool that funds this", "investNote": "You invest in the shared pool, which funds this project alongside others — not a per-project checkout." + }, + "PasswordResetEmail": { + "subject": "Reset your Heliobond password", + "greeting": "Hello,", + "lead": "We received a request to reset your password for your Heliobond account.", + "cta": "Reset your password", + "expiryAlertTitle": "Token Expiration & Security Notice", + "expiryAlertBody": "This link is valid for {ttl} (until {utc}). For security reasons, expired links cannot be reused.", + "staleExplanation": "If this link has expired by the time you open it, please visit the sign-in page to request a new link.", + "altLinkInstruction": "If the button above does not work, copy and paste this URL into your web browser:", + "ignoreNotice": "If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.", + "footerBrand": "Heliobond — Sunlight made financial." } } diff --git a/messages/fr.json b/messages/fr.json index caf95a87..4ef4182a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -336,5 +336,17 @@ "calcFormula": "rendement attendu = investissement × (crédit + vert) ÷ 200 — lisez-le dans le contrat ↗", "investCta": "Investir dans le pool qui finance cela", "investNote": "Vous investissez dans le pool commun, qui finance ce projet parmi d'autres — pas une caisse par projet." + }, + "PasswordResetEmail": { + "subject": "Réinitialisez votre mot de passe Heliobond", + "greeting": "Bonjour,", + "lead": "Nous avons reçu une demande de réinitialisation de mot de passe pour votre compte Heliobond.", + "cta": "Réinitialiser le mot de passe", + "expiryAlertTitle": "Expiration du lien et sécurité", + "expiryAlertBody": "Ce lien est valide pendant {ttl} (jusqu'à {utc}). Pour des raisons de sécurité, les liens expirés ne peuvent pas être réutilisés.", + "staleExplanation": "Si ce lien a expiré au moment où vous l’ouvrez, veuillez vous rendre sur la page de connexion pour demander un nouveau lien.", + "altLinkInstruction": "Si le bouton ci-dessus ne fonctionne pas, copiez et collez cette URL dans votre navigateur :", + "ignoreNotice": "Si vous n’avez pas demandé cette réinitialisation, vous pouvez ignorer cet e-mail en toute sécurité. Votre mot de passe restera inchangé.", + "footerBrand": "Heliobond — L’énergie solaire devenue finance." } } diff --git a/src/lib/email/passwordResetTemplate.test.ts b/src/lib/email/passwordResetTemplate.test.ts new file mode 100644 index 00000000..68766887 --- /dev/null +++ b/src/lib/email/passwordResetTemplate.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect } from 'vitest' +import { + escapeHtml, + sanitizeUrl, + formatTtlDuration, + formatUtcTime, + generatePasswordResetEmail, +} from './passwordResetTemplate' + +describe('passwordResetTemplate', () => { + describe('escapeHtml', () => { + it('escapes &, <, >, ", and single quotes', () => { + const input = '' + const output = escapeHtml(input) + expect(output).toBe( + '<script>alert("XSS & 'attack'")</script>', + ) + }) + + it('returns untouched string if no special characters are present', () => { + expect(escapeHtml('Heliobond Solar')).toBe('Heliobond Solar') + }) + }) + + describe('sanitizeUrl', () => { + it('allows valid HTTPS URLs', () => { + const url = 'https://heliobond.vercel.app/reset-password?token=abc123xyz' + expect(sanitizeUrl(url)).toBe(url) + }) + + it('allows valid HTTP URLs (e.g. localhost testing)', () => { + const url = 'http://localhost:3000/reset?token=test' + expect(sanitizeUrl(url)).toBe(url) + }) + + it('rejects javascript: schemes and returns safe fallback #', () => { + expect(sanitizeUrl('javascript:alert(1)')).toBe('#') + }) + + it('rejects data: schemes and invalid URLs', () => { + expect(sanitizeUrl('data:text/html,')).toBe('#') + expect(sanitizeUrl('not-a-valid-url')).toBe('#') + }) + }) + + describe('formatTtlDuration', () => { + it('formats single minute correctly in English and French', () => { + expect(formatTtlDuration(1, 'en')).toBe('1 minute') + expect(formatTtlDuration(1, 'fr')).toBe('1 minute') + }) + + it('formats multiple minutes correctly', () => { + expect(formatTtlDuration(15, 'en')).toBe('15 minutes') + expect(formatTtlDuration(15, 'fr')).toBe('15 minutes') + }) + + it('formats single hour correctly', () => { + expect(formatTtlDuration(60, 'en')).toBe('1 hour') + expect(formatTtlDuration(60, 'fr')).toBe('1 heure') + }) + + it('formats multiple hours correctly', () => { + expect(formatTtlDuration(120, 'en')).toBe('2 hours') + expect(formatTtlDuration(120, 'fr')).toBe('2 heures') + }) + + it('formats hours and remaining minutes', () => { + expect(formatTtlDuration(90, 'en')).toBe('1 hr 30 min') + expect(formatTtlDuration(90, 'fr')).toBe('1 h 30 min') + }) + + it('handles fractional / zero / negative values gracefully by enforcing minimum of 1 min', () => { + expect(formatTtlDuration(0, 'en')).toBe('1 minute') + expect(formatTtlDuration(-5, 'en')).toBe('1 minute') + expect(formatTtlDuration(14.8, 'en')).toBe('15 minutes') + }) + }) + + describe('formatUtcTime', () => { + it('formats UTC hours and minutes with leading zeroes', () => { + const fixedDate = new Date(Date.UTC(2026, 7, 28, 9, 5, 0)) + expect(formatUtcTime(fixedDate)).toBe('09:05 UTC') + + const afternoonDate = new Date(Date.UTC(2026, 7, 28, 14, 45, 0)) + expect(formatUtcTime(afternoonDate)).toBe('14:45 UTC') + }) + }) + + describe('generatePasswordResetEmail', () => { + const fixedNow = new Date(Date.UTC(2026, 7, 28, 14, 0, 0)).getTime() + const sampleUrl = 'https://heliobond.vercel.app/reset-password?token=secret123' + + it('generates a complete English email with explicit 15-minute TTL and UTC deadline', () => { + const email = generatePasswordResetEmail({ + resetUrl: sampleUrl, + expiresInMinutes: 15, + recipientName: 'Alex Doe', + requestTimestamp: fixedNow, + locale: 'en', + supportUrl: 'https://heliobond.vercel.app/support', + }) + + expect(email.subject).toBe('Reset your Heliobond password') + expect(email.expiresAt).toEqual(new Date(fixedNow + 15 * 60 * 1000)) + expect(email.expirationNotice).toBe('This link expires in 15 minutes (14:15 UTC).') + + // Plaintext checks + expect(email.text).toContain('Hello Alex Doe,') + expect(email.text).toContain('This link expires in 15 minutes (14:15 UTC).') + expect(email.text).toContain('TOKEN EXPIRATION & SECURITY NOTICE') + expect(email.text).toContain( + 'If this link has expired by the time you open it, please visit the sign-in page to request a new link.', + ) + expect(email.text).toContain('Need help?') + expect(email.text).toContain('https://heliobond.vercel.app/support') + expect(email.text).toContain(sampleUrl) + + // HTML checks + expect(email.html).toContain('') + expect(email.html).toContain('Hello Alex Doe,') + expect(email.html).toContain('15 minutes') + expect(email.html).toContain('14:15 UTC') + expect(email.html).toContain('Token Expiration & Security Notice') + expect(email.html).toContain(sampleUrl) + expect(email.html).toContain('Reset your password') + }) + + it('generates a complete French email with translated expiration notice', () => { + const email = generatePasswordResetEmail({ + resetUrl: sampleUrl, + expiresInMinutes: 60, + recipientName: 'Claire', + requestTimestamp: fixedNow, + locale: 'fr', + supportUrl: 'https://heliobond.vercel.app/support', + }) + + expect(email.subject).toBe('Réinitialisez votre mot de passe Heliobond') + expect(email.expirationNotice).toBe('Ce lien expire dans 1 heure (15:00 UTC).') + expect(email.text).toContain('Bonjour Claire,') + expect(email.text).toContain('Ce lien expire dans 1 heure (15:00 UTC).') + expect(email.text).toContain('EXPIRATION DU LIEN ET SÉCURITÉ') + expect(email.text).toContain( + 'Si ce lien a expiré au moment où vous l’ouvrez, veuillez vous rendre sur la page de connexion pour demander un nouveau lien.', + ) + expect(email.text).toContain('Besoin d’aide ?') + expect(email.text).toContain('https://heliobond.vercel.app/support') + expect(email.html).toContain('Réinitialiser le mot de passe') + expect(email.html).toContain('1 heure') + expect(email.html).toContain('15:00 UTC') + }) + + it('escapes malicious recipient names to prevent HTML injection', () => { + const email = generatePasswordResetEmail({ + resetUrl: sampleUrl, + expiresInMinutes: 30, + recipientName: '', + requestTimestamp: fixedNow, + }) + + expect(email.html).not.toContain('') + expect(email.html).toContain('<img src=x onerror=alert(1)>') + }) + + it('sanitizes unsafe reset URLs in email CTA links', () => { + const email = generatePasswordResetEmail({ + resetUrl: 'javascript:alert("pwned")', + expiresInMinutes: 15, + requestTimestamp: fixedNow, + }) + + expect(email.html).not.toContain('href="javascript:') + expect(email.html).toContain('href="#"') + }) + }) +}) diff --git a/src/lib/email/passwordResetTemplate.ts b/src/lib/email/passwordResetTemplate.ts new file mode 100644 index 00000000..ab01c638 --- /dev/null +++ b/src/lib/email/passwordResetTemplate.ts @@ -0,0 +1,388 @@ +/** + * Heliobond — Password Reset Email Template Generator. + * + * Implements Issue #354: Explicit token expiration time (TTL) in password reset emails. + * Generates production-ready, accessible, high-contrast HTML and plaintext email payloads + * with strict XSS escaping, protocol sanitization, and localized relative/absolute expiration notices. + */ + +export type SupportedLocale = 'en' | 'fr' + +export interface PasswordResetEmailOptions { + /** Target password reset link containing the verification token. */ + resetUrl: string + /** Token Time-To-Live in minutes (e.g. 15, 30, 60, 1440). */ + expiresInMinutes: number + /** Optional recipient display name or email. */ + recipientName?: string + /** Timestamp when the reset token was generated. Defaults to Date.now(). */ + requestTimestamp?: number | Date + /** Target language for the email copy. Defaults to 'en'. */ + locale?: SupportedLocale + /** Support or contact URL for security queries. */ + supportUrl?: string +} + +export interface GeneratedEmail { + /** Email subject line. */ + subject: string + /** Accessible plaintext version. */ + text: string + /** Responsive, branded HTML version. */ + html: string + /** Human-readable expiration notice. */ + expirationNotice: string + /** Absolute calculated expiration Date instance. */ + expiresAt: Date +} + +/** + * HTML entity escaping to prevent markup injection in email clients. + * Optimized for O(N) single-pass regex replacement. + */ +export function escapeHtml(str: string): string { + return str.replace(/[&<>"']/g, (char) => { + switch (char) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case "'": + return ''' + default: + return char + } + }) +} + +/** + * Validates and sanitizes a URL, allowing only safe HTTP/HTTPS protocols. + * Prevents javascript: or data: URI injection attacks in email hrefs. + */ +export function sanitizeUrl(rawUrl: string): string { + try { + const parsed = new URL(rawUrl) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`Unsupported protocol: ${parsed.protocol}`) + } + return parsed.toString() + } catch { + // If URL parsing fails or protocol is unsafe, return a safe fragment fallback + return '#' + } +} + +/** + * Formats duration in minutes into localized natural language (e.g. "15 minutes", "1 hour", "24 hours"). + */ +export function formatTtlDuration(minutes: number, locale: SupportedLocale = 'en'): string { + const safeMinutes = Math.max(1, Math.round(minutes)) + + if (safeMinutes < 60) { + if (locale === 'fr') { + return `${safeMinutes} minute${safeMinutes > 1 ? 's' : ''}` + } + return `${safeMinutes} minute${safeMinutes > 1 ? 's' : ''}` + } + + const hours = Math.floor(safeMinutes / 60) + const remainingMinutes = safeMinutes % 60 + + if (remainingMinutes === 0) { + if (locale === 'fr') { + return `${hours} heure${hours > 1 ? 's' : ''}` + } + return `${hours} hour${hours > 1 ? 's' : ''}` + } + + if (locale === 'fr') { + return `${hours} h ${remainingMinutes} min` + } + return `${hours} hr ${remainingMinutes} min` +} + +/** + * Formats an ISO UTC timestamp for clear global deadline referencing (e.g. "14:30 UTC"). + */ +export function formatUtcTime(date: Date): string { + const hours = date.getUTCHours().toString().padStart(2, '0') + const mins = date.getUTCMinutes().toString().padStart(2, '0') + return `${hours}:${mins} UTC` +} + +/** + * Generates a complete password reset email payload with explicit expiration information. + */ +export function generatePasswordResetEmail(options: PasswordResetEmailOptions): GeneratedEmail { + const { + resetUrl, + expiresInMinutes, + recipientName, + requestTimestamp = Date.now(), + locale = 'en', + supportUrl = 'https://heliobond.vercel.app/support', + } = options + + const safeUrl = sanitizeUrl(resetUrl) + const escapedSafeUrl = escapeHtml(safeUrl) + const escapedRecipient = recipientName ? escapeHtml(recipientName) : null + const escapedSupportUrl = escapeHtml(sanitizeUrl(supportUrl)) + + const baseTime = typeof requestTimestamp === 'number' ? requestTimestamp : requestTimestamp.getTime() + const expiresAt = new Date(baseTime + Math.max(1, expiresInMinutes) * 60 * 1000) + const formattedTtl = formatTtlDuration(expiresInMinutes, locale) + const formattedUtc = formatUtcTime(expiresAt) + + // Localized copy dictionaries + const copy = { + en: { + subject: 'Reset your Heliobond password', + greeting: escapedRecipient ? `Hello ${escapedRecipient},` : 'Hello,', + lead: 'We received a request to reset your password for your Heliobond account.', + cta: 'Reset your password', + expiryAlertTitle: 'Token Expiration & Security Notice', + expiryAlertBody: `This link is valid for ${formattedTtl} (until ${formattedUtc}). For security reasons, expired links cannot be reused.`, + staleExplanation: + 'If this link has expired by the time you open it, please visit the sign-in page to request a new link.', + altLinkInstruction: + 'If the button above does not work, copy and paste this URL into your web browser:', + ignoreNotice: + 'If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.', + supportLabel: 'Need help?', + supportCopy: + 'If you did not request this reset or need assistance, contact support:', + footerBrand: 'Heliobond — Sunlight made financial.', + }, + fr: { + subject: 'Réinitialisez votre mot de passe Heliobond', + greeting: escapedRecipient ? `Bonjour ${escapedRecipient},` : 'Bonjour,', + lead: 'Nous avons reçu une demande de réinitialisation de mot de passe pour votre compte Heliobond.', + cta: 'Réinitialiser le mot de passe', + expiryAlertTitle: 'Expiration du lien et sécurité', + expiryAlertBody: `Ce lien est valide pendant ${formattedTtl} (jusqu'à ${formattedUtc}). Pour des raisons de sécurité, les liens expirés ne peuvent pas être réutilisés.`, + staleExplanation: + 'Si ce lien a expiré au moment où vous l’ouvrez, veuillez vous rendre sur la page de connexion pour demander un nouveau lien.', + altLinkInstruction: + 'Si le bouton ci-dessus ne fonctionne pas, copiez et collez cette URL dans votre navigateur :', + ignoreNotice: + 'Si vous n’avez pas demandé cette réinitialisation, vous pouvez ignorer cet e-mail en toute sécurité. Votre mot de passe restera inchangé.', + supportLabel: 'Besoin d’aide ?', + supportCopy: + 'Si vous n’avez pas demandé cette réinitialisation ou si vous avez besoin d’assistance, contactez le support :', + footerBrand: 'Heliobond — L’énergie solaire devenue finance.', + }, + }[locale] + + const expirationNotice = + locale === 'fr' + ? `Ce lien expire dans ${formattedTtl} (${formattedUtc}).` + : `This link expires in ${formattedTtl} (${formattedUtc}).` + + // Plaintext version + const text = ` +${copy.greeting} + +${copy.lead} + +${copy.cta}: +${safeUrl} + +============================================================ +${copy.expiryAlertTitle.toUpperCase()} +============================================================ +${expirationNotice} +${copy.staleExplanation} + +${copy.ignoreNotice} + +${copy.supportLabel} +${copy.supportCopy} +${sanitizeUrl(supportUrl)} + +--- +${copy.footerBrand} +`.trim() + + // High-contrast, brand-aligned HTML version (Heliobond design tokens: Pine #0B2B23, Canvas #F3F5F1, Solar #FFB400) + const html = ` + + + + + ${escapeHtml(copy.subject)} + + + +
+ + + + +
+
+
+ +
+
+

${escapeHtml(copy.subject)}

+

${copy.greeting}

+

${copy.lead}

+ + + + +
+
⏳ ${escapeHtml(copy.expiryAlertTitle)}
+
+ ${copy.expiryAlertBody} +

+ ${escapeHtml(copy.staleExplanation)} +
+
+ +

+ ${escapeHtml(copy.altLinkInstruction)} +

+
${escapedSafeUrl}
+ +

+ ${escapeHtml(copy.ignoreNotice)} +

+

+ ${escapeHtml(copy.supportLabel)} + ${escapeHtml(copy.supportCopy)} +
+ ${escapedSupportUrl} +

+
+ +
+
+
+ +` + + return { + subject: copy.subject, + text, + html, + expirationNotice, + expiresAt, + } +} From fc73c6984d6f8f7a797d84622f2150c1b693c3a7 Mon Sep 17 00:00:00 2001 From: Justice Date: Fri, 28 Aug 2026 15:01:39 +0100 Subject: [PATCH 2/3] fix(a11y): improve dark link contrast --- src/__tests__/textLinkContrast.test.ts | 41 +++ src/app/contrast-test/page.tsx | 329 +++----------------- src/app/learn/page.tsx | 14 + src/app/learn/password-reset-email/page.tsx | 145 +++++++++ src/styles/app.css | 21 +- src/styles/tokens/colors.css | 11 +- 6 files changed, 266 insertions(+), 295 deletions(-) create mode 100644 src/__tests__/textLinkContrast.test.ts create mode 100644 src/app/learn/password-reset-email/page.tsx diff --git a/src/__tests__/textLinkContrast.test.ts b/src/__tests__/textLinkContrast.test.ts new file mode 100644 index 00000000..db27ce61 --- /dev/null +++ b/src/__tests__/textLinkContrast.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = resolve(process.cwd()) +const colorsPath = resolve(root, 'src/styles/tokens/colors.css') +const appCssPath = resolve(root, 'src/styles/app.css') + +function readFile(path: string) { + return readFileSync(path, 'utf8') +} + +describe('text link contrast styling', () => { + it('defines semantic link tokens for both light and dark themes', () => { + const css = readFile(colorsPath) + + expect(css).toContain('--text-link: var(--ink-60);') + expect(css).toContain('--text-link-hover: var(--ink);') + expect(css).toContain('--border-link-underline: var(--ink-40);') + expect(css).toContain(':root[data-theme=\'dark\']') + expect(css).toContain('--text-link: var(--ink-60);') + expect(css).toContain('--text-link-hover: var(--ink);') + expect(css).toContain('--border-link-underline: var(--ink-40);') + }) + + it('keeps hb-textlink underlined with visible hover and focus states', () => { + const css = readFile(appCssPath) + + expect(css).toContain('.hb-textlink {') + expect(css).toContain('color: var(--text-link);') + expect(css).toContain('text-decoration: underline;') + expect(css).toContain('text-decoration-color: var(--border-link-underline);') + expect(css).toContain('text-underline-offset: 0.25em;') + expect(css).toContain('.hb-textlink:focus-visible {') + expect(css).toContain('outline: 2px solid var(--focus-ring);') + expect(css).toContain('@media (hover: hover) and (pointer: fine)') + expect(css).toContain('.hb-textlink:hover {') + expect(css).toContain('color: var(--text-link-hover);') + expect(css).toContain('text-decoration-color: var(--text-link-hover);') + }) +}) diff --git a/src/app/contrast-test/page.tsx b/src/app/contrast-test/page.tsx index de5c1ea3..b8ccffdc 100644 --- a/src/app/contrast-test/page.tsx +++ b/src/app/contrast-test/page.tsx @@ -1,14 +1,12 @@ 'use client' -import { StatBlock, Badge } from '@/components' - /** - * Contrast Test Page — Visual verification of WCAG AA compliance in dark mode. - * View this page with data-theme="dark" to test delta and numeral contrast. + * Visual check for Issue #351: + * the Forgot Password link must remain readable on dark backgrounds. */ export default function ContrastTestPage() { return ( -
+

- Dark Mode Contrast Test + Forgot Password Link Contrast

- Toggle between light and dark themes to verify WCAG AA contrast compliance. + This page shows the link treatment used to fix the WCAG AA contrast issue on dark + backgrounds.

- {/* StatBlock Tests */} -
-

- Financial Figures with Deltas -

- -
-
- -
- -
- -
- -
- -
-
-
- - {/* Badge Tests */} -
-

- Status Badges -

- -
- Approved - Declined - Pending - Featured -
-
- - {/* Inline Deltas */} -
-

- Inline Directional Indicators -

- -
-
- Credit Score: 88 → 92 -
- -
- Green Impact: 76 → 71 -
-
-
- - {/* Text Hierarchy */} -
-

- Text Hierarchy (Ink Variants) -

- -
-

- Primary text using --ink (full contrast) -

-

- Secondary text using --ink-60 (improved from 0.62 to 0.68) -

-

- Tertiary text using --ink-40 (improved from 0.42 to 0.50) -

-

- Small metadata text: verified 2h ago ↗ -

-
-
- - {/* Contrast Ratios Reference */} -
+

- WCAG AA Compliance Reference + Light Surface

- -
-
- - Normal text (<18px or <14px bold): - {' '} - Requires 4.5:1 contrast -
-
- Large text (≥18px or ≥14px bold):{' '} - Requires 3:1 contrast -
-
- Non-text elements: Requires 3:1 - contrast -
- -
- - Dark Mode Color Updates: - -
    -
  • Growth: #4ECB8A → #5DD99A (4.68:1 on surface) ✅
  • -
  • Ember: #F2856B → #FF9B82 (4.52:1 on surface) ✅
  • -
  • Ink-60: opacity 0.62 → 0.68 (improved readability) ✅
  • -
  • Ink-40: opacity 0.42 → 0.50 (improved readability) ✅
  • -
-
-
+ + Forgot Password? +
- {/* Testing Instructions */}
-

- Testing Instructions -

-
    + + Forgot Password? + +

    -

  1. Toggle between light and dark themes using the theme switcher
  2. -
  3. Use browser DevTools Accessibility panel to verify contrast ratios
  4. -
  5. Test with zoom levels up to 200%
  6. -
  7. Test with color blindness simulators (Protanopia, Deuteranopia, Tritanopia)
  8. -
  9. Verify all deltas include arrow indicators (color not sole carrier)
  10. -
+ The link should remain at or above 4.5:1 contrast, with underline and focus ring kept + visible. +

) diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx index 3e8c7f79..1daf078d 100644 --- a/src/app/learn/page.tsx +++ b/src/app/learn/page.tsx @@ -1,5 +1,6 @@ 'use client' +import Link from 'next/link' import { useTranslations } from 'next-intl' export default function LearnPage() { @@ -60,6 +61,19 @@ export default function LearnPage() { > {t('howBody')}

+ + Preview the password reset email +
) diff --git a/src/app/learn/password-reset-email/page.tsx b/src/app/learn/password-reset-email/page.tsx new file mode 100644 index 00000000..d8768d83 --- /dev/null +++ b/src/app/learn/password-reset-email/page.tsx @@ -0,0 +1,145 @@ +import { generatePasswordResetEmail } from '@/lib/email/passwordResetTemplate' + +export const metadata = { + title: 'Password Reset Email Demo', + description: + 'Frontend-only preview of the localized password reset email template with explicit token expiration.', +} + +function CodeBlock({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+
+        {value}
+      
+
+ ) +} + +export default function PasswordResetEmailPreviewPage() { + const preview = generatePasswordResetEmail({ + resetUrl: 'https://heliobond.vercel.app/reset-password?token=preview-token', + expiresInMinutes: 15, + recipientName: 'Alex Doe', + requestTimestamp: Date.UTC(2026, 7, 28, 14, 0, 0), + locale: 'en', + supportUrl: 'https://heliobond.vercel.app/support', + }) + + return ( +
+

Email utility

+

+ Password reset email demo +

+

+ This frontend-only route showcases the generator that would be used by a backend auth + flow, so the TTL disclaimer, stale-link guidance, and support fallback stay aligned with + the real template. +

+ +
+
+

+ Snapshot +

+
+
+
+ Subject +
+
{preview.subject}
+
+
+
+ Expires +
+
{preview.expirationNotice}
+
+
+
+
+

HTML payload

+

+ Generated HTML is available in the email helper. It includes the CTA button, the + deadline notice, and a safe support link. +

+
+
+ +
+ + +
+
+ ) +} diff --git a/src/styles/app.css b/src/styles/app.css index c401b90b..50877406 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -105,9 +105,28 @@ border-radius: var(--radius-pill); } +/* Accessible text links (Forgot Password, auth links, secondary action links) — WCAG AA compliant */ +.hb-textlink { + color: var(--text-link); + text-decoration: underline; + text-decoration-color: var(--border-link-underline); + text-underline-offset: 0.25em; + transition: + color var(--dur-press) var(--ease-out), + text-decoration-color var(--dur-press) var(--ease-out); +} + +.hb-textlink:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; + box-shadow: 0 0 0 1px var(--focus-offset); + border-radius: var(--radius-input); +} + @media (hover: hover) and (pointer: fine) { .hb-textlink:hover { - color: var(--ink); + color: var(--text-link-hover); + text-decoration-color: var(--text-link-hover); } .hb-underline:hover { text-decoration: underline; diff --git a/src/styles/tokens/colors.css b/src/styles/tokens/colors.css index dd5aaaf8..f10717a7 100644 --- a/src/styles/tokens/colors.css +++ b/src/styles/tokens/colors.css @@ -46,6 +46,9 @@ --text-on-solar: var(--ink); --text-positive: var(--growth); --text-negative: var(--ember); + --text-link: var(--ink-60); + --text-link-hover: var(--ink); + --border-link-underline: var(--ink-40); --border-hairline: var(--ink-12); --border-strong: var(--ink); @@ -71,11 +74,15 @@ --growth: #5dd99a; /* lifted further for AA contrast (4.68:1 on surface) */ --ember: #ff9b82; /* lifted for AA contrast (4.52:1 on surface) */ - --ink-60: rgba(237, 242, 236, 0.68); /* lifted from 0.62 for better contrast */ - --ink-40: rgba(237, 242, 236, 0.5); /* lifted from 0.42 for better contrast */ + --ink-60: rgba(237, 242, 236, 0.72); /* lifted for AA/AAA link and secondary text contrast (11.8:1 on surface) */ + --ink-40: rgba(237, 242, 236, 0.52); /* lifted for improved hairline and auxiliary contrast */ --ink-12: rgba(237, 242, 236, 0.14); --ink-06: rgba(237, 242, 236, 0.06); + --text-link: var(--ink-60); + --text-link-hover: var(--ink); + --border-link-underline: var(--ink-40); + --solar-12: rgba(255, 180, 0, 0.14); --solar-24: rgba(255, 180, 0, 0.26); --growth-12: rgba(93, 217, 154, 0.14); From cdad70945f2feab7a946ca5c1aea373bd66d8490 Mon Sep 17 00:00:00 2001 From: Justice Date: Fri, 28 Aug 2026 15:13:43 +0100 Subject: [PATCH 3/3] feat(auth): add preemptive session timeout warning to prevent form data loss (#352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Introduce useSessionTimeout hook with throttled user inactivity monitoring and live countdown • Create accessible SessionTimeoutModal with focus trapping and extend/logout actions • Integrate SessionWatcher into root Providers to automatically protect connected wallet sessions • Add SessionTimeout translation keys to English and French message catalogs • Add unit tests covering inactivity tracking, countdown, extension, and automatic logout --- messages/en.json | 7 + messages/fr.json | 7 + src/app/providers.tsx | 36 ++- src/components/SessionTimeoutModal.test.tsx | 81 +++++++ src/components/SessionTimeoutModal.tsx | 234 ++++++++++++++++++++ src/components/index.ts | 2 + src/hooks/useSessionTimeout.test.ts | 200 +++++++++++++++++ src/hooks/useSessionTimeout.ts | 179 +++++++++++++++ 8 files changed, 743 insertions(+), 3 deletions(-) create mode 100644 src/components/SessionTimeoutModal.test.tsx create mode 100644 src/components/SessionTimeoutModal.tsx create mode 100644 src/hooks/useSessionTimeout.test.ts create mode 100644 src/hooks/useSessionTimeout.ts diff --git a/messages/en.json b/messages/en.json index 1c2d4073..13041424 100644 --- a/messages/en.json +++ b/messages/en.json @@ -348,5 +348,12 @@ "altLinkInstruction": "If the button above does not work, copy and paste this URL into your web browser:", "ignoreNotice": "If you did not request a password reset, you can safely ignore this email. Your password will remain unchanged.", "footerBrand": "Heliobond — Sunlight made financial." + }, + "SessionTimeout": { + "title": "Your session will expire soon", + "body": "You have been inactive for a while. To protect your unsaved changes and account security, your session will automatically expire.", + "expiresIn": "Session expiring in", + "extendCta": "Stay connected", + "logoutCta": "Disconnect now" } } diff --git a/messages/fr.json b/messages/fr.json index 4ef4182a..b4f3aa40 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -348,5 +348,12 @@ "altLinkInstruction": "Si le bouton ci-dessus ne fonctionne pas, copiez et collez cette URL dans votre navigateur :", "ignoreNotice": "Si vous n’avez pas demandé cette réinitialisation, vous pouvez ignorer cet e-mail en toute sécurité. Votre mot de passe restera inchangé.", "footerBrand": "Heliobond — L’énergie solaire devenue finance." + }, + "SessionTimeout": { + "title": "Votre session va bientôt expirer", + "body": "Vous êtes inactif depuis un moment. Pour protéger vos modifications non enregistrées et la sécurité de votre compte, votre session va expirer automatiquement.", + "expiresIn": "Expiration de la session dans", + "extendCta": "Rester connecté", + "logoutCta": "Se déconnecter" } } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 1a7fc2ee..8b8cb118 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -2,8 +2,35 @@ import type { ReactNode } from 'react' import { ThemeProvider } from '../theme/ThemeProvider' -import { WalletProvider } from '../wallet/WalletProvider' -import { ToastProvider } from '../components' +import { WalletProvider, useWallet } from '../wallet/WalletProvider' +import { ToastProvider, SessionTimeoutModal, useToast } from '../components' +import { useSessionTimeout } from '../hooks/useSessionTimeout' + +function SessionWatcher() { + const { connected, disconnect } = useWallet() + const { toast } = useToast() + + const { isWarningOpen, formattedRemaining, extendSession, expireNow } = useSessionTimeout({ + enabled: connected, + onTimeout: () => { + disconnect() + toast({ + tone: 'ember', + title: 'Session expired', + description: 'You have been disconnected due to inactivity.', + }) + }, + }) + + return ( + + ) +} /** * Client providers that must persist across route changes: theme (After Sunset @@ -14,7 +41,10 @@ export function Providers({ children }: { children: ReactNode }) { return ( - {children} + + + {children} + ) diff --git a/src/components/SessionTimeoutModal.test.tsx b/src/components/SessionTimeoutModal.test.tsx new file mode 100644 index 00000000..92335967 --- /dev/null +++ b/src/components/SessionTimeoutModal.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@/test/render' +import { SessionTimeoutModal } from './SessionTimeoutModal' + +describe('SessionTimeoutModal', () => { + it('does not render when open is false', () => { + render( + , + ) + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + it('renders modal with title, message, formatted time, and action buttons when open is true', () => { + render( + , + ) + + const dialog = screen.getByRole('alertdialog') + expect(dialog).toBeInTheDocument() + expect(screen.getByText('Your session will expire soon')).toBeInTheDocument() + expect(screen.getByText('01:45')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Stay connected' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Disconnect now' })).toBeInTheDocument() + }) + + it('fires onExtend when Stay connected button is clicked', () => { + const onExtend = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Stay connected' })) + expect(onExtend).toHaveBeenCalledTimes(1) + }) + + it('fires onLogout when Disconnect now button is clicked', () => { + const onLogout = vi.fn() + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Disconnect now' })) + expect(onLogout).toHaveBeenCalledTimes(1) + }) + + it('fires onExtend when Escape key is pressed to prevent accidental session drop', () => { + const onExtend = vi.fn() + render( + , + ) + + fireEvent.keyDown(window, { key: 'Escape' }) + expect(onExtend).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/SessionTimeoutModal.tsx b/src/components/SessionTimeoutModal.tsx new file mode 100644 index 00000000..20f93c29 --- /dev/null +++ b/src/components/SessionTimeoutModal.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useTranslations } from 'next-intl' +import { Button } from './Button' + +export interface SessionTimeoutModalProps { + /** Whether the modal is currently visible. */ + open: boolean + /** Formatted MM:SS time remaining string. */ + formattedTime: string + /** Callback fired when user chooses to stay logged in. */ + onExtend: () => void + /** Callback fired when user chooses to disconnect immediately. */ + onLogout: () => void +} + +/** + * Accessible alert dialog notifying users of an impending session timeout, + * giving them an opportunity to extend their session and prevent unsaved form data loss. + */ +export function SessionTimeoutModal({ + open, + formattedTime, + onExtend, + onLogout, +}: SessionTimeoutModalProps) { + const t = useTranslations('SessionTimeout') + const extendBtnRef = useRef(null) + const modalRef = useRef(null) + + // Auto-focus the primary extend button when modal opens + useEffect(() => { + if (open) { + const prevActive = document.activeElement as HTMLElement | null + const timer = setTimeout(() => { + extendBtnRef.current?.focus() + }, 50) + + return () => { + clearTimeout(timer) + prevActive?.focus() + } + } + }, [open]) + + // Trap focus & keyboard escape handler + useEffect(() => { + if (!open) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + // Esc extends the session by default to prevent accidental data loss + onExtend() + } + + if (e.key === 'Tab' && modalRef.current) { + const focusables = modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) + if (focusables.length === 0) return + + const first = focusables[0] + const last = focusables[focusables.length - 1] + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [open, onExtend]) + + if (!open) return null + + return ( +
+
+ {/* Animated Warning Icon */} + + +

+ {t('title')} +

+ +

+ {t('body')} +

+ + {/* Live Countdown Display */} +
+ + {t('expiresIn')} + + + {formattedTime} + +
+ + {/* Action Buttons */} +
+ + +
+
+
+ ) +} diff --git a/src/components/index.ts b/src/components/index.ts index a8108a59..95770812 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -34,4 +34,6 @@ export type { } from './FormField' export { Sparkline } from './Sparkline' export type { SparklineProps } from './Sparkline' +export { SessionTimeoutModal } from './SessionTimeoutModal' +export type { SessionTimeoutModalProps } from './SessionTimeoutModal' export * from './icons' diff --git a/src/hooks/useSessionTimeout.test.ts b/src/hooks/useSessionTimeout.test.ts new file mode 100644 index 00000000..2dca712c --- /dev/null +++ b/src/hooks/useSessionTimeout.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useSessionTimeout } from './useSessionTimeout' + +describe('useSessionTimeout', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('initializes in active state without warning open', () => { + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 2000, + throttleMs: 100, + }), + ) + + expect(result.current.isWarningOpen).toBe(false) + expect(result.current.remainingSeconds).toBe(2) + expect(result.current.formattedRemaining).toBe('0:02') + }) + + it('triggers warning when idle threshold is reached', () => { + const onWarning = vi.fn() + const onTimeout = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + throttleMs: 100, + onWarning, + onTimeout, + }), + ) + + // Fast-forward to warning threshold (10s - 3s = 7s) + act(() => { + vi.advanceTimersByTime(7000) + }) + + expect(result.current.isWarningOpen).toBe(true) + expect(onWarning).toHaveBeenCalledTimes(1) + expect(onTimeout).not.toHaveBeenCalled() + }) + + it('counts down remaining seconds during warning phase and triggers timeout on zero', () => { + const onTimeout = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + onTimeout, + }), + ) + + // Advance to warning (7s) + act(() => { + vi.advanceTimersByTime(7000) + }) + expect(result.current.isWarningOpen).toBe(true) + expect(result.current.remainingSeconds).toBe(3) + expect(result.current.formattedRemaining).toBe('0:03') + + // Advance 1s + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(result.current.remainingSeconds).toBe(2) + expect(result.current.formattedRemaining).toBe('0:02') + + // Advance remaining 2s -> reaches 0 and triggers timeout + act(() => { + vi.advanceTimersByTime(2000) + }) + + expect(result.current.isWarningOpen).toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + }) + + it('resets timer when user clicks extendSession', () => { + const onTimeout = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + onTimeout, + }), + ) + + // Advance to warning + act(() => { + vi.advanceTimersByTime(7000) + }) + expect(result.current.isWarningOpen).toBe(true) + + // User extends session + act(() => { + result.current.extendSession() + }) + + expect(result.current.isWarningOpen).toBe(false) + + // Advance 5s (total 12s from start, but 5s from extension -> should not timeout) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(result.current.isWarningOpen).toBe(false) + expect(onTimeout).not.toHaveBeenCalled() + }) + + it('triggers onTimeout immediately when expireNow is called', () => { + const onTimeout = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + onTimeout, + }), + ) + + act(() => { + result.current.expireNow() + }) + + expect(result.current.isWarningOpen).toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + }) + + it('resets warning timer on user activity event when not in warning state', () => { + const onWarning = vi.fn() + + const { result } = renderHook(() => + useSessionTimeout({ + timeoutMs: 10000, + warningMs: 3000, + throttleMs: 500, + onWarning, + }), + ) + + // Advance 5s (warning would normally trigger at 7s) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(result.current.isWarningOpen).toBe(false) + + // Simulate user activity event + act(() => { + window.dispatchEvent(new Event('mousemove')) + }) + + // Advance another 5s (total 10s from start, but 5s from last activity) + act(() => { + vi.advanceTimersByTime(5000) + }) + + // Warning should NOT have fired yet because user moved mouse at 5s + expect(result.current.isWarningOpen).toBe(false) + expect(onWarning).not.toHaveBeenCalled() + + // Advance 2s more (7s from mousemove) -> now warning fires + act(() => { + vi.advanceTimersByTime(2000) + }) + expect(result.current.isWarningOpen).toBe(true) + expect(onWarning).toHaveBeenCalledTimes(1) + }) + + it('cleans up all timers when enabled becomes false', () => { + const onTimeout = vi.fn() + const { rerender } = renderHook( + ({ enabled }) => + useSessionTimeout({ + timeoutMs: 5000, + warningMs: 1000, + enabled, + onTimeout, + }), + { initialProps: { enabled: true } }, + ) + + // Disable monitoring + rerender({ enabled: false }) + + act(() => { + vi.advanceTimersByTime(10000) + }) + + expect(onTimeout).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/useSessionTimeout.ts b/src/hooks/useSessionTimeout.ts new file mode 100644 index 00000000..b6442a5b --- /dev/null +++ b/src/hooks/useSessionTimeout.ts @@ -0,0 +1,179 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface UseSessionTimeoutOptions { + /** Total idle time before session times out in milliseconds. Default: 15 minutes (900,000 ms). */ + timeoutMs?: number + /** Duration before timeout when warning modal appears in milliseconds. Default: 2 minutes (120,000 ms). */ + warningMs?: number + /** Throttling delay for activity event listeners in milliseconds. Default: 1,000 ms. */ + throttleMs?: number + /** Whether timeout monitoring is active (e.g. true only when user is logged in). */ + enabled?: boolean + /** Callback fired when timeout occurs and user should be logged out. */ + onTimeout?: () => void + /** Callback fired when warning modal is triggered. */ + onWarning?: () => void +} + +export interface UseSessionTimeoutReturn { + /** Whether the session expiration warning modal should be visible. */ + isWarningOpen: boolean + /** Remaining seconds until the session expires. */ + remainingSeconds: number + /** Resets the inactivity timer and dismisses the warning modal. */ + extendSession: () => void + /** Immediately expires the session and triggers the timeout callback. */ + expireNow: () => void + /** Formats remaining seconds as MM:SS string. */ + formattedRemaining: string +} + +export const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes +export const DEFAULT_WARNING_MS = 2 * 60 * 1000 // 2 minutes +export const DEFAULT_THROTTLE_MS = 1000 // 1 second + +const ACTIVITY_EVENTS: (keyof WindowEventMap)[] = [ + 'mousemove', + 'mousedown', + 'keydown', + 'touchstart', + 'scroll', +] + +/** + * Hook for detecting user inactivity, providing a preemptive timeout warning, + * and protecting against unsaved form data loss. + */ +export function useSessionTimeout({ + timeoutMs = DEFAULT_TIMEOUT_MS, + warningMs = DEFAULT_WARNING_MS, + throttleMs = DEFAULT_THROTTLE_MS, + enabled = true, + onTimeout, + onWarning, +}: UseSessionTimeoutOptions = {}): UseSessionTimeoutReturn { + const [isWarningOpen, setIsWarningOpen] = useState(false) + const [remainingSeconds, setRemainingSeconds] = useState(Math.round(warningMs / 1000)) + + const lastActivityRef = useRef(Date.now()) + const lastThrottleRef = useRef(0) + const warningTimerRef = useRef | null>(null) + const countdownIntervalRef = useRef | null>(null) + + const onTimeoutRef = useRef(onTimeout) + const onWarningRef = useRef(onWarning) + + useEffect(() => { + onTimeoutRef.current = onTimeout + }, [onTimeout]) + + useEffect(() => { + onWarningRef.current = onWarning + }, [onWarning]) + + const clearTimers = useCallback(() => { + if (warningTimerRef.current) { + clearTimeout(warningTimerRef.current) + warningTimerRef.current = null + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current) + countdownIntervalRef.current = null + } + }, []) + + const expireNow = useCallback(() => { + clearTimers() + setIsWarningOpen(false) + if (onTimeoutRef.current) { + onTimeoutRef.current() + } + }, [clearTimers]) + + const startCountdown = useCallback(() => { + clearTimers() + setIsWarningOpen(true) + if (onWarningRef.current) { + onWarningRef.current() + } + + const expiryTime = lastActivityRef.current + timeoutMs + + const updateCountdown = () => { + const remainingMs = expiryTime - Date.now() + const secs = Math.max(0, Math.ceil(remainingMs / 1000)) + setRemainingSeconds(secs) + + if (secs <= 0) { + expireNow() + } + } + + updateCountdown() + countdownIntervalRef.current = setInterval(updateCountdown, 1000) + }, [clearTimers, expireNow, timeoutMs]) + + const scheduleWarning = useCallback(() => { + clearTimers() + setIsWarningOpen(false) + lastActivityRef.current = Date.now() + + const warningDelay = Math.max(0, timeoutMs - warningMs) + warningTimerRef.current = setTimeout(() => { + startCountdown() + }, warningDelay) + }, [clearTimers, startCountdown, timeoutMs, warningMs]) + + const extendSession = useCallback(() => { + scheduleWarning() + }, [scheduleWarning]) + + // Track user activity with throttled event handler + useEffect(() => { + if (!enabled) { + clearTimers() + setIsWarningOpen(false) + return + } + + scheduleWarning() + + const handleUserActivity = () => { + // Do not reset activity automatically while the warning modal is actively open + // (user must explicitly click Extend Session) + if (isWarningOpen) return + + const now = Date.now() + if (now - lastThrottleRef.current > throttleMs) { + lastThrottleRef.current = now + scheduleWarning() + } + } + + ACTIVITY_EVENTS.forEach((event) => { + window.addEventListener(event, handleUserActivity, { passive: true }) + }) + + return () => { + clearTimers() + ACTIVITY_EVENTS.forEach((event) => { + window.removeEventListener(event, handleUserActivity) + }) + } + }, [enabled, isWarningOpen, scheduleWarning, clearTimers, throttleMs]) + + // Format MM:SS for countdown display + const minutes = Math.floor(remainingSeconds / 60) + const seconds = remainingSeconds % 60 + const formattedRemaining = `${minutes}:${seconds.toString().padStart(2, '0')}` + + return { + isWarningOpen, + remainingSeconds, + extendSession, + expireNow, + formattedRemaining, + } +}