Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fireflyframework/core",
"version": "0.15.0",
"version": "0.16.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
},
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
221 changes: 221 additions & 0 deletions packages/core/src/lib/cookies/cookie-consent.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, 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) {
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: `
<div *ffCookieConsent="category()" data-testid="consent-content">
Visible content
</div>
`,
})
class TestHostComponent {
category = signal<string>('analytics');
}

@Component({
standalone: true,
imports: [FfCookieConsentDirective],
template: `
<p *ffCookieConsent="'essential'" data-testid="essential-content">
Essential content
</p>
`,
})
class EssentialHostComponent {}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe('FfCookieConsentDirective', () => {
let cookieMock: ReturnType<typeof mockDocumentCookie>;

beforeEach(() => {
cookieMock = mockDocumentCookie();
});

afterEach(() => {
cookieMock.restore();
});

function setup<T>(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();
});
});
});
62 changes: 62 additions & 0 deletions packages/core/src/lib/cookies/cookie-consent.directive.ts
Original file line number Diff line number Diff line change
@@ -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
* <div *ffCookieConsent="'analytics'">
* <!-- Only rendered when analytics cookies are consented -->
* <analytics-widget />
* </div>
*
* <ng-template [ffCookieConsent]="'preferences'">
* <p>Preference-dependent content</p>
* </ng-template>
* ```
*/
@Directive({
selector: '[ffCookieConsent]',
standalone: true,
})
export class FfCookieConsentDirective {
private readonly consent = inject(CookieConsentService);
private readonly templateRef = inject(TemplateRef<unknown>);
private readonly viewContainer = inject(ViewContainerRef);

/** The cookie category to check consent for. */
readonly ffCookieConsent = input.required<CookieCategory>();

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;
}
});
}
}
Loading