From 9a08e5edf6d4fa9a4ae6d8c53617738a9d6025b2 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Tue, 26 May 2026 20:51:09 +0200 Subject: [PATCH 1/6] [STEP-2.15-001] Add CookiesModule types, interfaces and config token Define public contracts for EXTENDED module #20 (cookies): - CookieCategory type (essential/analytics/preferences + extensible) - CookieOptions interface (per-cookie set options with GDPR category) - ConsentPreferences interface (category consent map, essential always true) - CookieModuleOptions interface (provideCookies input) - CookieConfig interface (resolved config with defaults) - COOKIE_CONFIG InjectionToken --- packages/core/src/lib/cookies/cookie.types.ts | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 packages/core/src/lib/cookies/cookie.types.ts diff --git a/packages/core/src/lib/cookies/cookie.types.ts b/packages/core/src/lib/cookies/cookie.types.ts new file mode 100644 index 0000000..9922aaa --- /dev/null +++ b/packages/core/src/lib/cookies/cookie.types.ts @@ -0,0 +1,225 @@ +import { InjectionToken } from '@angular/core'; + +// --------------------------------------------------------------------------- +// Cookie category +// --------------------------------------------------------------------------- + +/** + * Category of a browser cookie for GDPR/ePrivacy classification. + * + * The three built-in categories are: + * - `'essential'` — Required for the application to function (always allowed). + * - `'analytics'` — Used for usage tracking and statistics. + * - `'preferences'` — Stores user preferences and functional settings. + * + * Products may define additional custom categories (e.g. `'marketing'`, + * `'social-media'`). Any `string` is accepted to support extensibility. + * + * @example + * ```typescript + * const category: CookieCategory = 'analytics'; + * const custom: CookieCategory = 'marketing'; // also valid + * ``` + */ +export type CookieCategory = 'essential' | 'analytics' | 'preferences' | (string & {}); + +// --------------------------------------------------------------------------- +// Cookie options (per-cookie) +// --------------------------------------------------------------------------- + +/** + * Options for setting an individual cookie via `CookieService.set()`. + * + * @example + * ```typescript + * cookieService.set('theme', 'dark', { + * path: '/', + * secure: true, + * sameSite: 'Lax', + * maxAgeDays: 30, + * category: 'preferences', + * }); + * ``` + */ +export interface CookieOptions { + /** Cookie path. Defaults to `'/'`. */ + readonly path?: string; + + /** Cookie domain. When omitted, uses the current domain. */ + readonly domain?: string; + + /** Number of days until the cookie expires. When omitted, creates a session cookie. */ + readonly maxAgeDays?: number; + + /** Whether to set the `Secure` attribute. Defaults to `CookieConfig.forceSecure`. */ + readonly secure?: boolean; + + /** + * `SameSite` attribute value. + * + * - `'Strict'` — Cookie is only sent in first-party context. + * - `'Lax'` — Cookie is sent with top-level navigations (default). + * - `'None'` — Cookie is sent in all contexts (requires `secure: true`). + */ + readonly sameSite?: 'Strict' | 'Lax' | 'None'; + + /** + * GDPR category of this cookie. Used by `CookieService` to enforce + * consent before writing. + * + * - `'essential'` or omitted → always written (no consent required). + * - Any other category → only written if the user has consented. + */ + readonly category?: CookieCategory; +} + +// --------------------------------------------------------------------------- +// Consent preferences +// --------------------------------------------------------------------------- + +/** + * User's cookie consent preferences by category. + * + * Maps each `CookieCategory` to a boolean indicating whether the + * user has accepted that category. The `essential` category is + * always `true` and cannot be changed by the user. + * + * @example + * ```typescript + * const prefs: ConsentPreferences = { + * essential: true, // always true — cannot be disabled + * analytics: false, // user has not consented + * preferences: true, // user has consented + * }; + * ``` + */ +export interface ConsentPreferences { + /** Essential cookies are always allowed. This field is always `true`. */ + readonly essential: true; + + /** Whether the user has consented to analytics cookies. */ + readonly analytics: boolean; + + /** Whether the user has consented to preferences cookies. */ + readonly preferences: boolean; + + /** Support for custom categories defined by the product. */ + readonly [category: string]: boolean; +} + +// --------------------------------------------------------------------------- +// Module configuration (user-facing) +// --------------------------------------------------------------------------- + +/** + * Configuration input for `provideCookies()`. + * + * All fields are optional — defaults provide a GDPR-compliant + * configuration for EU products. + * + * @example + * ```typescript + * // EU product with analytics + * provideCookies({ + * consent: true, + * categories: ['essential', 'analytics', 'preferences'], + * }) + * + * // Product without GDPR requirement + * provideCookies({ consent: false }) + * ``` + */ +export interface CookieModuleOptions { + /** + * Enable GDPR consent management. + * + * When `true`, `CookieConsentService` is registered and + * `CookieService.set()` enforces consent before writing + * non-essential cookies. + * + * When `false`, only `CookieService` is registered (no consent UI, + * no enforcement). Defaults to `true`. + */ + readonly consent?: boolean; + + /** + * Name of the cookie used to persist the user's consent preferences. + * Defaults to `'ff_cookie_consent'`. + */ + readonly consentCookieName?: string; + + /** + * Number of days the consent cookie remains valid. + * After expiration the user must re-consent. Defaults to `365`. + */ + readonly consentMaxAgeDays?: number; + + /** + * Cookie categories enabled for this product. + * Defaults to `['essential', 'analytics', 'preferences']`. + * + * `'essential'` is always implicitly included even if omitted. + */ + readonly categories?: readonly CookieCategory[]; + + /** + * Cookie domain for cross-subdomain support (embedded mode). + * When omitted, uses the current domain. + */ + readonly domain?: string; + + /** + * Force `Secure` attribute on all cookies. + * Defaults to `true` (recommended for production). + */ + readonly forceSecure?: boolean; +} + +// --------------------------------------------------------------------------- +// Resolved config (internal) +// --------------------------------------------------------------------------- + +/** + * Resolved configuration stored in the DI container. + * + * Built from `CookieModuleOptions` by `provideCookies()` with + * all defaults applied. Services inject this via `COOKIE_CONFIG`. + */ +export interface CookieConfig { + /** Whether consent management is active. */ + readonly consent: boolean; + + /** Name of the consent persistence cookie. */ + readonly consentCookieName: string; + + /** Validity of the consent cookie in days. */ + readonly consentMaxAgeDays: number; + + /** Active cookie categories. Always includes `'essential'`. */ + readonly categories: readonly CookieCategory[]; + + /** Cookie domain (undefined = current domain). */ + readonly domain: string | undefined; + + /** Whether `Secure` is forced on all cookies. */ + readonly forceSecure: boolean; +} + +// --------------------------------------------------------------------------- +// Injection token +// --------------------------------------------------------------------------- + +/** + * Injection token for the cookies module configuration. + * + * Provided by `provideCookies()`. Services inject this to read config. + * + * @example + * ```typescript + * const config = inject(COOKIE_CONFIG); + * console.log(config.consent); // true + * ``` + */ +export const COOKIE_CONFIG = new InjectionToken( + 'COOKIE_CONFIG', +); From 114e5be88e806327c1b2ba334049b7e8173e0cf4 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Tue, 26 May 2026 22:17:28 +0200 Subject: [PATCH 2/6] [STEP-2.15-002] Add CookieConsentService with GDPR consent management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement signal-based consent service with 6 public methods: - consentGiven() / preferences() as reactive Signal reads - acceptAll() / rejectAll() / updatePreferences() for mutations - isCategoryAllowed() returns computed Signal per category Persists preferences in dedicated cookie (ff_cookie_consent) via direct document.cookie access (no CookieService dependency — D4). 24 tests covering all methods, persistence and edge cases. --- .../cookies/cookie-consent.service.spec.ts | 335 ++++++++++++++++++ .../src/lib/cookies/cookie-consent.service.ts | 227 ++++++++++++ 2 files changed, 562 insertions(+) create mode 100644 packages/core/src/lib/cookies/cookie-consent.service.spec.ts create mode 100644 packages/core/src/lib/cookies/cookie-consent.service.ts diff --git a/packages/core/src/lib/cookies/cookie-consent.service.spec.ts b/packages/core/src/lib/cookies/cookie-consent.service.spec.ts new file mode 100644 index 0000000..01e6508 --- /dev/null +++ b/packages/core/src/lib/cookies/cookie-consent.service.spec.ts @@ -0,0 +1,335 @@ +import { TestBed } from '@angular/core/testing'; + +import { CookieConsentService } from './cookie-consent.service'; +import type { CookieConfig, ConsentPreferences } from './cookie.types'; +import { COOKIE_CONFIG } from './cookie.types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DEFAULT_CONFIG: CookieConfig = { + consent: true, + consentCookieName: 'ff_cookie_consent', + consentMaxAgeDays: 365, + categories: ['essential', 'analytics', 'preferences'], + domain: undefined, + forceSecure: true, +}; + +/** + * Mock `document.cookie` with a simple in-memory store. + * + * Returns a `cookies` map and a `restore` function to clean up. + */ +function mockDocumentCookie() { + const cookies = new Map(); + + const originalDescriptor = Object.getOwnPropertyDescriptor( + Document.prototype, + 'cookie', + ); + + Object.defineProperty(document, 'cookie', { + configurable: true, + get() { + return Array.from(cookies.entries()) + .map(([k, v]) => `${k}=${v}`) + .join('; '); + }, + set(value: string) { + const [pair] = value.split(';'); + const eqIndex = pair.indexOf('='); + const name = pair.substring(0, eqIndex); + const val = pair.substring(eqIndex + 1); + + // Handle max-age=0 (delete) + if (value.includes('max-age=0')) { + cookies.delete(name); + return; + } + cookies.set(name, val); + }, + }); + + return { + cookies, + restore() { + if (originalDescriptor) { + Object.defineProperty(document, 'cookie', originalDescriptor); + } + }, + }; +} + +function setup( + configOverrides?: Partial, +) { + const config = { ...DEFAULT_CONFIG, ...configOverrides }; + TestBed.configureTestingModule({ + providers: [ + CookieConsentService, + { provide: COOKIE_CONFIG, useValue: config }, + ], + }); + return TestBed.inject(CookieConsentService); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('CookieConsentService', () => { + let cookieMock: ReturnType; + + beforeEach(() => { + cookieMock = mockDocumentCookie(); + }); + + afterEach(() => { + cookieMock.restore(); + }); + + // ----------------------------------------------------------------------- + // Initial state (no prior consent) + // ----------------------------------------------------------------------- + describe('initial state (no prior consent)', () => { + it('should report consentGiven as false', () => { + const service = setup(); + expect(service.consentGiven()()).toBe(false); + }); + + it('should have essential=true and others=false in preferences', () => { + const service = setup(); + const prefs = service.preferences()(); + expect(prefs.essential).toBe(true); + expect(prefs.analytics).toBe(false); + expect(prefs.preferences).toBe(false); + }); + + it('should report essential as always allowed', () => { + const service = setup(); + expect(service.isCategoryAllowed('essential')()).toBe(true); + }); + + it('should report analytics as not allowed', () => { + const service = setup(); + expect(service.isCategoryAllowed('analytics')()).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // acceptAll + // ----------------------------------------------------------------------- + describe('acceptAll', () => { + it('should set all categories to true', () => { + const service = setup(); + service.acceptAll(); + + const prefs = service.preferences()(); + expect(prefs.essential).toBe(true); + expect(prefs.analytics).toBe(true); + expect(prefs.preferences).toBe(true); + }); + + it('should set consentGiven to true', () => { + const service = setup(); + service.acceptAll(); + expect(service.consentGiven()()).toBe(true); + }); + + it('should persist preferences to cookie', () => { + const service = setup(); + service.acceptAll(); + + const raw = cookieMock.cookies.get('ff_cookie_consent'); + expect(raw).toBeDefined(); + const parsed = JSON.parse(decodeURIComponent(raw!)); + expect(parsed.essential).toBe(true); + expect(parsed.analytics).toBe(true); + expect(parsed.preferences).toBe(true); + }); + + it('should update isCategoryAllowed signals reactively', () => { + const service = setup(); + const analytics = service.isCategoryAllowed('analytics'); + expect(analytics()).toBe(false); + + service.acceptAll(); + expect(analytics()).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // rejectAll + // ----------------------------------------------------------------------- + describe('rejectAll', () => { + it('should set only essential to true, others to false', () => { + const service = setup(); + // First accept, then reject + service.acceptAll(); + service.rejectAll(); + + const prefs = service.preferences()(); + expect(prefs.essential).toBe(true); + expect(prefs.analytics).toBe(false); + expect(prefs.preferences).toBe(false); + }); + + it('should set consentGiven to true (user has responded)', () => { + const service = setup(); + service.rejectAll(); + expect(service.consentGiven()()).toBe(true); + }); + + it('should persist to cookie', () => { + const service = setup(); + service.rejectAll(); + + const raw = cookieMock.cookies.get('ff_cookie_consent'); + expect(raw).toBeDefined(); + const parsed = JSON.parse(decodeURIComponent(raw!)); + expect(parsed.analytics).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // updatePreferences + // ----------------------------------------------------------------------- + describe('updatePreferences', () => { + it('should merge partial preferences with current state', () => { + const service = setup(); + service.updatePreferences({ analytics: true }); + + const prefs = service.preferences()(); + expect(prefs.analytics).toBe(true); + expect(prefs.preferences).toBe(false); // unchanged + }); + + it('should always keep essential=true even if explicitly set to false', () => { + const service = setup(); + service.updatePreferences({ essential: false } as Partial); + + expect(service.preferences()().essential).toBe(true); + }); + + it('should set consentGiven to true', () => { + const service = setup(); + service.updatePreferences({ analytics: true }); + expect(service.consentGiven()()).toBe(true); + }); + + it('should support custom categories', () => { + const service = setup({ + categories: ['essential', 'analytics', 'preferences', 'marketing'], + }); + service.updatePreferences({ marketing: true }); + + expect(service.preferences()().marketing).toBe(true); + }); + + it('should persist updated preferences to cookie', () => { + const service = setup(); + service.updatePreferences({ analytics: true, preferences: true }); + + const raw = cookieMock.cookies.get('ff_cookie_consent'); + const parsed = JSON.parse(decodeURIComponent(raw!)); + expect(parsed.analytics).toBe(true); + expect(parsed.preferences).toBe(true); + }); + }); + + // ----------------------------------------------------------------------- + // isCategoryAllowed + // ----------------------------------------------------------------------- + describe('isCategoryAllowed', () => { + it('should always return true for essential', () => { + const service = setup(); + const sig = service.isCategoryAllowed('essential'); + expect(sig()).toBe(true); + + service.rejectAll(); + expect(sig()).toBe(true); + }); + + it('should return a reactive signal for non-essential categories', () => { + const service = setup(); + const analytics = service.isCategoryAllowed('analytics'); + + expect(analytics()).toBe(false); + service.acceptAll(); + expect(analytics()).toBe(true); + service.rejectAll(); + expect(analytics()).toBe(false); + }); + + it('should return false for unknown categories not yet set', () => { + const service = setup(); + const custom = service.isCategoryAllowed('social-media'); + expect(custom()).toBe(false); + }); + }); + + // ----------------------------------------------------------------------- + // Persistence (load from existing cookie) + // ----------------------------------------------------------------------- + describe('persistence', () => { + it('should load preferences from existing cookie on init', () => { + const prefs: ConsentPreferences = { + essential: true, + analytics: true, + preferences: false, + }; + cookieMock.cookies.set( + 'ff_cookie_consent', + encodeURIComponent(JSON.stringify(prefs)), + ); + + const service = setup(); + expect(service.consentGiven()()).toBe(true); + expect(service.preferences()().analytics).toBe(true); + expect(service.preferences()().preferences).toBe(false); + }); + + it('should handle corrupted cookie data gracefully', () => { + cookieMock.cookies.set('ff_cookie_consent', 'not-valid-json'); + + const service = setup(); + expect(service.consentGiven()()).toBe(true); // cookie exists + expect(service.preferences()().essential).toBe(true); + expect(service.preferences()().analytics).toBe(false); // fallback + }); + + it('should handle cookie with missing essential field gracefully', () => { + cookieMock.cookies.set( + 'ff_cookie_consent', + encodeURIComponent(JSON.stringify({ analytics: true })), + ); + + const service = setup(); + // parsed.essential !== true, so deserialize returns null → defaults + expect(service.preferences()().essential).toBe(true); + expect(service.preferences()().analytics).toBe(false); // defaults + }); + }); + + // ----------------------------------------------------------------------- + // Custom cookie name + // ----------------------------------------------------------------------- + describe('custom config', () => { + it('should use custom consent cookie name', () => { + const service = setup({ consentCookieName: 'my_consent' }); + service.acceptAll(); + + expect(cookieMock.cookies.has('my_consent')).toBe(true); + expect(cookieMock.cookies.has('ff_cookie_consent')).toBe(false); + }); + + it('should include domain in cookie when configured', () => { + // We can verify the cookie was written (domain is part of Set-Cookie, not readable) + const service = setup({ domain: '.example.com' }); + service.acceptAll(); + expect(cookieMock.cookies.has('ff_cookie_consent')).toBe(true); + }); + }); +}); diff --git a/packages/core/src/lib/cookies/cookie-consent.service.ts b/packages/core/src/lib/cookies/cookie-consent.service.ts new file mode 100644 index 0000000..566694e --- /dev/null +++ b/packages/core/src/lib/cookies/cookie-consent.service.ts @@ -0,0 +1,227 @@ +import { computed, inject, Injectable, Signal, signal } from '@angular/core'; + +import type { ConsentPreferences, CookieCategory, CookieConfig } from './cookie.types'; +import { COOKIE_CONFIG } from './cookie.types'; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Serialize consent preferences to a cookie-safe JSON string. */ +function serializePreferences(prefs: ConsentPreferences): string { + return JSON.stringify(prefs); +} + +/** Deserialize consent preferences from the cookie value. Returns `null` if invalid. */ +function deserializePreferences(raw: string): ConsentPreferences | null { + try { + const parsed = JSON.parse(raw); + if (typeof parsed === 'object' && parsed !== null && parsed.essential === true) { + return parsed as ConsentPreferences; + } + return null; + } catch { + return null; + } +} + +/** Read a cookie by name from `document.cookie`. */ +function readCookie(name: string): string | null { + const match = document.cookie + .split('; ') + .find((row) => row.startsWith(`${name}=`)); + return match ? decodeURIComponent(match.split('=').slice(1).join('=')) : null; +} + +/** Write a cookie with the given name, value, maxAge in days, and optional domain. */ +function writeCookie( + name: string, + value: string, + maxAgeDays: number, + domain: string | undefined, +): void { + const maxAge = maxAgeDays * 24 * 60 * 60; + let cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax`; + if (domain) { + cookie += `; domain=${domain}`; + } + document.cookie = cookie; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * Manages GDPR/ePrivacy cookie consent with reactive signals. + * + * Persists the user's consent preferences in a dedicated first-party + * cookie (configurable name, default `ff_cookie_consent`). On creation, + * loads any existing preferences from that cookie. + * + * **Important:** This service operates directly on `document.cookie` + * for its own persistence — it does **not** depend on `CookieService` + * to avoid circular dependencies (see plan decision D4). + * + * @example + * ```typescript + * const consent = inject(CookieConsentService); + * + * // Check if user has given consent + * if (!consent.consentGiven()) { + * showConsentBanner(); + * } + * + * // Accept all categories + * consent.acceptAll(); + * + * // Check a specific category reactively + * const analyticsAllowed = consent.isCategoryAllowed('analytics'); + * ``` + */ +@Injectable() +export class CookieConsentService { + private readonly config = inject(COOKIE_CONFIG); + + /** Internal writable signal holding the current preferences. */ + private readonly _preferences = signal( + this.loadFromCookie(), + ); + + /** Internal writable signal tracking whether the user has responded. */ + private readonly _consentGiven = signal( + this.hasExistingConsent(), + ); + + // ----------------------------------------------------------------------- + // Public API — read-only signals + // ----------------------------------------------------------------------- + + /** + * Whether the user has given any consent response (accept/reject/custom). + * + * Returns `false` until the user interacts with the consent UI. + * After the user responds, returns `true` (even if they rejected all). + */ + consentGiven(): Signal { + return this._consentGiven.asReadonly(); + } + + /** + * The user's current consent preferences by category. + * + * `essential` is always `true`. Other categories reflect the user's + * choices. Changes are emitted reactively when any preference updates. + */ + preferences(): Signal { + return this._preferences.asReadonly(); + } + + // ----------------------------------------------------------------------- + // Public API — mutations + // ----------------------------------------------------------------------- + + /** + * Accept all configured categories. + * + * Sets every category in `CookieConfig.categories` to `true` + * and persists the result. + */ + acceptAll(): void { + const prefs = this.buildPreferences(true); + this.applyAndPersist(prefs); + } + + /** + * Reject all non-essential categories. + * + * Sets every category except `'essential'` to `false` + * and persists the result. + */ + rejectAll(): void { + const prefs = this.buildPreferences(false); + this.applyAndPersist(prefs); + } + + /** + * Update individual category preferences. + * + * Merges the provided partial preferences with the current state. + * The `essential` category is always forced to `true`. + * + * @param partial - Categories to update (e.g. `{ analytics: true }`) + */ + updatePreferences(partial: Partial): void { + const current = this._preferences(); + const merged: ConsentPreferences = { + ...current, + ...partial, + essential: true, // always enforce + }; + this.applyAndPersist(merged); + } + + /** + * Get a reactive signal indicating whether a specific category is allowed. + * + * The `'essential'` category always returns a signal that is `true`. + * Other categories return a computed signal derived from `preferences()`. + * + * @param category - The cookie category to check + * @returns A `Signal` that updates when preferences change + */ + isCategoryAllowed(category: CookieCategory): Signal { + if (category === 'essential') { + return signal(true).asReadonly(); + } + return computed(() => this._preferences()[category] === true); + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + /** Apply preferences to signals and persist to cookie. */ + private applyAndPersist(prefs: ConsentPreferences): void { + this._preferences.set(prefs); + this._consentGiven.set(true); + this.saveToCookie(prefs); + } + + /** Build a full ConsentPreferences object with all categories set to `value` (except essential=true). */ + private buildPreferences(value: boolean): ConsentPreferences { + const prefs: Record = { essential: true }; + for (const cat of this.config.categories) { + prefs[cat] = cat === 'essential' ? true : value; + } + return prefs as ConsentPreferences; + } + + /** Load preferences from the consent cookie, or return defaults (all false except essential). */ + private loadFromCookie(): ConsentPreferences { + const raw = readCookie(this.config.consentCookieName); + if (raw) { + const parsed = deserializePreferences(raw); + if (parsed) { + return parsed; + } + } + // Default: essential true, everything else false + return this.buildPreferences(false); + } + + /** Check whether a consent cookie already exists. */ + private hasExistingConsent(): boolean { + return readCookie(this.config.consentCookieName) !== null; + } + + /** Persist preferences to the consent cookie. */ + private saveToCookie(prefs: ConsentPreferences): void { + writeCookie( + this.config.consentCookieName, + serializePreferences(prefs), + this.config.consentMaxAgeDays, + this.config.domain, + ); + } +} From 7091259212f39d2e1180b79d471f507eebc1a609 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Tue, 26 May 2026 22:26:33 +0200 Subject: [PATCH 3/6] [STEP-2.15-003] Add CookieService with typed CRUD and consent enforcement Implement CookieService with get/set/delete/getAll methods wrapping document.cookie. Optionally injects CookieConsentService to enforce GDPR consent before writing non-essential cookies. 34 tests covering basic operations, consent enforcement, and consent-disabled mode. --- .../src/lib/cookies/cookie.service.spec.ts | 431 ++++++++++++++++++ .../core/src/lib/cookies/cookie.service.ts | 173 +++++++ 2 files changed, 604 insertions(+) create mode 100644 packages/core/src/lib/cookies/cookie.service.spec.ts create mode 100644 packages/core/src/lib/cookies/cookie.service.ts diff --git a/packages/core/src/lib/cookies/cookie.service.spec.ts b/packages/core/src/lib/cookies/cookie.service.spec.ts new file mode 100644 index 0000000..9396400 --- /dev/null +++ b/packages/core/src/lib/cookies/cookie.service.spec.ts @@ -0,0 +1,431 @@ +import { TestBed } from '@angular/core/testing'; +import { vi } from 'vitest'; + +import { CookieConsentService } from './cookie-consent.service'; +import { CookieService } from './cookie.service'; +import type { CookieConfig } from './cookie.types'; +import { COOKIE_CONFIG } from './cookie.types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DEFAULT_CONFIG: CookieConfig = { + consent: true, + consentCookieName: 'ff_cookie_consent', + consentMaxAgeDays: 365, + categories: ['essential', 'analytics', 'preferences'], + domain: undefined, + forceSecure: true, +}; + +/** + * Mock `document.cookie` with a simple in-memory store. + * + * Returns a `cookies` map, a `written` array (raw strings) and a `restore` function. + */ +function mockDocumentCookie() { + const cookies = new Map(); + const written: string[] = []; + + const originalDescriptor = Object.getOwnPropertyDescriptor( + Document.prototype, + 'cookie', + ); + + Object.defineProperty(document, 'cookie', { + configurable: true, + get() { + return Array.from(cookies.entries()) + .map(([k, v]) => `${k}=${v}`) + .join('; '); + }, + set(value: string) { + written.push(value); + const [pair] = value.split(';'); + const eqIndex = pair.indexOf('='); + const name = pair.substring(0, eqIndex); + const val = pair.substring(eqIndex + 1); + + // Handle max-age=0 (delete) + if (value.includes('max-age=0')) { + cookies.delete(name); + return; + } + cookies.set(name, val); + }, + }); + + return { + cookies, + written, + restore() { + if (originalDescriptor) { + Object.defineProperty(document, 'cookie', originalDescriptor); + } + }, + }; +} + +/** Setup TestBed with CookieService and optionally CookieConsentService. */ +function setup(options?: { + configOverrides?: Partial; + withConsent?: boolean; +}) { + const config = { ...DEFAULT_CONFIG, ...options?.configOverrides }; + const providers: unknown[] = [ + CookieService, + { provide: COOKIE_CONFIG, useValue: config }, + ]; + + if (options?.withConsent !== false) { + providers.push(CookieConsentService); + } + + TestBed.configureTestingModule({ providers }); + + const service = TestBed.inject(CookieService); + const consent = + options?.withConsent !== false + ? TestBed.inject(CookieConsentService) + : null; + + return { service, consent, config }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('CookieService', () => { + let cookieMock: ReturnType; + + beforeEach(() => { + cookieMock = mockDocumentCookie(); + }); + + afterEach(() => { + cookieMock.restore(); + }); + + // ----------------------------------------------------------------------- + // get() + // ----------------------------------------------------------------------- + describe('get', () => { + it('should return null when cookie does not exist', () => { + const { service } = setup(); + expect(service.get('nonexistent')).toBeNull(); + }); + + it('should return the cookie value when it exists', () => { + cookieMock.cookies.set('theme', 'dark'); + const { service } = setup(); + expect(service.get('theme')).toBe('dark'); + }); + + it('should decode URI-encoded values', () => { + cookieMock.cookies.set('data', encodeURIComponent('hello world')); + const { service } = setup(); + expect(service.get('data')).toBe('hello world'); + }); + + it('should handle cookies with = in the value', () => { + cookieMock.cookies.set('token', 'abc=def=ghi'); + const { service } = setup(); + expect(service.get('token')).toBe('abc=def=ghi'); + }); + + it('should not match partial cookie names', () => { + cookieMock.cookies.set('theme_v2', 'blue'); + const { service } = setup(); + expect(service.get('theme')).toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // set() — basic writing + // ----------------------------------------------------------------------- + describe('set (basic)', () => { + it('should write a cookie with default path and SameSite', () => { + const { service } = setup(); + service.set('theme', 'dark'); + + expect(cookieMock.cookies.has('theme')).toBe(true); + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('path=/'); + expect(raw).toContain('SameSite=Lax'); + }); + + it('should URI-encode the value', () => { + const { service } = setup(); + service.set('msg', 'hello world'); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('msg=hello%20world'); + }); + + it('should set custom path', () => { + const { service } = setup(); + service.set('x', '1', { path: '/app' }); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('path=/app'); + }); + + it('should set domain from options', () => { + const { service } = setup(); + service.set('x', '1', { domain: '.example.com' }); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('domain=.example.com'); + }); + + it('should use config domain when no option domain is provided', () => { + const { service } = setup({ + configOverrides: { domain: '.mysite.com' }, + }); + service.set('x', '1'); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('domain=.mysite.com'); + }); + + it('should set max-age from maxAgeDays', () => { + const { service } = setup(); + service.set('x', '1', { maxAgeDays: 7 }); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + const expectedSeconds = 7 * 24 * 60 * 60; + expect(raw).toContain(`max-age=${expectedSeconds}`); + }); + + it('should set Secure when forceSecure is true in config', () => { + const { service } = setup({ configOverrides: { forceSecure: true } }); + service.set('x', '1'); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('Secure'); + }); + + it('should not set Secure when forceSecure is false and no option', () => { + const { service } = setup({ configOverrides: { forceSecure: false } }); + service.set('x', '1'); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).not.toContain('Secure'); + }); + + it('should override config forceSecure with option secure=false', () => { + const { service } = setup({ configOverrides: { forceSecure: true } }); + service.set('x', '1', { secure: false }); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).not.toContain('Secure'); + }); + + it('should set SameSite=Strict when specified', () => { + const { service } = setup(); + service.set('x', '1', { sameSite: 'Strict' }); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('SameSite=Strict'); + }); + + it('should set SameSite=None when specified', () => { + const { service } = setup(); + service.set('x', '1', { sameSite: 'None' }); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('SameSite=None'); + }); + }); + + // ----------------------------------------------------------------------- + // set() — consent enforcement + // ----------------------------------------------------------------------- + describe('set (consent enforcement)', () => { + it('should allow writing cookies with no category', () => { + const { service } = setup(); + service.set('generic', 'value'); + + expect(cookieMock.cookies.has('generic')).toBe(true); + }); + + it('should allow writing essential cookies', () => { + const { service } = setup(); + service.set('session', 'abc', { category: 'essential' }); + + expect(cookieMock.cookies.has('session')).toBe(true); + }); + + it('should block non-essential cookies when consent not given', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(); + const { service } = setup(); + + service.set('tracker', 'xyz', { category: 'analytics' }); + + expect(cookieMock.cookies.has('tracker')).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Blocked'), + ); + warnSpy.mockRestore(); + }); + + it('should allow non-essential cookies after consent is given', () => { + const { service, consent } = setup(); + consent!.acceptAll(); + + service.set('tracker', 'xyz', { category: 'analytics' }); + + expect(cookieMock.cookies.has('tracker')).toBe(true); + }); + + it('should block specific category when only others are consented', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(); + const { service, consent } = setup(); + consent!.updatePreferences({ analytics: true }); + + service.set('pref_cookie', 'val', { category: 'preferences' }); + + expect(cookieMock.cookies.has('pref_cookie')).toBe(false); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('should allow specific category after it is consented', () => { + const { service, consent } = setup(); + consent!.updatePreferences({ preferences: true }); + + service.set('pref_cookie', 'val', { category: 'preferences' }); + + expect(cookieMock.cookies.has('pref_cookie')).toBe(true); + }); + + it('should include category name in warning message', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(); + const { service } = setup(); + + service.set('x', '1', { category: 'analytics' }); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('analytics'), + ); + warnSpy.mockRestore(); + }); + }); + + // ----------------------------------------------------------------------- + // set() — no consent service (consent disabled) + // ----------------------------------------------------------------------- + describe('set (consent disabled)', () => { + it('should allow all cookies when CookieConsentService is not registered', () => { + const { service } = setup({ withConsent: false }); + + service.set('tracker', 'xyz', { category: 'analytics' }); + + expect(cookieMock.cookies.has('tracker')).toBe(true); + }); + + it('should not emit warnings when consent service is absent', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(); + const { service } = setup({ withConsent: false }); + + service.set('tracker', 'xyz', { category: 'analytics' }); + + expect(warnSpy).not.toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + // ----------------------------------------------------------------------- + // delete() + // ----------------------------------------------------------------------- + describe('delete', () => { + it('should remove a cookie by setting max-age=0', () => { + cookieMock.cookies.set('theme', 'dark'); + const { service } = setup(); + + service.delete('theme'); + + expect(cookieMock.cookies.has('theme')).toBe(false); + }); + + it('should include default path in delete', () => { + cookieMock.cookies.set('x', '1'); + const { service } = setup(); + + service.delete('x'); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('path=/'); + expect(raw).toContain('max-age=0'); + }); + + it('should use custom path when provided', () => { + cookieMock.cookies.set('x', '1'); + const { service } = setup(); + + service.delete('x', { path: '/admin' }); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('path=/admin'); + }); + + it('should include domain when provided', () => { + cookieMock.cookies.set('x', '1'); + const { service } = setup(); + + service.delete('x', { domain: '.example.com' }); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('domain=.example.com'); + }); + + it('should use config domain when no option domain is provided', () => { + cookieMock.cookies.set('x', '1'); + const { service } = setup({ + configOverrides: { domain: '.mysite.com' }, + }); + + service.delete('x'); + + const raw = cookieMock.written[cookieMock.written.length - 1]; + expect(raw).toContain('domain=.mysite.com'); + }); + }); + + // ----------------------------------------------------------------------- + // getAll() + // ----------------------------------------------------------------------- + describe('getAll', () => { + it('should return empty object when no cookies exist', () => { + const { service } = setup(); + expect(service.getAll()).toEqual({}); + }); + + it('should return all cookies as key-value pairs', () => { + cookieMock.cookies.set('a', '1'); + cookieMock.cookies.set('b', '2'); + const { service } = setup(); + + const all = service.getAll(); + expect(all).toEqual({ a: '1', b: '2' }); + }); + + it('should decode URI-encoded values', () => { + cookieMock.cookies.set('msg', encodeURIComponent('hello world')); + const { service } = setup(); + + const all = service.getAll(); + expect(all['msg']).toBe('hello world'); + }); + + it('should handle cookies with = in the value', () => { + cookieMock.cookies.set('token', 'abc=def'); + const { service } = setup(); + + const all = service.getAll(); + expect(all['token']).toBe('abc=def'); + }); + }); +}); diff --git a/packages/core/src/lib/cookies/cookie.service.ts b/packages/core/src/lib/cookies/cookie.service.ts new file mode 100644 index 0000000..fd516c0 --- /dev/null +++ b/packages/core/src/lib/cookies/cookie.service.ts @@ -0,0 +1,173 @@ +import { inject, Injectable } from '@angular/core'; + +import { CookieConsentService } from './cookie-consent.service'; +import type { CookieConfig, CookieOptions } from './cookie.types'; +import { COOKIE_CONFIG } from './cookie.types'; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** + * Typed CRUD service for browser cookies. + * + * Wraps `document.cookie` with a modern API and enforces GDPR consent + * when `CookieConsentService` is available (i.e. when `provideCookies()` + * was called with `consent: true`, which is the default). + * + * **Consent enforcement rules:** + * - Cookies with `category: 'essential'` or no category → always written. + * - Cookies with any other category → only written if the user has + * consented to that category. If not consented, a `console.warn` is + * emitted and the cookie is **not** written. + * - If `CookieConsentService` is not registered (consent disabled), + * all cookies are written without restriction. + * + * @example + * ```typescript + * const cookies = inject(CookieService); + * + * // Read + * const theme = cookies.get('theme'); // string | null + * + * // Write with options + * cookies.set('theme', 'dark', { + * path: '/', + * maxAgeDays: 30, + * category: 'preferences', + * }); + * + * // Delete + * cookies.delete('theme'); + * + * // Read all + * const all = cookies.getAll(); // Record + * ``` + */ +@Injectable() +export class CookieService { + private readonly config = inject(COOKIE_CONFIG); + private readonly consent = inject(CookieConsentService, { optional: true }); + + /** + * Read a cookie by name. + * + * @param name - Cookie name + * @returns The cookie value, or `null` if the cookie does not exist. + */ + get(name: string): string | null { + const match = document.cookie + .split('; ') + .find((row) => row.startsWith(`${name}=`)); + return match + ? decodeURIComponent(match.split('=').slice(1).join('=')) + : null; + } + + /** + * Set a cookie with optional attributes. + * + * When consent management is active and the cookie has a non-essential + * category, the write is blocked if the user has not consented to that + * category. A `console.warn` is emitted in that case. + * + * @param name - Cookie name + * @param value - Cookie value (will be URI-encoded) + * @param options - Optional cookie attributes + */ + set(name: string, value: string, options?: CookieOptions): void { + if (!this.isAllowed(options?.category)) { + console.warn( + `[CookieService] Blocked: cookie "${name}" requires consent for category "${options!.category}".`, + ); + return; + } + + const parts: string[] = [ + `${name}=${encodeURIComponent(value)}`, + ]; + + const path = options?.path ?? '/'; + parts.push(`path=${path}`); + + const domain = options?.domain ?? this.config.domain; + if (domain) { + parts.push(`domain=${domain}`); + } + + if (options?.maxAgeDays !== undefined) { + parts.push(`max-age=${options.maxAgeDays * 24 * 60 * 60}`); + } + + const secure = options?.secure ?? this.config.forceSecure; + if (secure) { + parts.push('Secure'); + } + + const sameSite = options?.sameSite ?? 'Lax'; + parts.push(`SameSite=${sameSite}`); + + document.cookie = parts.join('; '); + } + + /** + * Delete a cookie by setting its `max-age` to `0`. + * + * @param name - Cookie name to delete + * @param options - Optional path/domain to match the original cookie + */ + delete(name: string, options?: Pick): void { + const path = options?.path ?? '/'; + const domain = options?.domain ?? this.config.domain; + + let cookie = `${name}=; path=${path}; max-age=0`; + if (domain) { + cookie += `; domain=${domain}`; + } + document.cookie = cookie; + } + + /** + * Read all cookies as a key-value record. + * + * @returns An object where keys are cookie names and values are + * decoded cookie values. Returns an empty object if no + * cookies exist. + */ + getAll(): Record { + const raw = document.cookie; + if (!raw) { + return {}; + } + const result: Record = {}; + for (const pair of raw.split('; ')) { + const eqIndex = pair.indexOf('='); + if (eqIndex === -1) continue; + const key = pair.substring(0, eqIndex); + const val = pair.substring(eqIndex + 1); + result[key] = decodeURIComponent(val); + } + return result; + } + + // ----------------------------------------------------------------------- + // Private + // ----------------------------------------------------------------------- + + /** + * Check whether a cookie with the given category is allowed to be written. + * + * - No category or `'essential'` → always allowed. + * - Any other category → only if consent service says so. + * - No consent service registered → always allowed. + */ + private isAllowed(category: string | undefined): boolean { + if (!category || category === 'essential') { + return true; + } + if (!this.consent) { + return true; + } + return this.consent.isCategoryAllowed(category)(); + } +} From be505f0ae6c56a2e052d21b8d82241a66c082996 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Tue, 26 May 2026 22:35:28 +0200 Subject: [PATCH 4/6] [STEP-2.15-004] Add FfCookieConsentDirective structural directive Implement *ffCookieConsent structural directive that conditionally renders content based on GDPR cookie consent for a given category. Reactive to consent changes via Angular signals and effect(). First standalone structural directive in the framework. 7 tests covering initial render, reactive updates, category switching, and essential. --- .../cookies/cookie-consent.directive.spec.ts | 221 ++++++++++++++++++ .../lib/cookies/cookie-consent.directive.ts | 62 +++++ 2 files changed, 283 insertions(+) create mode 100644 packages/core/src/lib/cookies/cookie-consent.directive.spec.ts create mode 100644 packages/core/src/lib/cookies/cookie-consent.directive.ts diff --git a/packages/core/src/lib/cookies/cookie-consent.directive.spec.ts b/packages/core/src/lib/cookies/cookie-consent.directive.spec.ts new file mode 100644 index 0000000..f7a0cf4 --- /dev/null +++ b/packages/core/src/lib/cookies/cookie-consent.directive.spec.ts @@ -0,0 +1,221 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { CookieConsentService } from './cookie-consent.service'; +import { FfCookieConsentDirective } from './cookie-consent.directive'; +import type { CookieConfig } from './cookie.types'; +import { COOKIE_CONFIG } from './cookie.types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DEFAULT_CONFIG: CookieConfig = { + consent: true, + consentCookieName: 'ff_cookie_consent', + consentMaxAgeDays: 365, + categories: ['essential', 'analytics', 'preferences'], + domain: undefined, + forceSecure: true, +}; + +function mockDocumentCookie() { + const cookies = new Map(); + + const originalDescriptor = Object.getOwnPropertyDescriptor( + Document.prototype, + 'cookie', + ); + + Object.defineProperty(document, 'cookie', { + configurable: true, + get() { + return Array.from(cookies.entries()) + .map(([k, v]) => `${k}=${v}`) + .join('; '); + }, + set(value: string) { + const [pair] = value.split(';'); + const eqIndex = pair.indexOf('='); + const name = pair.substring(0, eqIndex); + const val = pair.substring(eqIndex + 1); + + if (value.includes('max-age=0')) { + cookies.delete(name); + return; + } + cookies.set(name, val); + }, + }); + + return { + cookies, + restore() { + if (originalDescriptor) { + Object.defineProperty(document, 'cookie', originalDescriptor); + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Test host components +// --------------------------------------------------------------------------- + +@Component({ + standalone: true, + imports: [FfCookieConsentDirective], + template: ` +
+ Visible content +
+ `, +}) +class TestHostComponent { + category = signal('analytics'); +} + +@Component({ + standalone: true, + imports: [FfCookieConsentDirective], + template: ` +

