diff --git a/packages/hightide-web/CHANGELOG.md b/packages/hightide-web/CHANGELOG.md index 0a3a15fa..8d167f1a 100644 --- a/packages/hightide-web/CHANGELOG.md +++ b/packages/hightide-web/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.19.0] - unpublished + +### Added + +- `CookieUtils` with `get`, `set`, `remove` and `resolveRegistrableDomain` to find the registrable domain (eTLD+1) of a host +- `useCookieKeyValueStore` storing values in prefixed cookies on a shared domain, with an optional `fallbackStore` for reading and syncing legacy values; the domain is resolved after mounting to stay hydration safe +- `preferenceStorage` config (`shareAcrossSubdomains`, `cookieDomain`, `cookiePrefix`) on `HightideConfig` +- `usePreferenceStore` returning the store used for persisting user preferences +- `ThemeProvider` `store` prop to override the store used for the theme + +### Changed + +- Theme and localization preferences are stored in cookies on the registrable domain by default (e.g. `helpwave.de`), so all subdomains share them; localStorage is kept as fallback and migrated automatically. Disable with `preferenceStorage.shareAcrossSubdomains: false` + ## [0.18.0] - 2026-10-13 ### Added diff --git a/packages/hightide-web/package.json b/packages/hightide-web/package.json index 03e43a8d..3c9f840c 100644 --- a/packages/hightide-web/package.json +++ b/packages/hightide-web/package.json @@ -10,7 +10,7 @@ "access": "public" }, "license": "AGPL-3.0-only", - "version": "0.18.0", + "version": "0.19.0", "browserslist": [ "> 0.2%", "last 3 versions", diff --git a/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx b/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx index 58a9e7eb..eeaf293b 100644 --- a/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx +++ b/packages/hightide-web/src/global-contexts/hightide-config/HightideConfigContext.tsx @@ -21,10 +21,17 @@ export type LocalizationConfig = { supportedLocales: SupportedLocalesConfig, } +export type PreferenceStorageConfig = { + shareAcrossSubdomains: boolean, + cookieDomain?: string, + cookiePrefix: string, +} + export type HightideConfig = { tooltip: TooltipConfig, theme: ThemeConfig, localization: LocalizationConfig, + preferenceStorage: PreferenceStorageConfig, } const defaultConfig: HightideConfig = { @@ -41,6 +48,10 @@ const defaultConfig: HightideConfig = { fallbackLocale: 'en-US', supportedLocales: HightideConfigUtils.defaultSupportedLocales, }, + preferenceStorage: { + shareAcrossSubdomains: true, + cookiePrefix: 'hightide-', + }, } const mergeSupportedThemesConfig = (config: SupportedThemesConfig, overwrite: DeepPartial | undefined): SupportedThemesConfig => { @@ -103,6 +114,10 @@ function mergeConfig(config: HightideConfig, overwrite: DeepPartial { + const { config } = useHightideConfig() + const { shareAcrossSubdomains, cookieDomain, cookiePrefix } = config.preferenceStorage + const browserStore = useBrowserKeyValueStore() + const cookieStore = useCookieKeyValueStore({ + domain: cookieDomain ?? 'auto', + prefix: cookiePrefix, + fallbackStore: browserStore, + }) + return shareAcrossSubdomains ? cookieStore : browserStore +} diff --git a/packages/hightide-web/src/global-contexts/localization/LocalizationProvider.tsx b/packages/hightide-web/src/global-contexts/localization/LocalizationProvider.tsx index a0625132..2bb14b0a 100644 --- a/packages/hightide-web/src/global-contexts/localization/LocalizationProvider.tsx +++ b/packages/hightide-web/src/global-contexts/localization/LocalizationProvider.tsx @@ -4,10 +4,10 @@ import { type SupportedLocalesConfig, type LocalizationProviderProps as LocalizationProviderPropsBase } from '@helpwave/hightide-utils/context/localization' -import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks' import { LocalizationUtils } from '@helpwave/hightide-utils/utils' import { useEffect, useState } from 'react' import { useHightideConfig } from '../hightide-config/HightideConfigContext' +import { usePreferenceStore } from '../hightide-config/usePreferenceStore' import { SafeGlobals } from '../../utils/safeGlobals' export type LocalizationProviderProps = PropsWithChildren @@ -51,8 +51,8 @@ export const LocalizationProvider = ({ store: storeOverride, ...rest }: LocalizationProviderProps) => { - const browserStore = useBrowserKeyValueStore() - const store = storeOverride ?? browserStore + const preferenceStore = usePreferenceStore() + const store = storeOverride ?? preferenceStore const { config } = useHightideConfig() const resolvedSupportedLocales = supportedLocales ?? config.localization.supportedLocales const detectedSystemLocale = useWebSystemLocale(resolvedSupportedLocales) diff --git a/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx b/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx index 33242a2c..c6095081 100644 --- a/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx +++ b/packages/hightide-web/src/global-contexts/theme/ThemeProvider.tsx @@ -1,8 +1,9 @@ import type { PropsWithChildren } from 'react' import { useEffect, useMemo } from 'react' -import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks' +import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks' import type { ThemeConfig as HightideThemeConfig } from '../hightide-config/HightideConfigContext' import { useHightideConfig } from '../hightide-config/HightideConfigContext' +import { usePreferenceStore } from '../hightide-config/usePreferenceStore' import { useWebSystemTheme } from '../../hooks/useWebSystemTheme' import type { ThemeMode, @@ -21,6 +22,7 @@ export type ThemeProviderProps = PropsWithChildren & Partial void, + store?: SimpleValueStore, } export const ThemeProvider = ({ @@ -29,9 +31,11 @@ export const ThemeProvider = ({ initialTheme, supportedThemes, onChangedTheme, + store: storeOverride, }: ThemeProviderProps) => { const { config } = useHightideConfig() - const store = useBrowserKeyValueStore() + const preferenceStore = usePreferenceStore() + const store = storeOverride ?? preferenceStore const systemTheme = useWebSystemTheme() const resolvedSupportedThemes = supportedThemes ?? config.theme.supportedThemes const mappedSupportedThemes = useMemo(() => { diff --git a/packages/hightide-web/src/hooks/index.ts b/packages/hightide-web/src/hooks/index.ts index ad6dc3c7..d4c1208a 100644 --- a/packages/hightide-web/src/hooks/index.ts +++ b/packages/hightide-web/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from './focus' export * from './forward-exports' export * from './useAnchoredPosition' +export * from './useCookieKeyValueStore' export * from './useHandleRefs' export * from './useIsMounted' export * from './useOutsideClick' diff --git a/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts b/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts new file mode 100644 index 00000000..21a1358a --- /dev/null +++ b/packages/hightide-web/src/hooks/useCookieKeyValueStore.ts @@ -0,0 +1,62 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import type { SimpleValueStore } from '@helpwave/hightide-utils/hooks' +import { CookieUtils } from '../utils/cookies' + +export type UseCookieKeyValueStoreProps = { + domain?: 'auto' | string | null, + prefix?: string, + maxAgeSeconds?: number, + fallbackStore?: SimpleValueStore, +} + +const ONE_YEAR_IN_SECONDS = 60 * 60 * 24 * 365 + +export const useCookieKeyValueStore = ({ + domain = 'auto', + prefix = 'hightide-', + maxAgeSeconds = ONE_YEAR_IN_SECONDS, + fallbackStore, +}: UseCookieKeyValueStoreProps = {}): SimpleValueStore => { + const [resolved, setResolved] = useState<{ domain: string | undefined } | null>(null) + + useEffect(() => { + setResolved({ + domain: domain === 'auto' ? CookieUtils.resolveRegistrableDomain() : (domain ?? undefined), + }) + }, [domain]) + + return useMemo(() => { + const resolvedDomain = resolved?.domain + const cookieName = (key: string) => `${prefix}${key}` + const deleteValue = (key: string) => { + CookieUtils.remove(cookieName(key), { domain: resolvedDomain }) + if (resolvedDomain) CookieUtils.remove(cookieName(key)) + fallbackStore?.deleteValue(key) + } + + return { + isInitialized: resolved !== null && (fallbackStore?.isInitialized ?? true), + getValue: (key: string) => { + if (resolved === null) return null + const value = CookieUtils.get(cookieName(key)) + if (value !== null) return value + return fallbackStore?.getValue(key) ?? null + }, + setValue: (key: string, value: string | null) => { + if (resolved === null) return + if (value === null) { + deleteValue(key) + return + } + CookieUtils.set(cookieName(key), value, { domain: resolvedDomain, maxAgeSeconds }) + fallbackStore?.setValue(key, value) + }, + deleteValue: (key: string) => { + if (resolved === null) return + deleteValue(key) + }, + } + }, [fallbackStore, maxAgeSeconds, prefix, resolved]) +} diff --git a/packages/hightide-web/src/utils/cookies.ts b/packages/hightide-web/src/utils/cookies.ts new file mode 100644 index 00000000..fbef5109 --- /dev/null +++ b/packages/hightide-web/src/utils/cookies.ts @@ -0,0 +1,82 @@ +import { SafeGlobals } from './safeGlobals' + +export type CookieWriteOptions = { + domain?: string, + path?: string, + maxAgeSeconds?: number, + sameSite?: 'Lax' | 'Strict' | 'None', + secure?: boolean, +} + +const get = (name: string): string | null => { + const doc = SafeGlobals.document('CookieUtils.get') + if (!doc) return null + const encodedName = encodeURIComponent(name) + for (const part of doc.cookie.split(';')) { + const separatorIndex = part.indexOf('=') + if (separatorIndex === -1) continue + if (part.slice(0, separatorIndex).trim() !== encodedName) continue + try { + return decodeURIComponent(part.slice(separatorIndex + 1).trim()) + } catch { + return null + } + } + return null +} + +const set = (name: string, value: string, { + domain, + path = '/', + maxAgeSeconds, + sameSite = 'Lax', + secure, +}: CookieWriteOptions = {}) => { + const doc = SafeGlobals.document('CookieUtils.set') + if (!doc) return + const isSecure = secure ?? doc.location?.protocol === 'https:' + const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`, `Path=${path}`, `SameSite=${sameSite}`] + if (domain) parts.push(`Domain=${domain}`) + if (maxAgeSeconds !== undefined) parts.push(`Max-Age=${Math.floor(maxAgeSeconds)}`) + if (isSecure || sameSite === 'None') parts.push('Secure') + doc.cookie = parts.join('; ') +} + +const remove = (name: string, options: Omit = {}) => { + set(name, '', { ...options, maxAgeSeconds: 0 }) +} + +const isIpAddress = (hostname: string) => /^[\d.]+$/.test(hostname) || hostname.includes(':') + +const registrableDomainCache = new Map() + +const resolveRegistrableDomain = (hostname?: string): string | undefined => { + hostname ??= SafeGlobals.window('CookieUtils.resolveRegistrableDomain')?.location.hostname + if (!hostname) return undefined + hostname = hostname.toLowerCase().replace(/\.$/, '') + if (registrableDomainCache.has(hostname)) return registrableDomainCache.get(hostname) + + let result: string | undefined = undefined + const labels = hostname.split('.') + if (!isIpAddress(hostname) && labels.length >= 2) { + const probeName = `__hightide_domain_probe_${Math.random().toString(36).slice(2)}` + for (let count = 2; count <= labels.length; count++) { + const candidate = labels.slice(-count).join('.') + set(probeName, '1', { domain: candidate, maxAgeSeconds: 10 }) + if (get(probeName) === '1') { + remove(probeName, { domain: candidate }) + result = candidate + break + } + } + } + registrableDomainCache.set(hostname, result) + return result +} + +export const CookieUtils = { + get, + set, + remove, + resolveRegistrableDomain, +} diff --git a/packages/hightide-web/src/utils/index.ts b/packages/hightide-web/src/utils/index.ts index 6637c26a..f34a7731 100644 --- a/packages/hightide-web/src/utils/index.ts +++ b/packages/hightide-web/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './StorageListener' +export * from './cookies' export * from './dom' export * from './forward-exports' export * from './propsUtil' diff --git a/packages/hightide-web/tests/storage/useCookieKeyValueStore.test.ts b/packages/hightide-web/tests/storage/useCookieKeyValueStore.test.ts new file mode 100644 index 00000000..717a15bb --- /dev/null +++ b/packages/hightide-web/tests/storage/useCookieKeyValueStore.test.ts @@ -0,0 +1,103 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"url": "https://tasks.helpwave.de/"} + */ + +import { createElement } from 'react' +import { renderToString } from 'react-dom/server' +import { renderHook } from '@testing-library/react' +import { useBrowserKeyValueStore } from '@helpwave/hightide-utils/hooks' +import { useCookieKeyValueStore } from '../../src/hooks/useCookieKeyValueStore' +import { CookieUtils } from '../../src/utils/cookies' + +const clearCookies = () => { + for (const part of document.cookie.split(';')) { + const name = part.split('=')[0]?.trim() + if (!name) continue + CookieUtils.remove(decodeURIComponent(name), { domain: 'helpwave.de' }) + CookieUtils.remove(decodeURIComponent(name)) + } +} + +describe('CookieUtils.resolveRegistrableDomain', () => { + test('resolves the registrable domain of the current host', () => { + expect(CookieUtils.resolveRegistrableDomain()).toBe('helpwave.de') + }) + + test('returns undefined for localhost and IPs', () => { + expect(CookieUtils.resolveRegistrableDomain('localhost')).toBeUndefined() + expect(CookieUtils.resolveRegistrableDomain('127.0.0.1')).toBeUndefined() + }) +}) + +describe('useCookieKeyValueStore', () => { + beforeEach(() => { + clearCookies() + window.localStorage.clear() + }) + + test('is not initialized and does not touch cookies before mounting', () => { + const getSpy = jest.spyOn(CookieUtils, 'get') + const resolveSpy = jest.spyOn(CookieUtils, 'resolveRegistrableDomain') + const states: boolean[] = [] + const Probe = () => { + const store = useCookieKeyValueStore() + states.push(store.isInitialized) + store.getValue('theme') + return null + } + + renderToString(createElement(Probe)) + + expect(states).toEqual([false]) + expect(getSpy).not.toHaveBeenCalled() + expect(resolveSpy).not.toHaveBeenCalled() + getSpy.mockRestore() + resolveSpy.mockRestore() + }) + + test('is initialized after mounting', () => { + const { result } = renderHook(() => useCookieKeyValueStore()) + + expect(result.current.isInitialized).toBe(true) + }) + + test('writes prefixed cookies on the shared domain', () => { + const setSpy = jest.spyOn(CookieUtils, 'set') + const { result } = renderHook(() => useCookieKeyValueStore()) + + result.current.setValue('theme', 'dark') + + expect(result.current.getValue('theme')).toBe('dark') + expect(CookieUtils.get('hightide-theme')).toBe('dark') + expect(setSpy).toHaveBeenCalledWith('hightide-theme', 'dark', expect.objectContaining({ domain: 'helpwave.de' })) + setSpy.mockRestore() + }) + + test('uses host-only cookies when domain is null', () => { + const setSpy = jest.spyOn(CookieUtils, 'set') + const { result } = renderHook(() => useCookieKeyValueStore({ domain: null })) + + result.current.setValue('locale', 'de-DE') + + expect(result.current.getValue('locale')).toBe('de-DE') + expect(setSpy).toHaveBeenCalledWith('hightide-locale', 'de-DE', expect.objectContaining({ domain: undefined })) + setSpy.mockRestore() + }) + + test('falls back to and syncs with the fallback store', () => { + window.localStorage.setItem('locale', 'de-DE') + const { result: browser } = renderHook(() => useBrowserKeyValueStore()) + const { result } = renderHook(() => useCookieKeyValueStore({ fallbackStore: browser.current })) + + expect(result.current.getValue('locale')).toBe('de-DE') + + result.current.setValue('locale', 'en-US') + expect(CookieUtils.get('hightide-locale')).toBe('en-US') + expect(window.localStorage.getItem('locale')).toBe('en-US') + + result.current.deleteValue('locale') + expect(result.current.getValue('locale')).toBeNull() + expect(window.localStorage.getItem('locale')).toBeNull() + }) +})