From 8afa07eefcc71482e62e32bf3c05292b335bdf90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Thu, 13 Jun 2024 16:34:01 +0200 Subject: [PATCH] refactor: REfactor CookieNotice -> move out document.cookie manipulation, simplify CookieNotice --- src/ts/classes/CookieNotice.ts | 328 ++++++++++++------------------- src/ts/classes/DocumentCookie.ts | 54 +++++ src/ts/type.ts | 2 +- 3 files changed, 180 insertions(+), 204 deletions(-) create mode 100644 src/ts/classes/DocumentCookie.ts diff --git a/src/ts/classes/CookieNotice.ts b/src/ts/classes/CookieNotice.ts index e2fb06c..59e58cc 100644 --- a/src/ts/classes/CookieNotice.ts +++ b/src/ts/classes/CookieNotice.ts @@ -1,33 +1,31 @@ -import { CookieOptions, CookiePolicy, CookieStatusList } from "../type"; +import { CookieOptions, CookiePolicies, CookieStatusList } from "../type"; import { CookiePolicySameSite, CookieStatus } from "./../enums"; +import { DocumentCookie } from "./DocumentCookie"; export default class CookieNotice { /** The CN cookie status */ - browserCookieName = "cn-init"; + private browserCookieName = "cn-init"; /** Cookie life time */ - cookieTimeLife = 6; + private cookieTimeLife = 6; /** The index on which store the expiration date */ - cookieExpirationName = "_expiration"; + private cookieExpirationName = "_expiration"; /** Cookie policies for each functionality */ - cookiePolicies: CookiePolicy = { + private cookiePolicies: CookiePolicies = { sameSite: CookiePolicySameSite.lax, secure: "", }; /** Cookie expiration date as specified in the cookie itself */ - cookieExpirationDate: Date; - - /** Holding all cookie configs */ - config: Record; + private cookieExpirationDate: Date; /** Prevent user from closing modal */ - restrictCloseModal: boolean = true; + private restrictCloseModal: boolean = true; // * -- DOM ELEMENTS - domElList: { + private domElList: { /** Modal transparent background => div.cn-modal-bg */ modalBg?: HTMLDivElement|null, /** Modal window container for the cookie panel and button => div#cn-wrapper */ @@ -58,7 +56,10 @@ export default class CookieNotice { // * -- END DOM ELEMENTS - constructor(config: Record) { + /** + * @param config Holding all cookie configs + */ + constructor(private config: Record) { // * Setup Config this.config = config; @@ -76,7 +77,7 @@ export default class CookieNotice { // * Setup CN Cookie if (!this.cookiesMatchesConfig() || this.cookiesOlderThan(new Date())) { // * Setup Browser Cookie if none. - this.resetBrowserCookie(); + this.resetCnCookie(); this.openCookieModalToBannerPage(); } @@ -105,8 +106,70 @@ export default class CookieNotice { }); } + // * --- PUBLIC --- + + /** Accept a all cookies */ + public acceptAllCookies(): void { + Object.keys(this.config).forEach((cookieName) => this.acceptCookie(cookieName)); + this.closeCookieModal(); + } + + /** Accept a specific cookie */ + public acceptCookie(cookieName: string): void { + const cookieOptions = this.getCookie(cookieName); + this.setCookieStatus(cookieName, CookieStatus.true, cookieOptions); + const cnSwitch = document.getElementById(`${cookieName}-switch`) as HTMLInputElement | null; + const cnSwitchInput = cnSwitch?.querySelector("input") as HTMLInputElement | null; + if (!cnSwitch || !cnSwitchInput) { + return; + } + cnSwitchInput.checked = true; + cnSwitch.ariaChecked = "true"; + } + + /** Deny a all cookies */ + public denyAllCookies(): void { + Object.keys(this.config).forEach((cookieName) => this.denyCookie(cookieName)); + this.closeCookieModal(); + } + + /** Deny a specific cookie */ + public denyCookie(cookieName: string): void { + const cookieOptions = this.getCookie(cookieName); + this.setCookieStatus(cookieName, CookieStatus.false, cookieOptions); + const cnSwitch = document.getElementById(`${cookieName}-switch`) as HTMLInputElement | null; + const cnSwitchInput = cnSwitch?.querySelector("input") as HTMLInputElement | null; + if (!cnSwitch || !cnSwitchInput) { + return; + } + cnSwitchInput.checked = false; + cnSwitch.ariaChecked = "false"; + } + + /** Deny all waiting cookies */ + public denyAllWaitingCookies(): void { + Object.entries(this.getCookieStatusList()).forEach(([cookieName, cookieStatus]) => { + if (cookieStatus === CookieStatus.wait) { + this.setCnCookieStatusFor(cookieName, CookieStatus.false); + return; + } + }); + this.updateRestrictCloseModal(); + } + + /** Get a specific cookie config */ + public getCookie(cookieName: string): CookieOptions { + const cookieOptions = Object.entries(this.config).find((entry) => entry[0] === cookieName)?.[1]; + if (cookieOptions === undefined) { + throw new Error(`Cookie options ${cookieName} not found in list`); + } + return cookieOptions; + } + + // * --- PRIVATE --- + /** Update restrict for closing cookie modal */ - updateRestrictCloseModal(): void { + private updateRestrictCloseModal(): void { const cookieCloseModalBtn = this.getDomEl("cookieCloseModalBtn"), modalBg = this.getDomEl("modalBg"); this.restrictCloseModal = (Object.values(this.getCookieStatusList()).includes(CookieStatus.wait)) ? true : false; @@ -115,8 +178,9 @@ export default class CookieNotice { } /** Get cookies expiration date or null if not defined already */ - readCookiesExpirationDate(): Date | null { - const cookies = this.getBrowserCookieValue() ?? "", + private readCookiesExpirationDate(): Date | null { + /** @var cookies A like analytics=true,youtube=true or undefined */ + const cookies = DocumentCookie.get(this.browserCookieName) ?? "", expirationDateString = cookies.split(",").find((cookieString) => cookieString.startsWith(this.cookieExpirationName)); if (!expirationDateString) { @@ -138,81 +202,24 @@ export default class CookieNotice { * * @param date The limit date for cookies */ - cookiesOlderThan(date: Date): boolean { + private cookiesOlderThan(date: Date): boolean { return this.cookieExpirationDate.getTime() < date.getTime(); } /** Check if the actual cookie matches the config */ - cookiesMatchesConfig(): boolean { + private cookiesMatchesConfig(): boolean { const cookies = this.getCookieStatusList(); return Object.keys(cookies).length === Object.keys(this.config).length; } - /** Accept a all cookies */ - acceptAllCookies(): void { - Object.keys(this.config).forEach((cookieName) => this.acceptCookie(cookieName)); - this.closeCookieModal(); - } - - /** Accept a specific cookie */ - acceptCookie(cookieName: string): void { - const cookieOptions = this.getCookie(cookieName); - this.setCookieStatus(cookieName, CookieStatus.true, cookieOptions); - const cnSwitch = document.getElementById(`${cookieName}-switch`) as HTMLInputElement | null; - const cnSwitchInput = cnSwitch?.querySelector("input") as HTMLInputElement | null; - if (!cnSwitch || !cnSwitchInput) { - return; - } - cnSwitchInput.checked = true; - cnSwitch.ariaChecked = "true"; - } - - /** Deny a all cookies */ - denyAllCookies(): void { - Object.keys(this.config).forEach((cookieName) => this.denyCookie(cookieName)); - this.closeCookieModal(); - } - - /** Deny a specific cookie */ - denyCookie(cookieName: string): void { - const cookieOptions = this.getCookie(cookieName); - this.setCookieStatus(cookieName, CookieStatus.false, cookieOptions); - const cnSwitch = document.getElementById(`${cookieName}-switch`) as HTMLInputElement | null; - const cnSwitchInput = cnSwitch?.querySelector("input") as HTMLInputElement | null; - if (!cnSwitch || !cnSwitchInput) { - return; - } - cnSwitchInput.checked = false; - cnSwitch.ariaChecked = "false"; - } - - /** Deny all waiting cookies */ - denyAllWaitingCookies(): void { - Object.entries(this.getCookieStatusList()).forEach(([cookieName, cookieStatus]) => { - if (cookieStatus === CookieStatus.wait) { - this.setBrowserCookieStatus(cookieName, CookieStatus.false); - return; - } - }); - this.updateRestrictCloseModal(); - } - - /** Get a specific cookie config */ - getCookie(cookieName: string): CookieOptions { - const cookieOptions = Object.entries(this.config).find((entry) => entry[0] === cookieName)?.[1]; - if (cookieOptions === undefined) { - throw new Error(`Cookie options ${cookieName} not found in list`); - } - return cookieOptions; - } - /** * Fetch the cookie status list * * @returns the cookie status list */ - getCookieStatusList(): CookieStatusList { - const cookies = this.getBrowserCookieValue(), + private getCookieStatusList(): CookieStatusList { + /** @var cookies A like analytics=true,youtube=true or undefined */ + const cookies = DocumentCookie.get(this.browserCookieName), statusList: CookieStatusList = {}; if (cookies === undefined) { return statusList; @@ -228,27 +235,13 @@ export default class CookieNotice { return statusList; } - /** - * Check has cookie status - * - * @param key - * @param value - */ - containsCookieStatus(key: string): boolean { - const statusList: CookieStatusList = this.getCookieStatusList(); - return ( - Object.keys(statusList).find((cookieName) => cookieName === key) !== - undefined - ); - } - /** * Get a cookie status * * @param key * @returns */ - getCookieStatus(key: string): CookieStatus { + private getCookieStatus(key: string): CookieStatus { const statusList: CookieStatusList = this.getCookieStatusList(); const cookieIndex = Object.keys(statusList).find((cookieName) => cookieName === key); if (cookieIndex === undefined) { @@ -264,52 +257,25 @@ export default class CookieNotice { * @param status * @param cookieOption */ - setCookieStatus( + private setCookieStatus( cookieName: string, status: CookieStatus, cookieOption: CookieOptions ): void { + this.setCnCookieStatusFor(cookieName, status); if (status === CookieStatus.true) { - this.addCookieStatus(cookieName, cookieOption); + if (cookieOption.script) { + cookieOption.script(); + } return; } if (status === CookieStatus.false) { - this.removeCookieStatus(cookieName, cookieOption); + if (cookieOption.fallback) { + cookieOption.fallback(); + } return; } - this.setBrowserCookieStatus(cookieName, CookieStatus.wait); - } - - /** - * Add a specific cookie and - * change his status to true - * - * @param cookieName - * @param cookieOption - */ - addCookieStatus(cookieName: string, cookieOption: CookieOptions): void { - if (cookieOption.script) { - cookieOption.script(); - } - this.setBrowserCookieStatus(cookieName, CookieStatus.true); - } - - /** - * Remove a specific cookie and - * change his status to false - * - * @param cookieName - * @param cookieOption - */ - removeCookieStatus(cookieName: string, cookieOption: CookieOptions): void { - const cookieNameElement = document.getElementById(cookieName); - if (cookieNameElement) { - cookieNameElement.remove(); - } - if (cookieOption.fallback) { - cookieOption.fallback(); - } - this.setBrowserCookieStatus(cookieName, CookieStatus.false); + this.setCnCookieStatusFor(cookieName, CookieStatus.wait); } /** @@ -318,48 +284,21 @@ export default class CookieNotice { * @param cookieName * @param status */ - setBrowserCookieStatus(cookieName: string, status: CookieStatus): void { + private setCnCookieStatusFor(cookieName: string, status: CookieStatus): void { const statusList: CookieStatusList = this.getCookieStatusList(); // * Force refresh - this.deleteBrowserCookie(); + DocumentCookie.markForDeletion(this.browserCookieName); statusList[cookieName] = status; - this.saveBrowserCookie(statusList); + this.persistCnCookie(statusList); this.updateRestrictCloseModal(); } - /** - * Has CN cokie setup ? - * @returns true if the cookie is setup - */ - hasBrowserCookie(): boolean { - return Boolean(this.getBrowserCookieValue()); - } - - /** - * Get the Ldcweb cookie - * - * @returns A string like analytics=true,youtube=true or undefined - */ - getBrowserCookieValue(): string|undefined { - const matches = document.cookie.match( - new RegExp( - "(?:^|; )" + - this.browserCookieName.replace( - /([.$?*|{}()[\]\\/+^])/g, - "\\$1" - ) + - "=([^;]*)" - ) - ); - return matches ? decodeURIComponent(matches[1]) : undefined; - } - /** * Save cookie content on web browser * * @param list */ - saveBrowserCookie(statusList: CookieStatusList): void { + private persistCnCookie(statusList: CookieStatusList): void { const date = new Date(), content = Object.entries(statusList) .map(([cookieName, cookieStatus]) => { @@ -369,45 +308,28 @@ export default class CookieNotice { this.cookieExpirationDate = new Date(date.setMonth(date.getMonth() + this.cookieTimeLife)); // * Append expiration date content.push(`${this.cookieExpirationName}=${this.cookieExpirationDate.getTime()}`); - document.cookie = `${this.browserCookieName}=${content.join(",")};path=/${ - location.hostname ? `;domain=${location.hostname}` : "" - };expires=${this.cookieExpirationDate.toUTCString()};${this.getCookiePoliciesAsString()}`; - } - /** - * Set an older expire parameter to a specific cookie - */ - deleteBrowserCookie(): void { - if (this.hasBrowserCookie()) { - // * Suppression du cookie et de sa version precedé d'un point - document.cookie = `${this.browserCookieName}=;path=/${ - location.hostname ? `;domain=.${location.hostname}` : "" - };expires=Thu, 01 Jan 1970 00:00:01 GMT;`; - document.cookie = `${this.browserCookieName}=;path=/${ - location.hostname ? `;domain=${location.hostname}` : "" - };expires=Thu, 01 Jan 1970 00:00:01 GMT;`; - } - } - - getCookiePoliciesAsString(): string { - return Object.entries(this.cookiePolicies) - .map(([policyName, policyValue]) => `${policyName}=${policyValue}`) - .join(";"); + DocumentCookie.set( + this.browserCookieName, + content.join(","), + this.cookieExpirationDate, + this.cookiePolicies + ); } /** * Reset Ldc Web CN cookie */ - resetBrowserCookie(): void { + private resetCnCookie(): void { // * Create Cookie as It does not exists. const newCookieList: CookieStatusList = {}; Object.entries(this.config).forEach( ([cookieName]) => (newCookieList[cookieName] = CookieStatus.wait) ); - this.saveBrowserCookie(newCookieList); + this.persistCnCookie(newCookieList); } - // LISTENERS + // * --- LISTENERS --- /** * Add event on a specific element @@ -417,7 +339,7 @@ export default class CookieNotice { * @param funct * @returns */ - addEvent(element: HTMLElement, evnt: string, funct: (e: Event) => void): void { + private addEvent(element: HTMLElement, evnt: string, funct: (e: Event) => void): void { element.addEventListener(evnt, funct.bind(this), false); } @@ -428,7 +350,7 @@ export default class CookieNotice { * @param evnt * @param funct */ - addEventPrevent( + private addEventPrevent( element: HTMLElement, evnt: string, funct: (e: Event) => void @@ -449,7 +371,7 @@ export default class CookieNotice { * @param element A HTMLElement on which the should should be bound. * @param funct A closure for the event hanling. */ - addEventKbdValidate(element: HTMLElement, funct: (e: Event) => void): void { + private addEventKbdValidate(element: HTMLElement, funct: (e: Event) => void): void { this.addEvent(element, "keydown", (e: Event) => { const kbdEvt = e as KeyboardEvent; if ( @@ -466,10 +388,10 @@ export default class CookieNotice { }); } - // COOKIE MODAL + // * --- COOKIE MODAL --- /** Show or hide Cookie modal */ - toggleCookieModal(): void { + private toggleCookieModal(): void { if (!document.body.classList.contains("cn-open")) { this.openCookieModalToBannerPage(); return; @@ -478,13 +400,13 @@ export default class CookieNotice { } /** Open the cookies modal to the banner page */ - openCookieModalToBannerPage() { + private openCookieModalToBannerPage() { this.openCookieModalToCookiePage(); this.toggleCookieModalPages(); } /** Open the cookies modal to the cookies page */ - openCookieModalToCookiePage() { + private openCookieModalToCookiePage() { document.body.classList.add("cn-open"); this.resetCookieModal(); this.getDomEl("bannerContent").classList.add("d-none"); @@ -498,7 +420,7 @@ export default class CookieNotice { } /** Hide the cookies modal */ - closeCookieModal() { + private closeCookieModal() { this.updateRestrictCloseModal(); if (!this.restrictCloseModal) { document.body.classList.remove("cn-open"); @@ -516,7 +438,7 @@ export default class CookieNotice { } /** Setup tab indexes on visible elements */ - setTabIndex() { + private setTabIndex() { // * Prevent focus html tag document.documentElement.setAttribute("tabindex", "-1"); setTimeout(() => { @@ -557,7 +479,7 @@ export default class CookieNotice { } /** Undo setup tab indexes on visible elements */ - unsetTabIndex() { + private unsetTabIndex() { document.documentElement.setAttribute("tabindex", "0"); setTimeout(() => { document.body.querySelectorAll(".cn-disabled").forEach((el) => { @@ -569,7 +491,7 @@ export default class CookieNotice { } /** Reset the modal view state */ - resetCookieModal() { + private resetCookieModal() { const customizeBtn = this.getDomEl("customizeBtn"); this.getDomEl("bannerContent").classList.remove("d-none"); this.getDomEl("modalContainer").classList.add("d-none"); @@ -581,7 +503,7 @@ export default class CookieNotice { } /** Set the cookies list in the modal */ - setupCookieModalList() { + private setupCookieModalList() { this.getDomEl("cookiesUnorderedList").innerHTML = ""; Object.entries(this.config).forEach(([cookieName, cookieConfig]) => { const cookie = document.createElement("li"); @@ -639,7 +561,7 @@ export default class CookieNotice { this.getDomEl("cookiesUnorderedList").append(cookie); - if (!this.hasBrowserCookie()) { + if (!DocumentCookie.has(this.browserCookieName)) { // * Ldcweb cookie is not set. return; } @@ -667,7 +589,7 @@ export default class CookieNotice { }); } - switchToModalPageOrClose(): void { + private switchToModalPageOrClose(): void { // * Modification du comportement du bouton, même si ca n'a pas de sens. // https://lab.s-web.fr/ldc-web/projets/ldc-web-cookie-notice/-/issues/9` @@ -680,7 +602,7 @@ export default class CookieNotice { } /** Toggle Cookie modal pages */ - toggleCookieModalPages(): void { + private toggleCookieModalPages(): void { this.getDomEl("bannerContent").classList.toggle("d-none"); this.getDomEl("modalContainer").classList.toggle("d-none"); this.getDomEl("bannerContainer").classList.toggle("cn-modal-page"); @@ -694,7 +616,7 @@ export default class CookieNotice { } /** Toggle customize btn */ - toggleCustomizeBtn(): void { + private toggleCustomizeBtn(): void { const customizeBtn = this.getDomEl("customizeBtn"), customizeBtnBannerDataset = this.getDomEl("customizeBtn").dataset; @@ -711,7 +633,7 @@ export default class CookieNotice { } /** Get the actual page: banner or modal (with cookie choices) */ - isOnBannerPage(): boolean { + private isOnBannerPage(): boolean { return this.getDomEl("modalContainer").classList.contains("d-none"); } diff --git a/src/ts/classes/DocumentCookie.ts b/src/ts/classes/DocumentCookie.ts new file mode 100644 index 0000000..5511d7a --- /dev/null +++ b/src/ts/classes/DocumentCookie.ts @@ -0,0 +1,54 @@ +import { CookiePolicies } from "../type"; + +export abstract class DocumentCookie { + + public static has(cookieName: string): boolean { + return this.getCookies().has(cookieName); + } + + public static get(cookieName: string): string|undefined { + return this.getCookies().get(cookieName); + } + + public static set( + cookieName: string, + value: string, + date: Date | null = null, + cookiePolicies: CookiePolicies | null = null + ): void { + let cookieValue = `${cookieName}=${value};path=/${ + location.hostname ? `;domain=${location.hostname}` : "" + }`; + if (date) { + cookieValue += `;${date.toUTCString()}`; + } + if (cookiePolicies) { + cookieValue += `;${Object.entries(cookiePolicies) + .map(([policyName, policyValue]) => `${policyName}=${policyValue}`) + .join(";")}`; + } + document.cookie = cookieValue; + } + + /** + * Set an older expire parameter to a specific cookie + */ + public static markForDeletion(cookieName: string): void { + if (DocumentCookie.has(cookieName)) { + console.debug(`No document cookie named ${cookieName}`); + } + // * Suppression du cookie et de sa version precedé d'un point + document.cookie = `${cookieName}=;path=/${ + location.hostname ? `;domain=.${location.hostname}` : "" + };expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + document.cookie = `${cookieName}=;path=/${ + location.hostname ? `;domain=${location.hostname}` : "" + };expires=Thu, 01 Jan 1970 00:00:01 GMT;`; + } + + /** Fetch browser cookies except(http-only ones) the cannot be handled using js */ + private static getCookies(): Map { + return new Map(Object.fromEntries(document.cookie.split("; ") + .map(c => c.split("=")).filter(c => c[1] !== undefined))); + } +} diff --git a/src/ts/type.ts b/src/ts/type.ts index ad8d06b..092e411 100644 --- a/src/ts/type.ts +++ b/src/ts/type.ts @@ -2,7 +2,7 @@ import type { CookiePolicySameSite, CookieStatus } from "./enums"; export type CookieStatusList = Record; -export type CookiePolicy = { +export type CookiePolicies = { sameSite: CookiePolicySameSite; secure: string; };