+ Essential content +

+ `, +}) +class EssentialHostComponent {} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FfCookieConsentDirective', () => { + let cookieMock: ReturnType; + + beforeEach(() => { + cookieMock = mockDocumentCookie(); + }); + + afterEach(() => { + cookieMock.restore(); + }); + + function setup(hostComponent: new (...args: unknown[]) => T) { + TestBed.configureTestingModule({ + imports: [hostComponent], + providers: [ + CookieConsentService, + { provide: COOKIE_CONFIG, useValue: DEFAULT_CONFIG }, + ], + }); + + const fixture = TestBed.createComponent(hostComponent); + const consent = TestBed.inject(CookieConsentService); + fixture.detectChanges(); + return { fixture, consent }; + } + + // ----------------------------------------------------------------------- + // Initial rendering + // ----------------------------------------------------------------------- + describe('initial rendering', () => { + it('should NOT render when category is not consented', () => { + const { fixture } = setup(TestHostComponent); + + const el = fixture.nativeElement.querySelector('[data-testid="consent-content"]'); + expect(el).toBeNull(); + }); + + it('should render when category is essential (always allowed)', () => { + const { fixture } = setup(EssentialHostComponent); + + const el = fixture.nativeElement.querySelector('[data-testid="essential-content"]'); + expect(el).not.toBeNull(); + expect(el.textContent).toContain('Essential content'); + }); + }); + + // ----------------------------------------------------------------------- + // Reactive updates + // ----------------------------------------------------------------------- + describe('reactive updates', () => { + it('should render after consent is given', () => { + const { fixture, consent } = setup(TestHostComponent); + + // Initially not rendered + expect(fixture.nativeElement.querySelector('[data-testid="consent-content"]')).toBeNull(); + + // Accept all + consent.acceptAll(); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('[data-testid="consent-content"]'); + expect(el).not.toBeNull(); + expect(el.textContent).toContain('Visible content'); + }); + + it('should remove content when consent is revoked', () => { + const { fixture, consent } = setup(TestHostComponent); + + // Accept, then reject + consent.acceptAll(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="consent-content"]')).not.toBeNull(); + + consent.rejectAll(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="consent-content"]')).toBeNull(); + }); + + it('should react to partial consent updates', () => { + const { fixture, consent } = setup(TestHostComponent); + + // Only consent to analytics + consent.updatePreferences({ analytics: true }); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="consent-content"]')).not.toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Category switching + // ----------------------------------------------------------------------- + describe('category switching', () => { + it('should react when input category changes', () => { + const { fixture, consent } = setup(TestHostComponent); + const host = fixture.componentInstance; + + // Consent to analytics but not preferences + consent.updatePreferences({ analytics: true }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="consent-content"]')).not.toBeNull(); + + // Switch to preferences category (not consented) + host.category.set('preferences'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="consent-content"]')).toBeNull(); + + // Consent to preferences + consent.updatePreferences({ preferences: true }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('[data-testid="consent-content"]')).not.toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Essential category + // ----------------------------------------------------------------------- + describe('essential category', () => { + it('should always render for essential even after rejectAll', () => { + const { fixture, consent } = setup(EssentialHostComponent); + + consent.rejectAll(); + fixture.detectChanges(); + + const el = fixture.nativeElement.querySelector('[data-testid="essential-content"]'); + expect(el).not.toBeNull(); + }); + }); +}); diff --git a/packages/core/src/lib/cookies/cookie-consent.directive.ts b/packages/core/src/lib/cookies/cookie-consent.directive.ts new file mode 100644 index 0000000..c74cb70 --- /dev/null +++ b/packages/core/src/lib/cookies/cookie-consent.directive.ts @@ -0,0 +1,62 @@ +import { + Directive, + effect, + inject, + input, + TemplateRef, + ViewContainerRef, +} from '@angular/core'; + +import { CookieConsentService } from './cookie-consent.service'; +import type { CookieCategory } from './cookie.types'; + +/** + * Structural directive that conditionally renders content based on + * cookie consent for a given category. + * + * When the user has consented to the specified category, the template + * is rendered. When consent is revoked (or not yet given), the template + * is removed. The directive reacts to consent changes in real time + * via Angular signals. + * + * @example + * ```html + *
+ * + * + *
+ * + * + *

