diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cbad29877..7c012a41ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,81 @@ 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 this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.7.0] - April, 11, 2024 + +### React + +#### Added + +- [CookieConsent] A new cookie is set containing version number of consents. + +#### Changed + +- [Link] Possibility to style a Link as button with `useButtonStyles` prop. +- [Header] Fixed an issue with inconsistent css-class order in SSR. +- [Select] Marked most props as deprecated. The redesigned next major version will have different props. +- [CookieConsent] Consent cookie's default domain was changed to window.location.hostname. +- [Combobox] Marked the component as deprecated +- [Footer.Base] Added `aria-hidden` to separators + +#### Fixed + +- [FileInput] FileInput accepts capitalized extensions (.png vs .PNG) +- [TextInput] Fix info, success and error icon shrinking when the description was long. + +### Core + +#### Changed + +- [Link] Possibility to style a Link as button. + +#### Fixed + +- [TextInput] Fix info, success and error icon shrinking when the description was long. + +### Documentation + +#### Added + +- [Hero] Added note that hero should not be used with side navigation. + +#### Changed + +- [CookieConsent] Cookie guidelines recommend using subdomains +- [Dropdown] Added notification about deprecated Combobox and deprecated Select props +- [Link] Added examples of styling Link as a button. +- [CookieConsent] Updated the list of cookies +- Updated Getting started-section for designers from Sketch/Abstract guidelines to Figma guidelines + - This includes: Contributing guide, FAQ, Versioning, and a new Figma tutorial + +#### Fixed + +- [Typography] Fixed typo in Typography table, mobile heading title had extra x. + +### Figma + +### Changed + +– [Design kit] The name on the project, file and cover is updated from HDS UI kit to HDS Design kit as per change of naming conventions to match the implementation more. +– [Footer] Copyright year updated from 2023 to 2024 + +### Fixed + +– [Button] Focus ring stroke width fixed from 2px to 3px +– [Dialog] Fixed icon on Danger-dialog from info to alert to match implementation +– [NavigationPattern] Broken hero on NavigationPattern medium size example fixed. + +### Changed + +– [Design kit] The name on the project, file and cover is updated from HDS UI kit to HDS Design kit as per change of naming conventions to match the implementation more. +– [Footer] Copyright year updated from 2023 to 2024 + +### Fixed + +– [Button] Focus ring stroke width fixed from 2px to 3px +– [Dialog] Fixed icon on Danger-dialog from info to alert to match implementation +– [NavigationPattern] Broken hero on NavigationPattern medium size example fixed. + ## [3.6.0] - March, 6, 2024 ### React diff --git a/packages/core/package.json b/packages/core/package.json index 272752da38..b903dc70cc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "hds-core", - "version": "3.6.0", + "version": "3.7.0", "description": "Core styles for the Helsinki Design System", "homepage": "https://github.com/City-of-Helsinki/helsinki-design-system#readme", "license": "MIT", @@ -30,7 +30,7 @@ "@storybook/manager-webpack5": "^6.5.16", "copyfiles": "2.2.0", "cssnano": "4.1.10", - "hds-design-tokens": "3.6.0", + "hds-design-tokens": "3.7.0", "postcss": "8.2.15", "postcss-cli": "8.3.1", "postcss-import": "12.0.1", diff --git a/packages/core/src/components/link/link.stories.js b/packages/core/src/components/link/link.stories.js index 69dcfbc6e5..ee865fa728 100644 --- a/packages/core/src/components/link/link.stories.js +++ b/packages/core/src/components/link/link.stories.js @@ -95,3 +95,10 @@ export const withCustomIcon = () => ` ` withCustomIcon.storyName = 'With a custom icon'; + +export const withButtonStyles = () => ` + + Link with button styles + +` +withButtonStyles.storyName = 'With Button styles'; \ No newline at end of file diff --git a/packages/core/src/components/text-input/text-input.css b/packages/core/src/components/text-input/text-input.css index 9ea566ead1..1e2bbd7fc3 100644 --- a/packages/core/src/components/text-input/text-input.css +++ b/packages/core/src/components/text-input/text-input.css @@ -194,6 +194,7 @@ background: var(--color-error); content: ''; display: inline-block; + flex-shrink: 0; height: var(--icon-size); margin-right: var(--spacing-2-xs); -webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg role='img' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E %3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11.175 3.45608C11.5239 2.86969 12.3977 2.84875 12.7842 3.39325L12.825 3.45608L21.8771 18.6666C22.2202 19.2432 21.8055 19.951 21.1235 19.9976L21.052 20H2.94799C2.24813 20 1.7987 19.3114 2.09013 18.7267L2.12295 18.6666L11.175 3.45608ZM13 16V18H11V16H13ZM13 8.5V14.5H11V8.5H13Z' fill='currentColor'%3E%3C/path%3E %3C/svg%3E"); @@ -224,6 +225,7 @@ background: var(--color-success); content: ''; display: inline-block; + flex-shrink: 0; height: var(--icon-size); margin-right: var(--spacing-2-xs); -webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg role='img' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E %3Cg fill='none' fill-rule='evenodd'%3E %3Crect width='24' height='24'/%3E %3Cpath fill='currentColor' d='M12,3 C7.02943725,3 3,7.02943725 3,12 C3,16.9705627 7.02943725,21 12,21 C16.9705627,21 21,16.9705627 21,12 C21,7.02943725 16.9705627,3 12,3 Z M16.5,8 L18,9.5 L10.5,17 L6,12.5 L7.5,11 L10.5,14 L16.5,8 Z'/%3E %3C/g%3E %3C/svg%3E"); @@ -254,6 +256,7 @@ background: var(--color-info); content: ''; display: inline-block; + flex-shrink: 0; height: var(--icon-size); margin-right: var(--spacing-2-xs); -webkit-mask-image: url("data:image/svg+xml;charset=utf-8,%3Csvg role='img' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E %3Cg fill='none' fill-rule='evenodd'%3E %3Crect width='24' height='24'/%3E %3Cpath fill='currentColor' d='M12,3 C16.9705627,3 21,7.02943725 21,12 C21,16.9705627 16.9705627,21 12,21 C7.02943725,21 3,16.9705627 3,12 C3,7.02943725 7.02943725,3 12,3 Z M13,16 L13,18 L11,18 L11,16 L13,16 Z M13,6 L13,14 L11,14 L11,6 L13,6 Z'/%3E %3C/g%3E %3C/svg%3E"); diff --git a/packages/design-tokens/package.json b/packages/design-tokens/package.json index 9e7b1f49fa..0c692205e3 100644 --- a/packages/design-tokens/package.json +++ b/packages/design-tokens/package.json @@ -1,6 +1,6 @@ { "name": "hds-design-tokens", - "version": "3.6.0", + "version": "3.7.0", "description": "Design tokens for the Helsinki Design System", "homepage": "https://github.com/City-of-Helsinki/helsinki-design-system#readme", "license": "MIT", diff --git a/packages/hds-js/package.json b/packages/hds-js/package.json index 782783ff8d..1e1ee50292 100644 --- a/packages/hds-js/package.json +++ b/packages/hds-js/package.json @@ -1,6 +1,6 @@ { "name": "hds-js", - "version": "3.6.0", + "version": "3.7.0", "description": "Vanilla js for the Helsinki Design System", "homepage": "https://github.com/City-of-Helsinki/helsinki-design-system#readme", "license": "MIT", diff --git a/packages/react/.eslintrc.json b/packages/react/.eslintrc.json index 0defff58ec..04f839ed12 100644 --- a/packages/react/.eslintrc.json +++ b/packages/react/.eslintrc.json @@ -23,7 +23,7 @@ "rules": { "no-use-before-define": "off", "default-param-last": "off", - "@typescript-eslint/no-use-before-define": ["error"], + "@typescript-eslint/no-use-before-define": "error", "no-shadow": "off", // replaced by ts-eslint rule below "@typescript-eslint/no-shadow": "error", "import/no-extraneous-dependencies": [ @@ -36,6 +36,14 @@ "error", { "groups": ["builtin", "external", "internal", ["parent", "sibling", "index"]], + "pathGroups": [ + { + "pattern": "./**/*.{css,scss}", + "group": "sibling", + "position": "before" + } + ], + "distinctGroup": false, "newlines-between": "always" } ], @@ -82,7 +90,7 @@ } ], "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error"] + "@typescript-eslint/no-unused-vars": "error" }, "settings": { "react": { diff --git a/packages/react/.loki/reference/chrome_iphone7_Components_Link_With_Button_styles.png b/packages/react/.loki/reference/chrome_iphone7_Components_Link_With_Button_styles.png new file mode 100644 index 0000000000..ef734cdc27 Binary files /dev/null and b/packages/react/.loki/reference/chrome_iphone7_Components_Link_With_Button_styles.png differ diff --git a/packages/react/.loki/reference/chrome_laptop_Components_Link_With_Button_styles.png b/packages/react/.loki/reference/chrome_laptop_Components_Link_With_Button_styles.png new file mode 100644 index 0000000000..9afa6a1a3a Binary files /dev/null and b/packages/react/.loki/reference/chrome_laptop_Components_Link_With_Button_styles.png differ diff --git a/packages/react/package.json b/packages/react/package.json index e5ab21319f..b66fca2054 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "hds-react", - "version": "3.6.0", + "version": "3.7.0", "description": "React components for the Helsinki Design System", "homepage": "https://github.com/City-of-Helsinki/helsinki-design-system#readme", "license": "MIT", @@ -133,7 +133,7 @@ "crc-32": "1.2.0", "date-fns": "2.16.1", "downshift": "6.0.6", - "hds-core": "3.6.0", + "hds-core": "3.7.0", "http-status-typed": "^1.0.1", "jwt-decode": "^3.1.2", "kashe": "1.0.4", diff --git a/packages/react/src/components/button/Button.tsx b/packages/react/src/components/button/Button.tsx index 911db93b8c..427e29d8d7 100644 --- a/packages/react/src/components/button/Button.tsx +++ b/packages/react/src/components/button/Button.tsx @@ -1,8 +1,8 @@ import React from 'react'; import '../../styles/base.module.css'; -import { LoadingSpinner } from '../loadingSpinner'; import styles from './Button.module.scss'; +import { LoadingSpinner } from '../loadingSpinner'; import classNames from '../../utils/classNames'; export type ButtonSize = 'default' | 'small'; diff --git a/packages/react/src/components/cookieConsent/__mocks__/mockDocumentCookie.ts b/packages/react/src/components/cookieConsent/__mocks__/mockDocumentCookie.ts index 1c652104b1..454d30f8f4 100644 --- a/packages/react/src/components/cookieConsent/__mocks__/mockDocumentCookie.ts +++ b/packages/react/src/components/cookieConsent/__mocks__/mockDocumentCookie.ts @@ -1,54 +1,104 @@ -import cookie from 'cookie'; +import cookie, { CookieSerializeOptions } from 'cookie'; type Options = Record; export type MockedDocumentCookieActions = { init: (keyValuePairs: Record) => void; - add: (keyValuePairs: Record) => void; + add: (keyValuePairs: Record, commonOptions?: CookieSerializeOptions) => void; + createCookieData: (keyValuePairs: Record) => string; getCookie: () => string; - getCookieOptions: (key: string) => Options; + getCookieOptions: (key: string, storedOptions?: CookieSerializeOptions, getDeleted?: boolean) => Options; extractCookieOptions: (cookieStr: string, keyToRemove: string) => Options; + getSerializeOptions: () => CookieSerializeOptions; restore: () => void; clear: () => void; mockGet: jest.Mock; mockSet: jest.Mock; }; -export default function mockDocumentCookie(): MockedDocumentCookieActions { +export default function mockDocumentCookie( + serializeOptionsOverride: CookieSerializeOptions = {}, +): MockedDocumentCookieActions { const COOKIE_OPTIONS_DELIMETER = ';'; const globalDocument = global.document; const oldDocumentCookie = globalDocument.cookie; const current = new Map(); const cookieWithOptions = new Map(); + const deletedActionSuffix = 'deleted!'; + const keyDelimeter = '___'; + const actionDelimeter = '>>>'; + const pathAndDomainDelimeter = '###'; - const getter = jest.fn((): string => { - return Array.from(current.entries()) - .map(([k, v]) => `${k} = ${v}${COOKIE_OPTIONS_DELIMETER}`) - .join(' '); - }); + const serializeOptions: CookieSerializeOptions = { + domain: 'default.domain.com', + path: '/', + ...serializeOptionsOverride, + }; - const setter = jest.fn((cookieData: string): void => { + const parseKeyValuePair = (cookieData: string): [string, string] => { const [key, value] = cookieData.split('='); const trimmedKey = key.trim(); if (!trimmedKey) { - return; + return ['', '']; } const newValue = String(value.split(COOKIE_OPTIONS_DELIMETER)[0]).trim(); - current.set(trimmedKey, newValue); - cookieWithOptions.set(trimmedKey, cookieData); - }); + return [trimmedKey, newValue]; + }; - Reflect.deleteProperty(globalDocument, 'cookie'); - Reflect.defineProperty(globalDocument, 'cookie', { - configurable: true, - get: () => getter(), - set: (newValue) => setter(newValue), - }); + const createKeyWithAllData = (key: string, path: string, domain: string, action?: string) => { + const pathAndDomain = `${encodeURIComponent(domain)}${pathAndDomainDelimeter}${encodeURIComponent(path)}`; + return `${key}${keyDelimeter}${pathAndDomain}${actionDelimeter}${action}`; + }; - const setWithObject = (keyValuePairs: Record) => - Object.entries(keyValuePairs).forEach(([k, v]) => { - setter(`${k}=${v}`); - }); + const splitKeyWithPathAndDomain = (source: string) => { + const [key, pathAndDomainAction] = source.split(keyDelimeter); + const [pathAndDomain, action] = String(pathAndDomainAction).split(actionDelimeter); + const [encodedDomain, encodedPath] = String(pathAndDomain).split(pathAndDomainDelimeter); + return { + key, + path: decodeURIComponent(encodedPath), + domain: decodeURIComponent(encodedDomain), + action, + }; + }; + + const createDeleteKey = (key: string, serializedData: string) => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const { path, domain } = getDomainAndPath(serializedData); + return createKeyWithAllData(key, path, domain, deletedActionSuffix); + }; + + const createStoredKey = (key: string, serializedData: string) => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + const { path, domain } = getDomainAndPath(serializedData); + return createKeyWithAllData(key, path, domain); + }; + + const storedKeyToDeleteKey = (keyWitProps: string) => { + const { path, domain, key } = splitKeyWithPathAndDomain(keyWitProps); + + return createKeyWithAllData(key, path, domain, deletedActionSuffix); + }; + + const getOriginalKey = (key: string) => { + return key.split(keyDelimeter)[0]; + }; + + const hasDeleteMatch = (keyWithAllProps: string) => { + return current.has(storedKeyToDeleteKey(keyWithAllProps)); + }; + + const isDeletedCookie = (key: string) => { + return key.includes(deletedActionSuffix) || hasDeleteMatch(key); + }; + + const createData = (data: [string, string][]): string => { + return data + .filter(([k]) => !isDeletedCookie(k)) + .map(([k, v]) => `${getOriginalKey(k)}=${v}${COOKIE_OPTIONS_DELIMETER}`) + .join(' ') + .slice(0, -1); // cookies do not have the last ";" + }; const extractCookieOptions = (cookieStr: string, keyToRemove: string): Options => { const fullCookie = cookie.parse(cookieStr); @@ -84,13 +134,90 @@ export default function mockDocumentCookie(): MockedDocumentCookieActions { }, options) as Options; }; + const hasDeleteFormat = (cookieData: string) => { + const { maxAge, expires } = extractCookieOptions(cookieData, '') as { maxAge: number; expires: Date }; + + if (!Number.isNaN(maxAge) && maxAge < 1) { + return true; + } + const time = expires && expires.getTime(); + if (!Number.isNaN(time) && time < Date.now()) { + return true; + } + return false; + }; + + const getDomainAndPath = (serializedData: string): { path: string; domain: string } => { + const { path = '', domain = '' } = { ...serializeOptions, ...extractCookieOptions(serializedData, '') }; + return { path, domain }; + }; + + const getter = jest.fn((): string => { + return createData(Array.from(current.entries())); + }); + + const setter = jest.fn((cookieData: string): void => { + const [trimmedKey, newValue] = parseKeyValuePair(cookieData); + if (!trimmedKey) { + return; + } + if (hasDeleteFormat(cookieData)) { + const deleteKey = createDeleteKey(trimmedKey, cookieData); + current.set(deleteKey, newValue); + cookieWithOptions.set(deleteKey, cookieData); + return; + } + + const keyWithPathAndDomain = createStoredKey(trimmedKey, cookieData); + + if (hasDeleteMatch(keyWithPathAndDomain)) { + const deleteKeyToDelete = createDeleteKey(trimmedKey, cookieData); + current.delete(deleteKeyToDelete); + // Note: cookieWithOptions are not deleted! + // This way it can be veried a cookie has been deleted at least once + } + current.set(keyWithPathAndDomain, newValue); + cookieWithOptions.set(keyWithPathAndDomain, cookieData); + }); + + Reflect.deleteProperty(globalDocument, 'cookie'); + Reflect.defineProperty(globalDocument, 'cookie', { + configurable: true, + get: () => getter(), + set: (newValue) => setter(newValue), + }); + + const setWithObject = ( + keyValuePairs: Record, + commonOptions: CookieSerializeOptions = serializeOptions, + ) => + Object.entries(keyValuePairs).forEach(([k, v]) => { + setter(cookie.serialize(k, v, commonOptions)); + }); + return { - add: (keyValuePairs) => setWithObject(keyValuePairs), + add: (keyValuePairs, commonOptions) => setWithObject(keyValuePairs, commonOptions), + createCookieData: (keyValuePairs) => { + const data = Object.entries(keyValuePairs).map(([k, v]) => { + return parseKeyValuePair(`${k}=${v}`); + }); + return createData(data); + }, getCookie: () => { return getter(); }, - getCookieOptions: (key) => { - return extractCookieOptions(cookieWithOptions.get(key), key); + getCookieOptions: (key, storedOptions = serializeOptions, getDeleted = false) => { + const storedKey = createKeyWithAllData( + key, + String(storedOptions.path), + String(storedOptions.domain), + getDeleted ? deletedActionSuffix : undefined, + ); + const options = cookieWithOptions.get(storedKey) as string; + if (!options) { + throw new Error(`No options found for ${key}/${storedKey}`); + } + return extractCookieOptions(options, key); }, extractCookieOptions, restore: () => { @@ -106,6 +233,8 @@ export default function mockDocumentCookie(): MockedDocumentCookieActions { setWithObject(keyValuePairs); setter.mockClear(); }, + getSerializeOptions: () => serializeOptions, + mockGet: getter, mockSet: setter, }; diff --git a/packages/react/src/components/cookieConsent/content.builder.test.ts b/packages/react/src/components/cookieConsent/content.builder.test.ts index 85c5dc1998..9f7b181563 100644 --- a/packages/react/src/components/cookieConsent/content.builder.test.ts +++ b/packages/react/src/components/cookieConsent/content.builder.test.ts @@ -1,10 +1,15 @@ +/* eslint-disable jest/no-mocks-import */ import { get } from 'lodash'; import { CookieContentSource, ContentSourceCookieGroup, createContent } from './content.builder'; import { getCookieContent } from './getContent'; import { CookieData, CookieGroup, Content, Category } from './contexts/ContentContext'; +import { COOKIE_NAME } from './cookieConsentController'; +import mockWindowLocation from '../../utils/mockWindowLocation'; +import { VERSION_COOKIE_NAME } from './cookieStorageProxy'; describe(`content.builder.ts`, () => { + const mockedWindowControls = mockWindowLocation(); const commonContent = getCookieContent(); const siteName = 'hel.fi'; const commonContentTestProps: CookieContentSource = { @@ -122,6 +127,10 @@ describe(`content.builder.ts`, () => { return JSON.parse(JSON.stringify(content)); }; + afterAll(() => { + mockedWindowControls.restore(); + }); + describe('createContent', () => { it('returns content.texts and content.language when categories are not passed. SiteName is in main.title.', () => { const plainContent = createContent(commonContentTestProps); @@ -883,14 +892,50 @@ describe(`content.builder.ts`, () => { expect(filterContentWithoutFunctions(contentWithCookie)).toEqual(filterContentWithoutFunctions(expectedResult)); }); }); - describe('Automatically adds the consent storage cookie to required consents', () => { + describe('Automatically adds the consent and consent version cookies to required consents', () => { + const baseProps = { siteName, currentLanguage: 'fi' } as CookieContentSource; + const pickSharedConsentGroup = (source: Content) => { + return source.requiredCookies?.groups[0] as CookieGroup; + }; + const findSharedConsentCookie = (source: Content, id: CookieData['id']) => { + return pickSharedConsentGroup(source).cookies.filter((cookie) => cookie.id === id)[0] as CookieData; + }; + const pickSharedConsentCookie = (source: Content) => { + return findSharedConsentCookie(source, COOKIE_NAME); + }; + const pickSharedConsentVersionCookie = (source: Content) => { + return findSharedConsentCookie(source, VERSION_COOKIE_NAME); + }; it('when noCommonConsentCookie is not true', () => { - const content = createContent({ siteName, currentLanguage: 'fi' }); + const content = createContent(baseProps); + const group = pickSharedConsentGroup(content); expect(content.requiredCookies).toBeDefined(); - expect(content.requiredCookies?.groups[0].title).toBe(commonContent.commonGroups.sharedConsents.fi.title); - expect(content.requiredCookies?.groups[0].cookies[0].name).toBe( - commonContent.commonCookies.helConsentCookie.fi.name, - ); + expect(group.title).toBe(commonContent.commonGroups.sharedConsents.fi.title); + expect(pickSharedConsentCookie(content)).toBeDefined(); + expect(pickSharedConsentVersionCookie(content)).toBeDefined(); + }); + it('cookies have name, hostName and id set automatically', () => { + const windowHostName = 'subdomain.hel.fi'; + const customHostName = 'cookie.domain.com'; + mockedWindowControls.setUrl(`https://${windowHostName}`); + + const content = createContent(baseProps, customHostName); + const storageCookie = pickSharedConsentCookie(content); + + expect(storageCookie.id).toBe(COOKIE_NAME); + expect(storageCookie.name).toBe(COOKIE_NAME); + expect(storageCookie.hostName).toBe(customHostName); + + const versionCookie = pickSharedConsentVersionCookie(content); + expect(versionCookie.id).toBe(VERSION_COOKIE_NAME); + expect(versionCookie.name).toBe(VERSION_COOKIE_NAME); + expect(versionCookie.hostName).toBe(customHostName); + + const contentWithoutPresetDomain = createContent(baseProps); + const storageCookie2 = pickSharedConsentCookie(contentWithoutPresetDomain); + expect(storageCookie2.hostName).toBe(windowHostName); + const versionCookie2 = pickSharedConsentVersionCookie(contentWithoutPresetDomain); + expect(versionCookie2.hostName).toBe(windowHostName); }); }); }); diff --git a/packages/react/src/components/cookieConsent/content.builder.ts b/packages/react/src/components/cookieConsent/content.builder.ts index 22d9f2280b..97c982746f 100644 --- a/packages/react/src/components/cookieConsent/content.builder.ts +++ b/packages/react/src/components/cookieConsent/content.builder.ts @@ -12,7 +12,8 @@ import type { ContentContextType, } from './contexts/ContentContext'; import { getCookieContent } from './getContent'; -import { COOKIE_NAME } from './cookieConsentController'; +import { COOKIE_NAME, getCookieDomainFromUrl } from './cookieConsentController'; +import { VERSION_COOKIE_NAME } from './cookieStorageProxy'; type ContentSourceCookieData = Partial & { commonGroup?: string; @@ -161,15 +162,16 @@ function mergeObjects(target: MergableContent, source: MergableContent, paths: s }); } -function buildCookieGroups(props: CookieContentSource): { - requiredCookies: CookieGroup[]; - optionalCookies: CookieGroup[]; -} { +function buildCookieGroups( + props: CookieContentSource, + cookieDomain: string, +): { requiredCookies: CookieGroup[]; optionalCookies: CookieGroup[] } { const requiredCookies = []; const optionalCookies = []; const groupMap = new Map(); const { currentLanguage, noCommonConsentCookie } = props; let helConsentCookieFound = false; + let helConsentCookieVersionFound = false; const parseGroup = (groupSource: ContentSourceCookieGroup, isRequired: boolean) => { let consentGroup: CookieGroup | undefined; @@ -245,6 +247,9 @@ function buildCookieGroups(props: CookieContentSource): { if (cookie.id === COOKIE_NAME) { helConsentCookieFound = true; } + if (cookie.id === VERSION_COOKIE_NAME) { + helConsentCookieVersionFound = true; + } }); } if (props.optionalCookies?.cookies) { @@ -253,13 +258,25 @@ function buildCookieGroups(props: CookieContentSource): { if (cookie.id === COOKIE_NAME) { helConsentCookieFound = true; } + if (cookie.id === VERSION_COOKIE_NAME) { + helConsentCookieVersionFound = true; + } }); } if (!noCommonConsentCookie && !helConsentCookieFound) { const consentCookie: Partial = getCommonCookie(currentLanguage, 'helConsentCookie'); consentCookie.id = COOKIE_NAME; + consentCookie.name = COOKIE_NAME; + consentCookie.hostName = cookieDomain; parseGroup({ commonGroup: 'sharedConsents', cookies: [consentCookie] }, true); } + if (!noCommonConsentCookie && !helConsentCookieVersionFound) { + const consentVersionCookie: Partial = getCommonCookie(currentLanguage, 'helConsentCookieVersion'); + consentVersionCookie.id = VERSION_COOKIE_NAME; + consentVersionCookie.name = VERSION_COOKIE_NAME; + consentVersionCookie.hostName = cookieDomain; + parseGroup({ commonGroup: 'sharedConsents', cookies: [consentVersionCookie] }, true); + } return { requiredCookies, optionalCookies, @@ -286,7 +303,7 @@ function buildConsentCategories( return data as Category; } -export function createContent(props: CookieContentSource): Content { +export function createContent(props: CookieContentSource, cookieDomain?: string): Content { const { siteName, language, currentLanguage, optionalCookies, requiredCookies, focusTargetSelector } = props; const content: Partial = { texts: getTexts(currentLanguage, siteName), @@ -296,7 +313,7 @@ export function createContent(props: CookieContentSource): Content { if (props.texts) { mergeObjects(content.texts, props.texts, ['sections.main', 'sections.details', 'ui', 'tableHeadings']); } - const consentGroups = buildCookieGroups(props); + const consentGroups = buildCookieGroups(props, cookieDomain || getCookieDomainFromUrl()); const categoryDescriptions = getCategoryDescriptions(currentLanguage); content.optionalCookies = buildConsentCategories( categoryDescriptions.optionalCookies, @@ -318,7 +335,7 @@ export function pickConsentIdsFromContentSource(contentSource: Partial { if (group.cookies) { diff --git a/packages/react/src/components/cookieConsent/contexts/ContentContext.tsx b/packages/react/src/components/cookieConsent/contexts/ContentContext.tsx index a988dcf6bb..1e699ed320 100644 --- a/packages/react/src/components/cookieConsent/contexts/ContentContext.tsx +++ b/packages/react/src/components/cookieConsent/contexts/ContentContext.tsx @@ -97,10 +97,10 @@ export const forceFocusToElement = (elementSelector: string): void => { } }; -export const Provider = ({ children, contentSource }: ConsentContextProps): React.ReactElement => { +export const Provider = ({ children, contentSource, cookieDomain }: ConsentContextProps): React.ReactElement => { const language = contentSource.currentLanguage; const contextData: ContentContextType = useMemo(() => { - const content = createContent(contentSource); + const content = createContent(contentSource, cookieDomain); const callbacks = { onAllConsentsGiven: contentSource.onAllConsentsGiven, onConsentsParsed: contentSource.onConsentsParsed, diff --git a/packages/react/src/components/cookieConsent/contexts/ContextComponent.test.tsx b/packages/react/src/components/cookieConsent/contexts/ContextComponent.test.tsx index 3ffc0e8df5..b32afe9800 100644 --- a/packages/react/src/components/cookieConsent/contexts/ContextComponent.test.tsx +++ b/packages/react/src/components/cookieConsent/contexts/ContextComponent.test.tsx @@ -255,7 +255,7 @@ describe('ContextComponent', () => { ...allApprovedConsentData.consents, ...unknownConsents, }); - expect(getSetCookieArguments().options.domain).toEqual('hel.fi'); + expect(getSetCookieArguments().options.domain).toEqual('subdomain.hel.fi'); }); it('sets the domain of the cookie to given cookieDomain', () => { diff --git a/packages/react/src/components/cookieConsent/cookieConsentController.test.ts b/packages/react/src/components/cookieConsent/cookieConsentController.test.ts index beec8a4a64..c9dab8d53a 100644 --- a/packages/react/src/components/cookieConsent/cookieConsentController.test.ts +++ b/packages/react/src/components/cookieConsent/cookieConsentController.test.ts @@ -10,6 +10,7 @@ import createConsentController, { } from './cookieConsentController'; import mockDocumentCookie from './__mocks__/mockDocumentCookie'; import { extractSetCookieArguments } from './test.util'; +import { VERSION_COOKIE_NAME } from './cookieStorageProxy'; describe(`cookieConsentController.ts`, () => { let controller: ConsentController; @@ -25,6 +26,7 @@ describe(`cookieConsentController.ts`, () => { }); const getSetCookieArguments = (index = -1) => extractSetCookieArguments(mockedCookieControls, index); + const versionCookie = { [VERSION_COOKIE_NAME]: '1' }; const defaultControllerTestData = { requiredConsents: ['requiredConsent1', 'requiredConsent2'], @@ -42,7 +44,7 @@ describe(`cookieConsentController.ts`, () => { cookie?: ConsentObject; cookieDomain?: string; }) => { - mockedCookieControls.init({ [COOKIE_NAME]: JSON.stringify(cookie) }); + mockedCookieControls.init({ [COOKIE_NAME]: JSON.stringify(cookie), ...versionCookie }); controller = createConsentController({ requiredConsents, optionalConsents, @@ -128,7 +130,8 @@ describe(`cookieConsentController.ts`, () => { it('cookie is only read on init', () => { createControllerAndInitCookie({}); - expect(mockedCookieControls.mockGet).toHaveBeenCalledTimes(1); + // cookieStorageProxy reads cookies (x2) first and then cookieController + expect(mockedCookieControls.mockGet).toHaveBeenCalledTimes(3); expect(mockedCookieControls.mockSet).toHaveBeenCalledTimes(0); }); }); @@ -318,16 +321,16 @@ describe(`cookieConsentController.ts`, () => { }); }); - it('the domain of the cookie is set to . so it is readable from *.hel.fi and *.hel.ninja', () => { + it('by default, the domain of the cookie is set to window.location.hostname so it is only readable from that exact domain and not other subdomains.', () => { mockedWindowControls.setUrl('https://subdomain.hel.fi'); createControllerAndInitCookie(defaultControllerTestData); controller.save(); - expect(getSetCookieArguments().options.domain).toEqual('hel.fi'); + expect(getSetCookieArguments().options.domain).toEqual('subdomain.hel.fi'); mockedWindowControls.setUrl('http://profiili.hel.ninja:3000?foo=bar'); createControllerAndInitCookie(defaultControllerTestData); controller.save(); - expect(getSetCookieArguments().options.domain).toEqual('hel.ninja'); + expect(getSetCookieArguments().options.domain).toEqual('profiili.hel.ninja'); }); it('if "cookieDomain" property is passed in the props, it is set as the domain of the cookie', () => { diff --git a/packages/react/src/components/cookieConsent/cookieConsentController.ts b/packages/react/src/components/cookieConsent/cookieConsentController.ts index ca415e4e98..ff450e2cdf 100644 --- a/packages/react/src/components/cookieConsent/cookieConsentController.ts +++ b/packages/react/src/components/cookieConsent/cookieConsentController.ts @@ -1,6 +1,6 @@ import { pick, isObject, isUndefined } from 'lodash'; -import { createCookieController } from './cookieController'; +import { createCookieStorageProxy, getCookieDomainForSubDomainAccess } from './cookieStorageProxy'; export type ConsentList = string[]; @@ -75,11 +75,7 @@ export function parseConsents(jsonString: string | undefined): ConsentObject { } export const getCookieDomainFromUrl = (): string => { - if (typeof window === 'undefined') { - return ''; - } - - return window.location.hostname.split('.').slice(-2).join('.'); + return getCookieDomainForSubDomainAccess(); }; export function createStorage(initialValues: ConsentStorage): { @@ -153,7 +149,7 @@ export default function createConsentController(props: ConsentControllerProps): verifyConsentProps(props); const { optionalConsents = [], requiredConsents = [] } = props; const allConsents = [...optionalConsents, ...requiredConsents]; - const cookieController = createCookieController( + const cookieController = createCookieStorageProxy( { maxAge: COOKIE_EXPIRATION_TIME, domain: props.cookieDomain || getCookieDomainFromUrl(), @@ -223,13 +219,3 @@ export default function createConsentController(props: ConsentControllerProps): save, }; } - -export function getConsentsFromCookie(cookieDomain?: string): ConsentObject { - const cookieController = createCookieController( - { - domain: cookieDomain || getCookieDomainFromUrl(), - }, - COOKIE_NAME, - ); - return parseConsents(cookieController.get()); -} diff --git a/packages/react/src/components/cookieConsent/cookieController.test.ts b/packages/react/src/components/cookieConsent/cookieController.test.ts index 6e27c02fc9..fc0623805d 100644 --- a/packages/react/src/components/cookieConsent/cookieController.test.ts +++ b/packages/react/src/components/cookieConsent/cookieController.test.ts @@ -1,4 +1,6 @@ /* eslint-disable jest/no-mocks-import */ +import cookie from 'cookie'; + import mockDocumentCookie from './__mocks__/mockDocumentCookie'; import { getAll, setNamedCookie, getNamedCookie, createCookieController, CookieSetOptions } from './cookieController'; @@ -59,7 +61,7 @@ describe(`cookieController.ts`, () => { const cookiesAsString = mockedCookieControls.getCookie(); Object.entries(allCookies).forEach(([key, value]) => { - expect(cookiesAsString.includes(`${key} = ${value};`)).toBeTruthy(); + expect(cookiesAsString.includes(cookie.serialize(key, value))).toBeTruthy(); }); }); @@ -78,7 +80,7 @@ describe(`cookieController.ts`, () => { setNamedCookie(target, newValue); expect(getNamedCookie(target)).toEqual(newValue); const cookiesAsString = mockedCookieControls.getCookie(); - expect(cookiesAsString).toEqual(`${target} = ${encodeURIComponent(newValue)};`); + expect(cookiesAsString).toEqual(`${target}=${encodeURIComponent(newValue)}`); }); it('passes also options to document.cookie', () => { @@ -92,17 +94,18 @@ describe(`cookieController.ts`, () => { maxAge: 100, }; setNamedCookie(dummyKey, dummyValue, options); - const optionsFromCookie = mockedCookieControls.getCookieOptions(dummyKey); + const optionsFromCookie = mockedCookieControls.getCookieOptions(dummyKey, options); expect(optionsFromCookie).toEqual(options); }); it('passes also partial options to document.cookie', () => { + const activeCookieOptions = mockedCookieControls.getSerializeOptions(); const options: CookieSetOptions = { domain: 'domain.com', sameSite: 'none', }; setNamedCookie(dummyKey, dummyValue, options); - const optionsFromCookie = mockedCookieControls.getCookieOptions(dummyKey); + const optionsFromCookie = mockedCookieControls.getCookieOptions(dummyKey, { ...activeCookieOptions, ...options }); expect(optionsFromCookie).toEqual(options); }); it('throws when setting invalid options', () => { @@ -133,7 +136,7 @@ describe(`cookieController.ts`, () => { controller.set(cookieValue); expect(getNamedCookie(cookieName)).toEqual(cookieValue); - const passedOptions = mockedCookieControls.getCookieOptions(cookieName); + const passedOptions = mockedCookieControls.getCookieOptions(cookieName, options); expect(passedOptions).toEqual(options); }); it('createCookieController.get gets a cookie with name passed to the controller on initialization', () => { diff --git a/packages/react/src/components/cookieConsent/cookieController.ts b/packages/react/src/components/cookieConsent/cookieController.ts index 3029d5e229..a869e9156d 100644 --- a/packages/react/src/components/cookieConsent/cookieController.ts +++ b/packages/react/src/components/cookieConsent/cookieController.ts @@ -2,6 +2,13 @@ import cookie, { CookieSerializeOptions } from 'cookie'; export type CookieSetOptions = CookieSerializeOptions; +export const defaultCookieSetOptions: CookieSetOptions = { + path: '/', + secure: false, + sameSite: 'strict', + maxAge: undefined, +}; + function getAll() { return cookie.parse(document.cookie); } @@ -22,13 +29,6 @@ function createCookieController( get: () => string; set: (data: string) => void; } { - const defaultCookieSetOptions: CookieSetOptions = { - path: '/', - secure: false, - sameSite: 'strict', - maxAge: undefined, - }; - const cookieOptions: CookieSetOptions = { ...defaultCookieSetOptions, ...options, diff --git a/packages/react/src/components/cookieConsent/cookieModal/CookieModal.test.tsx b/packages/react/src/components/cookieConsent/cookieModal/CookieModal.test.tsx index 3d1909e240..b11e3a5159 100644 --- a/packages/react/src/components/cookieConsent/cookieModal/CookieModal.test.tsx +++ b/packages/react/src/components/cookieConsent/cookieModal/CookieModal.test.tsx @@ -27,7 +27,7 @@ import { Content } from '../contexts/ContentContext'; const { defaultConsentData, unknownConsents, dataTestIds } = commonTestProps; -const mockedCookieControls = mockDocumentCookie(); +const mockedCookieControls = mockDocumentCookie({ domain: 'localhost' }); let content: Content; diff --git a/packages/react/src/components/cookieConsent/cookiePage/CookiePage.test.tsx b/packages/react/src/components/cookieConsent/cookiePage/CookiePage.test.tsx index 60682dd446..c613309f3b 100644 --- a/packages/react/src/components/cookieConsent/cookiePage/CookiePage.test.tsx +++ b/packages/react/src/components/cookieConsent/cookiePage/CookiePage.test.tsx @@ -21,6 +21,7 @@ import { } from '../test.util'; import { createContent } from '../content.builder'; import { CookiePage } from './CookiePage'; +import { VERSION_COOKIE_NAME } from '../cookieStorageProxy'; const { requiredGroupParent, optionalGroupParent, defaultConsentData, unknownConsents, dataTestIds } = commonTestProps; @@ -40,8 +41,9 @@ const renderCookieConsent = ({ ...unknownConsents, }; const contentSource = getContentSource(requiredConsents, optionalConsents); + const versionCookie = { [VERSION_COOKIE_NAME]: '1' }; content = createContent(contentSource); - mockedCookieControls.init({ [COOKIE_NAME]: JSON.stringify(cookieWithInjectedUnknowns) }); + mockedCookieControls.init({ [COOKIE_NAME]: JSON.stringify(cookieWithInjectedUnknowns), ...versionCookie }); const result = render(); return result; diff --git a/packages/react/src/components/cookieConsent/cookieStorageProxy.test.ts b/packages/react/src/components/cookieConsent/cookieStorageProxy.test.ts new file mode 100644 index 0000000000..0dabaa4316 --- /dev/null +++ b/packages/react/src/components/cookieConsent/cookieStorageProxy.test.ts @@ -0,0 +1,236 @@ +/* eslint-disable jest/no-mocks-import */ +import { CookieSerializeOptions } from 'cookie'; + +import mockDocumentCookie from './__mocks__/mockDocumentCookie'; +import mockWindowLocation from '../../utils/mockWindowLocation'; +import { CookieSetOptions, defaultCookieSetOptions, getAll } from './cookieController'; +import { VERSION_COOKIE_NAME, createCookieStorageProxy } from './cookieStorageProxy'; +import { COOKIE_NAME } from './cookieConsentController'; +import { getMockCalls } from '../../utils/testHelpers'; + +describe(`cookieStorageProxy.ts`, () => { + const mockedCookieControls = mockDocumentCookie(); + const mockedWindowControls = mockWindowLocation(); + + afterEach(() => { + mockedCookieControls.clear(); + }); + afterAll(() => { + mockedCookieControls.restore(); + mockedWindowControls.restore(); + }); + + const otherCookiesList = { + cookieKey: COOKIE_NAME, + jsonCookie: JSON.stringify({ json: true }), + objectStringCookie: '{ obj: true }', + emptyCookie: '', + }; + + const windowDomain = 'subdomain.hel.fi'; + + mockedWindowControls.setUrl(`https://${windowDomain}`); + + const cookieOptionsForMultiDomain: CookieSerializeOptions = { + domain: 'hel.fi', + path: '/', + }; + + const cookieOptionsForSubDomain: CookieSerializeOptions = { + ...cookieOptionsForMultiDomain, + domain: windowDomain, + }; + + const baseConsentData = { + 'consent-name': false, + 'another-consent-name': true, + consentX: false, + consentY: true, + [COOKIE_NAME]: true, + }; + const versionCookie = { [VERSION_COOKIE_NAME]: '1' }; + describe('createcookieStorageProxy creates a proxy for cookieController', () => { + const consentCookie = { + [COOKIE_NAME as string]: JSON.stringify(baseConsentData), + }; + + const multipleCookiesWithConsentCookie = { + ...otherCookiesList, + ...consentCookie, + }; + + const getNumberOfStoredConsentCookies = () => document.cookie.split(`${COOKIE_NAME}=`).length - 1; + const getCookieWriteCount = () => getMockCalls(mockedCookieControls.mockSet).length; + const getCookieReadCount = () => getMockCalls(mockedCookieControls.mockGet).length; + const storeCookieVersion = () => mockedCookieControls.add(versionCookie); + const removeCookieVersion = (cookies: string) => { + return cookies.replace(`; ${VERSION_COOKIE_NAME}=1`, ''); + }; + + const getCookieOptionsDomain = (cookieName: string, writeOptions: CookieSetOptions, wasDeleted: boolean) => { + return mockedCookieControls.getCookieOptions(cookieName, writeOptions, wasDeleted).domain; + }; + const getAddedCookieOptionsDomain = (cookieName: string, writeOptions: CookieSetOptions) => { + return getCookieOptionsDomain(cookieName, writeOptions, false); + }; + const getDeletedCookieOptionsDomain = (cookieName: string, writeOptions: CookieSetOptions) => { + return getCookieOptionsDomain(cookieName, writeOptions, true); + }; + + const initTests = (extraOptions: CookieSetOptions = {}) => { + const options: CookieSetOptions = { + ...defaultCookieSetOptions, + ...extraOptions, + }; + return createCookieStorageProxy(options, COOKIE_NAME); + }; + + it('Does not add or convert cookies, if no old nor new consents are found.', () => { + mockedCookieControls.add(otherCookiesList); + const cookiesBefore = document.cookie; + const writeCountBeforeInit = getCookieWriteCount(); + const readCountBeforeInit = getCookieReadCount(); + const proxy = initTests(); + // consent cookie and version cookie are read + expect(getCookieReadCount()).toBe(readCountBeforeInit + 2); + expect(getNumberOfStoredConsentCookies()).toBe(0); + expect(getCookieReadCount()).toBe(readCountBeforeInit + 3); + + const consents = proxy.get(); + expect(getCookieReadCount()).toBe(readCountBeforeInit + 4); + + const result = getAll(); + expect(getCookieReadCount()).toBe(readCountBeforeInit + 5); + + expect(consents).toEqual(''); + expect(result).toMatchObject(otherCookiesList); + expect(getCookieWriteCount()).toBe(writeCountBeforeInit); + expect(document.cookie).toBe(cookiesBefore); + }); + + it('Does not add or convert cookies, if there is a single consent cookie and version is stored.', () => { + storeCookieVersion(); + mockedCookieControls.add(multipleCookiesWithConsentCookie); + const cookiesBefore = document.cookie; + expect(getNumberOfStoredConsentCookies()).toBe(1); + const writeCountBeforeInit = getCookieWriteCount(); + const readCountBeforeInit = getCookieReadCount(); + const proxy = initTests(); + expect(getCookieReadCount()).toBe(readCountBeforeInit + 2); + const consents = proxy.get(); + expect(consents).toEqual(multipleCookiesWithConsentCookie[COOKIE_NAME]); + const result = getAll(); + expect(result).toMatchObject(multipleCookiesWithConsentCookie); + expect(result[COOKIE_NAME]).toEqual(multipleCookiesWithConsentCookie[COOKIE_NAME]); + expect(getCookieWriteCount()).toBe(writeCountBeforeInit); + expect(getCookieReadCount()).toBe(readCountBeforeInit + 4); + expect(document.cookie).toBe(cookiesBefore); + }); + + it('Stores also a version cookie when consents are stored for the first time.', () => { + mockedCookieControls.add(otherCookiesList); + const writeCountBeforeInit = getCookieWriteCount(); + const cookieData = consentCookie[COOKIE_NAME]; + const proxy = initTests(); + // consent cookie and version cookie are read + expect(getNumberOfStoredConsentCookies()).toBe(0); + expect(getCookieWriteCount()).toBe(writeCountBeforeInit); + + proxy.set(cookieData); + expect(getCookieWriteCount()).toBe(writeCountBeforeInit + 2); + const consents = proxy.get(); + + expect(consents).toEqual(cookieData); + const result = getAll(); + expect(result).toMatchObject({ ...otherCookiesList, ...consentCookie, ...versionCookie }); + }); + + it('Moves old cookie to a new domain, if version is not found. New cookies have same domain. Version cookie is not set.', () => { + mockedCookieControls.add(multipleCookiesWithConsentCookie, cookieOptionsForMultiDomain); + const cookiesBefore = document.cookie; + expect(getNumberOfStoredConsentCookies()).toBe(1); + const writeCountBeforeInit = getCookieWriteCount(); + const readCountBeforeInit = getCookieReadCount(); + const proxy = initTests(); + const consents = proxy.get(); + expect(consents).toEqual(multipleCookiesWithConsentCookie[COOKIE_NAME]); + const result = getAll(); + expect(result).toMatchObject(multipleCookiesWithConsentCookie); + expect(result[COOKIE_NAME]).toEqual(multipleCookiesWithConsentCookie[COOKIE_NAME]); + // one write for removing old cookie, one write for adding new + expect(getCookieWriteCount()).toBe(writeCountBeforeInit + 2); + expect(getCookieReadCount()).toBe(readCountBeforeInit + 4); + expect(removeCookieVersion(document.cookie)).toBe(cookiesBefore); + + expect(getDeletedCookieOptionsDomain(COOKIE_NAME, cookieOptionsForMultiDomain)).toBe( + cookieOptionsForMultiDomain.domain, + ); + expect(getAddedCookieOptionsDomain(COOKIE_NAME, cookieOptionsForSubDomain)).toBe( + cookieOptionsForSubDomain.domain, + ); + + expect(() => getAddedCookieOptionsDomain(VERSION_COOKIE_NAME, cookieOptionsForSubDomain)).toThrow(); + }); + + it('If a custom domain is set, the consent cookie is moved, if the version cookie is not found. Version cookie is not set.', () => { + const cookieOptions = cookieOptionsForMultiDomain; + const commonDomainForAllCookies = cookieOptions.domain; + mockedCookieControls.add(multipleCookiesWithConsentCookie, cookieOptions); + const cookiesBefore = document.cookie; + const writeCountBeforeInit = getCookieWriteCount(); + const proxy = initTests({ domain: commonDomainForAllCookies }); + expect(proxy.get()).toEqual(multipleCookiesWithConsentCookie[COOKIE_NAME]); + expect(removeCookieVersion(document.cookie)).toBe(cookiesBefore); + expect(getCookieWriteCount()).toBe(writeCountBeforeInit + 2); + + expect(getDeletedCookieOptionsDomain(COOKIE_NAME, cookieOptions)).toBe(commonDomainForAllCookies); + expect(getAddedCookieOptionsDomain(COOKIE_NAME, cookieOptions)).toBe(commonDomainForAllCookies); + expect(() => getAddedCookieOptionsDomain(VERSION_COOKIE_NAME, cookieOptionsForSubDomain)).toThrow(); + }); + it('Removes all consent cookies, if there are multiple consent cookies. Empty consent cookie is added.', () => { + mockedCookieControls.add(multipleCookiesWithConsentCookie, cookieOptionsForMultiDomain); + mockedCookieControls.add(multipleCookiesWithConsentCookie, cookieOptionsForSubDomain); + const writeCountBeforeInit = getCookieWriteCount(); + expect(getNumberOfStoredConsentCookies()).toBe(2); + const proxy = initTests(); + // empty consent cookie was added + expect(getNumberOfStoredConsentCookies()).toBe(1); + const consents = proxy.get(); + expect(consents).toEqual('{}'); + const result = getAll(); + + expect(result).toMatchObject(otherCookiesList); + // two writes for removing old cookies, one write for adding empty one + expect(getCookieWriteCount()).toBe(writeCountBeforeInit + 3); + }); + + it('Does not add or convert cookies, if there are no consents, but version is stored.', () => { + mockedCookieControls.add(otherCookiesList, cookieOptionsForMultiDomain); + storeCookieVersion(); + const cookiesBefore = document.cookie; + expect(getNumberOfStoredConsentCookies()).toBe(0); + const writeCountBeforeInit = getCookieWriteCount(); + const readCountBeforeInit = getCookieReadCount(); + const proxy = initTests(); + expect(getCookieReadCount()).toBe(readCountBeforeInit + 2); + const consents = proxy.get(); + expect(consents).toEqual(''); + const result = getAll(); + expect(result).toMatchObject(otherCookiesList); + expect(getCookieWriteCount()).toBe(writeCountBeforeInit); + expect(document.cookie).toBe(cookiesBefore); + }); + + it('When a cookie is removed both "maxAge" and "expires" should not be set. "expires" is set to year 1970.', () => { + mockedCookieControls.add(multipleCookiesWithConsentCookie, cookieOptionsForMultiDomain); + initTests(); + const multiDomainDeleteOptions = mockedCookieControls.getCookieOptions( + COOKIE_NAME, + cookieOptionsForMultiDomain, + true, + ); + expect(multiDomainDeleteOptions.maxAge).toBeUndefined(); + expect((multiDomainDeleteOptions.expires as Date).getFullYear()).toBe(1970); + }); + }); +}); diff --git a/packages/react/src/components/cookieConsent/cookieStorageProxy.ts b/packages/react/src/components/cookieConsent/cookieStorageProxy.ts new file mode 100644 index 0000000000..0a73a3c7fb --- /dev/null +++ b/packages/react/src/components/cookieConsent/cookieStorageProxy.ts @@ -0,0 +1,134 @@ +import { CookieSerializeOptions, parse } from 'cookie'; + +import { createCookieController, setNamedCookie, defaultCookieSetOptions, getAll } from './cookieController'; + +export type CookieSetOptions = CookieSerializeOptions; + +const COOKIE_DELIMETER = ';'; +const CURRENT_VERSION = 1; +export const VERSION_COOKIE_NAME = 'city-of-helsinki-consent-version'; + +// the old version how default cookie domain was picked +function getCookieDomainForMultiDomainAccess(): string { + if (typeof window === 'undefined') { + return ''; + } + + return window.location.hostname.split('.').slice(-2).join('.'); +} + +// the new version how to pick default cookie domain +export function getCookieDomainForSubDomainAccess(): string { + if (typeof window === 'undefined') { + return ''; + } + + return window.location.hostname; +} + +function getVersionNumber(cookies?: ReturnType): number { + const data = cookies || getAll(); + const version = data ? parseInt(data[VERSION_COOKIE_NAME], 10) : 0; + return Number.isNaN(version) ? 0 : version; +} + +/** + * This is a proxy between 'cookieConsentController' and 'cookieController'. It mimics the 'cookieController'. + * The purpose of this controller is to convert old cookie consents to the new version after the cookie consent default domain has changed. + * This handles the uncontrollable scenario where old users have consents stored to '*.hel.fi' and new version stores them to '.hel.fi'. + * The 'document.cookie' would contain both versions in uncertain order. The 'cookie.parse()' returns only the first, which can be either. + * This proxy should fix all overlaps, because it moves old versions to new domain. + */ + +export function createCookieStorageProxy( + ...args: Parameters +): ReturnType { + const [options, cookieName] = args; + const domain = options.domain || getCookieDomainForSubDomainAccess(); + const controllerOptions = { ...options, domain }; + const cookieController = createCookieController(controllerOptions, cookieName); + + const isConsentCookie = (cookieData: unknown) => { + return String(cookieData).trim().startsWith(cookieName); + }; + + const getConsentCookies = (): string[] => { + const cookies = document.cookie || ''; + if (!cookies || cookies.indexOf('=') < 0) { + return []; + } + return cookies.split(COOKIE_DELIMETER).filter((cookieData) => isConsentCookie(cookieData)); + }; + + const getConsentData = (consentCookieList: string[]): string => { + const consentData = consentCookieList[0]; + const data = consentData ? parse(`${consentData}${COOKIE_DELIMETER}`.trim()) : {}; + return data[cookieName] || ''; + }; + + let hasVersionNumber = getVersionNumber() === CURRENT_VERSION; + const storeVersionNumberIfNotSet = () => { + if (hasVersionNumber) { + return; + } + setNamedCookie(VERSION_COOKIE_NAME, String(CURRENT_VERSION), { ...defaultCookieSetOptions, ...controllerOptions }); + hasVersionNumber = true; + }; + + const clearedDomains = new Set(); + + const clearCookie = (cookieDomain: string) => { + if (clearedDomains.has(cookieDomain)) { + return; + } + clearedDomains.add(cookieDomain); + setNamedCookie(cookieName, '', { + ...defaultCookieSetOptions, + ...options, + domain: cookieDomain, + expires: new Date(0), + maxAge: undefined, + }); + }; + + const clearAllConsentCookieDomains = () => { + clearCookie(getCookieDomainForMultiDomainAccess()); + clearCookie(getCookieDomainForSubDomainAccess()); + if (options.domain) { + clearCookie(options.domain); + } + }; + + // Logic of the cookie filtering: + // The old domain was by default *.hel.fi + // If a custom domain has been used, it can only be .hel.fi or still *.hel.fi + // If a custom domain has been added/removed after cookies with old default domain are set, there might be two existing versions returned already; before even the new default was added. + // If this is the case there are two cookies with consents. + // As a fail-safe, if multiple consents are found they are all removed, because there is no way knowing which consents are latest. + // In this case cookie consents are asked again. + + const consentCookies = getConsentCookies(); + const hasStoredConsents = consentCookies.length > 0; + const hasMultipleVersions = consentCookies.length > 1; + + if (hasMultipleVersions) { + clearAllConsentCookieDomains(); + cookieController.set('{}'); + } else if (!hasVersionNumber && hasStoredConsents) { + clearCookie(getCookieDomainForMultiDomainAccess()); + if (hasStoredConsents) { + const current = getConsentData(consentCookies); + if (current) { + cookieController.set(current); + } + } + } + + return { + get: () => cookieController.get(), + set: (data: string) => { + storeVersionNumberIfNotSet(); + cookieController.set(data); + }, + }; +} diff --git a/packages/react/src/components/cookieConsent/getContent.ts b/packages/react/src/components/cookieConsent/getContent.ts index 2b9c973364..347c08451d 100644 --- a/packages/react/src/components/cookieConsent/getContent.ts +++ b/packages/react/src/components/cookieConsent/getContent.ts @@ -614,27 +614,44 @@ export function getCookieContent() { commonCookies: { helConsentCookie: { id: 'SET_IN_CODE', - hostName: '*.hel.fi', + hostName: 'SET_IN_CODE', commonGroup: 'SET_IN_CODE', + name: 'SET_IN_CODE', fi: { - name: 'Evästesuostumukset', description: 'Sivusto käyttää tätä evästettä tietojen tallentamiseen siitä, ovatko kävijät antaneet hyväksyntänsä tai kieltäytyneet evästeiden käytöstä.', expiration: '1 vuosi', }, sv: { - name: 'Samtycken till kakor', description: 'Webbplatsen använder denna kaka för att lagra information om huruvida besökare har godkänt användningen av kakor eller inte.', expiration: 'Ett år', }, en: { - name: 'Cookie consents', description: 'Used by hel.fi to store information about whether visitors have given or declined the use of cookie categories used on the hel.fi site.', expiration: '1 year', }, }, + helConsentCookieVersion: { + id: 'SET_IN_CODE', + hostName: 'SET_IN_CODE', + commonGroup: 'SET_IN_CODE', + name: 'SET_IN_CODE', + fi: { + description: 'Tähän evästeeseen tallennetaan käyttäjän hyväksymän evästeselosteen versio.', + expiration: '1 vuosi', + }, + sv: { + description: 'Används för att lagra information om versionen av cookies samtycke som användaren har godkänt.', + expiration: 'Ett år', + }, + en: { + description: + 'Used by hel.fi to store information about what version of the cookie consent the user has agreed to.', + expiration: '1 year', + }, + }, cookiehub: { id: 'cookiehub', hostName: 'cookiehub.com', diff --git a/packages/react/src/components/cookieConsent/languageSwitcher/LanguageSwitcherItem/LanguageSwitcherItem.tsx b/packages/react/src/components/cookieConsent/languageSwitcher/LanguageSwitcherItem/LanguageSwitcherItem.tsx index ac24e57f0b..3b7a347826 100644 --- a/packages/react/src/components/cookieConsent/languageSwitcher/LanguageSwitcherItem/LanguageSwitcherItem.tsx +++ b/packages/react/src/components/cookieConsent/languageSwitcher/LanguageSwitcherItem/LanguageSwitcherItem.tsx @@ -1,9 +1,9 @@ import React from 'react'; +import styles from './LanguageSwitcherItem.module.scss'; import classNames from '../../../../utils/classNames'; import { MergeElementProps } from '../../../../common/types'; import { useMobile } from '../../../../hooks/useMobile'; -import styles from './LanguageSwitcherItem.module.scss'; type ItemProps = { /** diff --git a/packages/react/src/components/dateInput/DateInput.tsx b/packages/react/src/components/dateInput/DateInput.tsx index 6816863df3..0bd620d526 100644 --- a/packages/react/src/components/dateInput/DateInput.tsx +++ b/packages/react/src/components/dateInput/DateInput.tsx @@ -1,11 +1,11 @@ import { format, parse, isValid, subYears, addYears, startOfMonth, endOfMonth, max } from 'date-fns'; import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import styles from './DateInput.module.scss'; import { IconCalendar } from '../../icons'; import mergeRefWithInternalRef from '../../utils/mergeRefWithInternalRef'; import { TextInput, TextInputProps } from '../textInput'; import { DatePicker, LegendItem } from './components/datePicker'; -import styles from './DateInput.module.scss'; export type DateInputProps = Omit & { /** diff --git a/packages/react/src/components/dateInput/components/datePicker/DatePicker.tsx b/packages/react/src/components/dateInput/components/datePicker/DatePicker.tsx index 9964b68413..27aed77945 100644 --- a/packages/react/src/components/dateInput/components/datePicker/DatePicker.tsx +++ b/packages/react/src/components/dateInput/components/datePicker/DatePicker.tsx @@ -13,6 +13,7 @@ import finnish from 'date-fns/locale/fi'; import swedish from 'date-fns/locale/sv'; import { Modifier, usePopper } from 'react-popper'; +import styles from './DatePicker.module.scss'; import { defaultProps } from './defaults/defaultProps'; import { DatePickerContext } from '../../context/DatePickerContext'; import { DayPickerProps } from './types'; @@ -20,7 +21,6 @@ import { MonthTable } from '../monthTable'; import { Legend } from '../legend'; import { Button } from '../../../button'; import { IconCheck, IconCross } from '../../../../icons'; -import styles from './DatePicker.module.scss'; import classNames from '../../../../utils/classNames'; import { scrollIntoViewIfNeeded } from '../../../../utils/scrollIntoViewIfNeeded'; diff --git a/packages/react/src/components/dialog/dialogContent/DialogContent.tsx b/packages/react/src/components/dialog/dialogContent/DialogContent.tsx index 8ec3a1492b..be143e57f7 100644 --- a/packages/react/src/components/dialog/dialogContent/DialogContent.tsx +++ b/packages/react/src/components/dialog/dialogContent/DialogContent.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; -import classNames from '../../../utils/classNames'; import styles from './DialogContent.module.scss'; +import classNames from '../../../utils/classNames'; import { DialogContext } from '../DialogContext'; export type DialogContentProps = React.PropsWithChildren<{ diff --git a/packages/react/src/components/dialog/dialogHeader/DialogHeader.tsx b/packages/react/src/components/dialog/dialogHeader/DialogHeader.tsx index 31bf089d3a..24cf481001 100644 --- a/packages/react/src/components/dialog/dialogHeader/DialogHeader.tsx +++ b/packages/react/src/components/dialog/dialogHeader/DialogHeader.tsx @@ -1,7 +1,7 @@ import React, { RefObject, useContext, useEffect } from 'react'; -import { IconCross } from '../../../icons'; import styles from './DialogHeader.module.scss'; +import { IconCross } from '../../../icons'; import { DialogContext } from '../DialogContext'; export type DialogHeaderProps = { diff --git a/packages/react/src/components/dropdown/combobox/Combobox.tsx b/packages/react/src/components/dropdown/combobox/Combobox.tsx index e62573ec57..838a9ef529 100644 --- a/packages/react/src/components/dropdown/combobox/Combobox.tsx +++ b/packages/react/src/components/dropdown/combobox/Combobox.tsx @@ -72,7 +72,9 @@ function getDefaultFilter(labelField: string): FilterFunction