diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx index 7ae60ead..777c28cc 100644 --- a/src/components/KeyBackupReminder.tsx +++ b/src/components/KeyBackupReminder.tsx @@ -10,10 +10,6 @@ export const KeyBackupReminder = () => { "We recommend backing up your key data regularly. Would you like to back up now?", onAccept: () => setDialogOpen("pkiBackup", true), enabled: true, - cookieOptions: { - secure: true, - sameSite: "strict", - }, }); // deno-lint-ignore jsx-no-useless-fragment return <>; diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index b42044bb..b2e76e43 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -5,8 +5,8 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "./UI/Toast.tsx"; -import { useToast } from "../core/hooks/useToast.ts"; +} from "@components/UI/Toast.tsx"; +import { useToast } from "@core/hooks/useToast.ts"; export function Toaster() { const { toasts } = useToast(); diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index 1f6b17bd..f1009095 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -1,15 +1,13 @@ -import { Button } from "../../components/UI/Button.tsx"; -import type { CookieAttributes } from "js-cookie"; +import { Button } from "@components/UI/Button.tsx"; import { useCallback, useEffect, useRef } from "react"; -import useCookie from "./useCookie.ts"; -import { useToast } from "./useToast.ts"; +import { useToast } from "@core/hooks/useToast.ts"; +import useLocalStorage from "@core/hooks/useLocalStorage.ts"; interface UseBackupReminderOptions { reminderInDays?: number; message: string; onAccept?: () => void | Promise; enabled: boolean; - cookieOptions?: CookieAttributes; } interface ReminderState { @@ -17,17 +15,15 @@ interface ReminderState { lastShown: string; } -const TOAST_APPEAR_DELAY = 10_000; // 10 seconds; -const TOAST_DURATION = 30_000; // 30 seconds;: - -// remind user in 1 year to backup keys again, if they accept the reminder; +const TOAST_APPEAR_DELAY = 10_000; // 10 seconds +const TOAST_DURATION = 30_000; // 30 seconds const ON_ACCEPT_REMINDER_DAYS = 365; +const STORAGE_KEY = "key_backup_reminder"; function isReminderExpired(lastShown: string): boolean { const lastShownDate = new Date(lastShown); const now = new Date(); - const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) / - (1000 * 60 * 60 * 24); + const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) / (1000 * 60 * 60 * 24); return daysSinceLastShown >= 7; } @@ -35,36 +31,32 @@ export function useBackupReminder({ reminderInDays = 7, enabled, message, - onAccept = () => {}, - cookieOptions, + onAccept = () => { }, }: UseBackupReminderOptions) { const { toast } = useToast(); const toastShownRef = useRef(false); - const { value: reminderCookie, setCookie } = useCookie( - "key_backup_reminder", + const [reminderState, setReminderState] = useLocalStorage( + STORAGE_KEY, + null ); - const suppressReminder = useCallback( - (days: number) => { - const expiryDate = new Date(); - expiryDate.setDate(expiryDate.getDate() + days); + // Suppress reminder for 10 years if not specified + const suppressReminder = useCallback((days: number = 3563) => { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + days); - setCookie( - { - suppressed: true, - lastShown: new Date().toISOString(), - }, - { ...cookieOptions, expires: expiryDate }, - ); - }, - [setCookie, cookieOptions], - ); + setReminderState({ + suppressed: true, + lastShown: new Date().toISOString(), + }); + }, [setReminderState]); useEffect(() => { if (!enabled || toastShownRef.current) return; - const shouldShowReminder = !reminderCookie?.suppressed || - isReminderExpired(reminderCookie.lastShown); + const shouldShowReminder = + !reminderState?.suppressed || isReminderExpired(reminderState.lastShown); + if (!shouldShowReminder) return; toastShownRef.current = true; @@ -75,28 +67,46 @@ export function useBackupReminder({ delay: TOAST_APPEAR_DELAY, description: message, action: ( -
- - +
+
+ + + +
+
+ +
), }); @@ -113,6 +123,6 @@ export function useBackupReminder({ reminderInDays, suppressReminder, toast, - reminderCookie, + reminderState, ]); } diff --git a/src/core/hooks/useLocalStorage.test.ts b/src/core/hooks/useLocalStorage.test.ts new file mode 100644 index 00000000..8d621a57 --- /dev/null +++ b/src/core/hooks/useLocalStorage.test.ts @@ -0,0 +1,52 @@ +import { renderHook, act } from '@testing-library/react' +import useLocalStorage from './useLocalStorage' +import { beforeEach, describe, expect, it } from "vitest"; + +describe('useLocalStorage', () => { + const key = 'test-key' + + beforeEach(() => { + localStorage.clear() + }) + + it('should initialize with initial value if localStorage is empty', () => { + const { result } = renderHook(() => useLocalStorage(key, 'initial')) + const [value] = result.current + expect(value).toBe('initial') + }) + + it('should read existing value from localStorage', () => { + localStorage.setItem(key, JSON.stringify('stored')) + const { result } = renderHook(() => useLocalStorage(key, 'initial')) + const [value] = result.current + expect(value).toBe('stored') + }) + + it('should update localStorage when setValue is called', () => { + const { result } = renderHook(() => useLocalStorage(key, 'initial')) + const [, setValue] = result.current + + act(() => { + setValue('updated') + }) + + expect(localStorage.getItem(key)).toBe(JSON.stringify('updated')) + expect(result.current[0]).toBe('updated') + }) + + it('should remove value from localStorage when removeValue is called', () => { + const { result } = renderHook(() => useLocalStorage(key, 'initial')) + const [, setValue, removeValue] = result.current + + act(() => { + setValue('to-be-removed') + }) + + act(() => { + removeValue() + }) + + expect(localStorage.getItem(key)).toBeNull() + expect(result.current[0]).toBe('initial') + }) +}) diff --git a/src/core/hooks/useToast.test.tsx b/src/core/hooks/useToast.test.tsx new file mode 100644 index 00000000..9125da75 --- /dev/null +++ b/src/core/hooks/useToast.test.tsx @@ -0,0 +1,81 @@ +import { renderHook, act } from '@testing-library/react' +import { useToast } from "@core/hooks/useToast.ts" +import { Button } from '@components/UI/Button.tsx' +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe('useToast', () => { + beforeEach(() => { + // Reset toast memory state before each test + // our hook uses global memory to store toasts + // @ts-expect-error - internal test reset + globalThis.memoryState = { toasts: [] } + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should create a toast with title, description, and action', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.toast({ + title: 'Backup Reminder', + description: 'Don\'t forget to backup!', + action: + }) + vi.runAllTimers() + }) + + const toast = result.current.toasts[0] + expect(result.current.toasts.length).toBe(1) + expect(toast.title).toBe('Backup Reminder') + expect(toast.description).toBe('Don\'t forget to backup!') + expect(toast.action).toBeTruthy() + expect(toast.open).toBe(true) + }) + it('should dismiss a toast using returned dismiss function', () => { + const { result } = renderHook(() => useToast()) + vi.useFakeTimers() + + let toastRef: { id: string, dismiss: () => void } + + act(() => { + toastRef = result.current.toast({ title: 'Dismiss Me' }) + vi.runAllTimers() // Flush ADD_TOAST + }) + + act(() => { + toastRef.dismiss() + }) + + const toast = result.current.toasts.find(t => t.id === toastRef.id) + expect(toast?.open).toBe(false) + + vi.useRealTimers() + }) + + + it('should allow dismiss via hook dismiss function', () => { + const { result } = renderHook(() => useToast()) + vi.useFakeTimers() + + let toastRef: { id: string } + + act(() => { + toastRef = result.current.toast({ title: 'Manual Dismiss' }) + vi.runAllTimers() + }) + + act(() => { + result.current.dismiss(toastRef.id) + }) + + const toast = result.current.toasts.find(t => t.id === toastRef.id) + expect(toast?.open).toBe(false) + + vi.useRealTimers() + }) + +}) diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts index 3269eee9..d728537f 100644 --- a/src/core/hooks/useToast.ts +++ b/src/core/hooks/useToast.ts @@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) { ...props, id, open: true, - onOpenChange: (open) => { + onOpenChange: (open: boolean) => { if (!open) dismiss(); }, },