-
Notifications
You must be signed in to change notification settings - Fork 1
Allow user to configure light/dark mode in setting; cache accepted terms #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0691902
1a46d32
4ef5493
5dee41d
f8dbf52
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { TestBed } from '@angular/core/testing' | ||
| import { ConfirmationService } from '../confirmation' | ||
| import { TermsService } from './terms.service' | ||
|
|
||
| const TEST_EMAIL = 'test@example.com' | ||
| const ACCEPTED_AT_KEY = `nlrTermsAcceptedAt:${TEST_EMAIL}` | ||
| const ACCEPTANCE_DAYS = 90 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. keep accepted terms for 90 days |
||
| const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000 | ||
|
|
||
| describe('TermsService', () => { | ||
| let service: TermsService | ||
|
|
||
| beforeEach(() => { | ||
| TestBed.configureTestingModule({ | ||
| providers: [{ provide: ConfirmationService, useValue: { open: jasmine.createSpy('open') } }], | ||
| }) | ||
| service = TestBed.inject(TermsService) | ||
| localStorage.removeItem(ACCEPTED_AT_KEY) | ||
| jasmine.clock().install() | ||
| jasmine.clock().mockDate(new Date('2026-01-01T00:00:00Z')) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| localStorage.removeItem(ACCEPTED_AT_KEY) | ||
| jasmine.clock().uninstall() | ||
| }) | ||
|
|
||
| it('records acceptance in browser storage', () => { | ||
| service.recordTermsAcceptance(TEST_EMAIL) | ||
|
|
||
| expect(localStorage.getItem(ACCEPTED_AT_KEY)).toBe(Date.now().toString()) | ||
| expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeTrue() | ||
| }) | ||
|
|
||
| it('keeps acceptance valid for less than 90 days', () => { | ||
| localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() - ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY + 1).toString()) | ||
|
|
||
| expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeTrue() | ||
| }) | ||
|
|
||
| it('expires acceptance after 90 days', () => { | ||
| localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() - ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY).toString()) | ||
|
|
||
| expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse() | ||
| }) | ||
|
|
||
| it('rejects missing, malformed, and future acceptance dates', () => { | ||
| expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse() | ||
|
|
||
| localStorage.setItem(ACCEPTED_AT_KEY, 'not-a-date') | ||
| expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse() | ||
|
|
||
| localStorage.setItem(ACCEPTED_AT_KEY, (Date.now() + 1).toString()) | ||
| expect(service.hasAcceptedTerms(TEST_EMAIL)).toBeFalse() | ||
| }) | ||
|
|
||
| it('does not share acceptance between different accounts', () => { | ||
| service.recordTermsAcceptance(TEST_EMAIL) | ||
|
|
||
| expect(service.hasAcceptedTerms('other@example.com')).toBeFalse() | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,32 @@ | ||
| import { inject, Injectable } from '@angular/core' | ||
| import { ConfirmationService } from '../confirmation' | ||
|
|
||
| const NLR_TERMS_ACCEPTED_AT_KEY = 'nlrTermsAcceptedAt' | ||
| const NLR_TERMS_ACCEPTANCE_DAYS = 90 | ||
| const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000 | ||
|
Comment on lines
+4
to
+6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in the latest commit. |
||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class TermsService { | ||
| private _confirmationService = inject(ConfirmationService) | ||
|
|
||
| hasAcceptedTerms(email: string): boolean { | ||
| const key = `${NLR_TERMS_ACCEPTED_AT_KEY}:${email.toLowerCase().trim()}` | ||
| const acceptedAt = Number(localStorage.getItem(key)) | ||
| const acceptanceAge = Date.now() - acceptedAt | ||
|
|
||
| return ( | ||
| Number.isFinite(acceptedAt) | ||
| && acceptedAt > 0 | ||
| && acceptanceAge >= 0 | ||
| && acceptanceAge < NLR_TERMS_ACCEPTANCE_DAYS * MILLISECONDS_PER_DAY | ||
| ) | ||
| } | ||
|
|
||
| recordTermsAcceptance(email: string): void { | ||
| const key = `${NLR_TERMS_ACCEPTED_AT_KEY}:${email.toLowerCase().trim()}` | ||
| localStorage.setItem(key, Date.now().toString()) | ||
| } | ||
|
|
||
| showTermsOfService(): void { | ||
| this._confirmationService.open({ | ||
| title: 'DOE Standard Energy Efficiency Data Platform | NLR Data Terms', | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import type { ComponentFixture } from '@angular/core/testing' | ||
| import { TestBed } from '@angular/core/testing' | ||
| import { ActivatedRoute, Router } from '@angular/router' | ||
| import { of } from 'rxjs' | ||
| import { ConfigService } from '@seed/api' | ||
| import { TermsService } from '@seed/services' | ||
| import { AuthService } from 'app/core/auth/auth.service' | ||
| import { AuthSignInComponent } from './sign-in.component' | ||
|
|
||
| describe('AuthSignInComponent', () => { | ||
| let fixture: ComponentFixture<AuthSignInComponent> | ||
| let component: AuthSignInComponent | ||
| let hasAcceptedTerms: jasmine.Spy | ||
| let recordTermsAcceptance: jasmine.Spy | ||
| let signIn: jasmine.Spy | ||
| let navigateByUrl: jasmine.Spy | ||
|
|
||
| beforeEach(async () => { | ||
| hasAcceptedTerms = jasmine.createSpy('hasAcceptedTerms').and.returnValue(false) | ||
| recordTermsAcceptance = jasmine.createSpy('recordTermsAcceptance') | ||
| signIn = jasmine.createSpy('signIn').and.returnValue(of({ access: 'access', refresh: 'refresh' })) | ||
| navigateByUrl = jasmine.createSpy('navigateByUrl').and.returnValue(Promise.resolve(true)) | ||
|
|
||
| await TestBed.configureTestingModule({ | ||
| imports: [AuthSignInComponent], | ||
| providers: [ | ||
| { provide: ActivatedRoute, useValue: { snapshot: { queryParamMap: { get: () => null } } } }, | ||
| { provide: Router, useValue: { navigateByUrl } }, | ||
| { provide: ConfigService, useValue: { config$: of({ allow_signup: false }) } }, | ||
| { provide: AuthService, useValue: { signIn } }, | ||
| { | ||
| provide: TermsService, | ||
| useValue: { hasAcceptedTerms, recordTermsAcceptance, showTermsOfService: jasmine.createSpy('showTermsOfService') }, | ||
| }, | ||
| ], | ||
| }) | ||
| .overrideComponent(AuthSignInComponent, { set: { template: '' } }) | ||
| .compileComponents() | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| fixture?.destroy() | ||
| }) | ||
|
|
||
| function createComponent(): void { | ||
| fixture = TestBed.createComponent(AuthSignInComponent) | ||
| component = fixture.componentInstance | ||
| fixture.detectChanges() | ||
| } | ||
|
|
||
| it('requires terms acceptance when the cached acceptance has expired', () => { | ||
| createComponent() | ||
| component.signInForm.patchValue({ email: 'user@example.com', password: 'password' }) | ||
|
|
||
| expect(component.termsPreviouslyAccepted).toBeFalse() | ||
| expect(component.signInForm.invalid).toBeTrue() | ||
|
|
||
| component.signInForm.controls.terms.setValue(true) | ||
| expect(component.signInForm.valid).toBeTrue() | ||
| }) | ||
|
|
||
| it('uses a current cached acceptance without recording a new timestamp', () => { | ||
| hasAcceptedTerms.and.returnValue(true) | ||
| createComponent() | ||
| component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true }) | ||
|
|
||
| component.signIn() | ||
|
|
||
| expect(recordTermsAcceptance).not.toHaveBeenCalled() | ||
| expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect') | ||
| }) | ||
|
|
||
| it('records a new acceptance after sign-in succeeds', () => { | ||
| createComponent() | ||
| component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true }) | ||
|
|
||
| component.signIn() | ||
|
|
||
| expect(recordTermsAcceptance).toHaveBeenCalledTimes(1) | ||
| expect(component.termsPreviouslyAccepted).toBeTrue() | ||
| expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect') | ||
| }) | ||
|
|
||
| it('waits for successful two-factor verification before recording acceptance', () => { | ||
| signIn.and.returnValues(of({ two_factor_required: true, two_factor_method: 'email' }), of({ access: 'access', refresh: 'refresh' })) | ||
| createComponent() | ||
| component.signInForm.setValue({ email: 'user@example.com', password: 'password', terms: true }) | ||
|
|
||
| component.signIn() | ||
| expect(recordTermsAcceptance).not.toHaveBeenCalled() | ||
|
|
||
| component.otpForm.controls.otp_token.setValue('123456') | ||
| component.submitOtp() | ||
|
|
||
| expect(recordTermsAcceptance).toHaveBeenCalledTimes(1) | ||
| expect(navigateByUrl).toHaveBeenCalledOnceWith('/signed-in-redirect') | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.