Preference-dependent content

+ *
+ * ``` + */ +@Directive({ + selector: '[ffCookieConsent]', + standalone: true, +}) +export class FfCookieConsentDirective { + private readonly consent = inject(CookieConsentService); + private readonly templateRef = inject(TemplateRef); + private readonly viewContainer = inject(ViewContainerRef); + + /** The cookie category to check consent for. */ + readonly ffCookieConsent = input.required(); + + private hasView = false; + + constructor() { + effect(() => { + const category = this.ffCookieConsent(); + const allowed = this.consent.isCategoryAllowed(category)(); + + if (allowed && !this.hasView) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.hasView = true; + } else if (!allowed && this.hasView) { + this.viewContainer.clear(); + this.hasView = false; + } + }); + } +} From 2188bd100fcd6949352efd6f8acd14d457bbd5c4 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Tue, 26 May 2026 22:48:51 +0200 Subject: [PATCH 5/6] [STEP-2.15-005] Add provideCookies() factory, barrel exports and main re-export Create provideCookies(options?) provider factory with conditional CookieConsentService registration. Add cookies/index.ts barrel exporting 10 public symbols. Re-export from src/index.ts. --- packages/core/src/index.ts | 1 + packages/core/src/lib/cookies/index.ts | 19 ++++ .../core/src/lib/cookies/provide-cookies.ts | 92 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 packages/core/src/lib/cookies/index.ts create mode 100644 packages/core/src/lib/cookies/provide-cookies.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7ae47a7..b7166ad 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,3 +16,4 @@ export * from './lib/security'; export * from './lib/files'; export * from './lib/notifications'; export * from './lib/event-bus'; +export * from './lib/cookies'; diff --git a/packages/core/src/lib/cookies/index.ts b/packages/core/src/lib/cookies/index.ts new file mode 100644 index 0000000..fe8346e --- /dev/null +++ b/packages/core/src/lib/cookies/index.ts @@ -0,0 +1,19 @@ +// Types +export type { + CookieCategory, + CookieOptions, + ConsentPreferences, + CookieModuleOptions, + CookieConfig, +} from './cookie.types'; +export { COOKIE_CONFIG } from './cookie.types'; + +// Provider factory +export { provideCookies } from './provide-cookies'; + +// Services +export { CookieService } from './cookie.service'; +export { CookieConsentService } from './cookie-consent.service'; + +// Directives +export { FfCookieConsentDirective } from './cookie-consent.directive'; diff --git a/packages/core/src/lib/cookies/provide-cookies.ts b/packages/core/src/lib/cookies/provide-cookies.ts new file mode 100644 index 0000000..785054f --- /dev/null +++ b/packages/core/src/lib/cookies/provide-cookies.ts @@ -0,0 +1,92 @@ +import type { Provider } from '@angular/core'; +import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core'; + +import { CookieConsentService } from './cookie-consent.service'; +import { CookieService } from './cookie.service'; +import type { CookieConfig, CookieModuleOptions } from './cookie.types'; +import { COOKIE_CONFIG } from './cookie.types'; + +/** Default configuration values. */ +const DEFAULTS: CookieConfig = { + consent: true, + consentCookieName: 'ff_cookie_consent', + consentMaxAgeDays: 365, + categories: ['essential', 'analytics', 'preferences'], + domain: undefined, + forceSecure: true, +}; + +/** + * Configure the Cookies module. + * + * Registers `CookieService` and, when consent is enabled (default), + * also `CookieConsentService` for GDPR consent management. + * + * This is an **EXTENDED** module — call `provideCookies()` in your + * `appConfig` providers to enable typed cookie management with + * optional GDPR consent enforcement. + * + * @example + * ```ts + * import { provideCookies } from '@fireflyframework/core'; + * + * // With consent (default — GDPR-compliant) + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideCookies(), + * ], + * }; + * + * // Without consent management + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideCookies({ consent: false }), + * ], + * }; + * + * // Custom categories + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideCookies({ + * categories: ['essential', 'analytics', 'preferences', 'marketing'], + * consentMaxAgeDays: 180, + * }), + * ], + * }; + * ``` + * + * @param options - Optional configuration. See `CookieModuleOptions` for details. + * @returns EnvironmentProviders to register in the application config + */ +export function provideCookies( + options?: CookieModuleOptions, +): EnvironmentProviders { + const consent = options?.consent ?? DEFAULTS.consent; + + const categories = options?.categories ?? DEFAULTS.categories; + const normalizedCategories = categories.includes('essential') + ? categories + : ['essential', ...categories]; + + const config: CookieConfig = { + consent, + consentCookieName: + options?.consentCookieName ?? DEFAULTS.consentCookieName, + consentMaxAgeDays: + options?.consentMaxAgeDays ?? DEFAULTS.consentMaxAgeDays, + categories: normalizedCategories, + domain: options?.domain ?? DEFAULTS.domain, + forceSecure: options?.forceSecure ?? DEFAULTS.forceSecure, + }; + + const providers: (Provider | EnvironmentProviders)[] = [ + { provide: COOKIE_CONFIG, useValue: config }, + CookieService, + ]; + + if (consent) { + providers.push(CookieConsentService); + } + + return makeEnvironmentProviders(providers); +} From fd28052c82b316beaa3a96395ad1679dd6763578 Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Tue, 26 May 2026 22:50:40 +0200 Subject: [PATCH 6/6] [STEP-2.15-006] Bump core version to 0.16.0 with CookiesModule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Version bump 0.15.0 → 0.16.0 in package.json - Add CHANGELOG entry for 0.16.0 documenting CookiesModule (EXTENDED #20) - Update compare links for [Unreleased] and [0.16.0] --- packages/core/CHANGELOG.md | 15 ++++++++++++++- packages/core/package.json | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index bb4b1af..ae60603 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.16.0] - 2026-05-26 + +### Added +- Cookies module (EXTENDED #20): `provideCookies(options?)` factory with `COOKIE_CONFIG` injection token +- `CookieService` — typed CRUD for browser cookies: `get()`, `set()`, `delete()`, `getAll()` with automatic URI encoding +- `CookieConsentService` — GDPR/ePrivacy consent management with signal-based reactive state +- Consent API: `acceptAll()`, `rejectAll()`, `updatePreferences()`, `isCategoryAllowed()`, `consentGiven()`, `preferences()` +- `FfCookieConsentDirective` — structural directive `*ffCookieConsent="'category'"` for conditional rendering based on consent +- Consent enforcement: `CookieService.set()` checks category consent before writing non-essential cookies +- Configurable categories: `essential` (always allowed), `analytics`, `preferences`, plus custom categories via `(string & {})` +- Types: `CookieCategory`, `CookieOptions`, `ConsentPreferences`, `CookieModuleOptions`, `CookieConfig` + ## [0.15.0] - 2026-05-23 ### Added @@ -224,7 +236,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - User context module: `UserContextService` - API runtime module: `ApiClient`, `TransportRegistry`, `HttpTransportAdapter` -[Unreleased]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.15.0...HEAD +[Unreleased]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.16.0...HEAD +[0.16.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.15.0...core@0.16.0 [0.15.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.14.0...core@0.15.0 [0.14.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.13.0...core@0.14.0 [0.13.0]: https://github.com/fireflyframework/firefly-frontend-framework/compare/core@0.12.0...core@0.13.0 diff --git a/packages/core/package.json b/packages/core/package.json index 327fac3..13c148d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@fireflyframework/core", - "version": "0.15.0", + "version": "0.16.0", "publishConfig": { "registry": "https://npm.pkg.github.com" },