diff --git a/docs/accessibility/LANGUAGE_DETECTION_IMPLEMENTATION.md b/docs/accessibility/LANGUAGE_DETECTION_IMPLEMENTATION.md index 46733622c..20c94967d 100644 --- a/docs/accessibility/LANGUAGE_DETECTION_IMPLEMENTATION.md +++ b/docs/accessibility/LANGUAGE_DETECTION_IMPLEMENTATION.md @@ -504,3 +504,5 @@ After initial implementation: **Target Completion:** Within 1 week **Priority:** HIGH - Blocks full WCAG 2.2 Level AA compliance + + diff --git a/docs/accessibility/TESTING_SUMMARY_Nov2025.md b/docs/accessibility/TESTING_SUMMARY_Nov2025.md index f9a53d5f5..87ca35144 100644 --- a/docs/accessibility/TESTING_SUMMARY_Nov2025.md +++ b/docs/accessibility/TESTING_SUMMARY_Nov2025.md @@ -189,3 +189,6 @@ The following require manual/interactive testing: - **Accessibility Colors**: Fixed colors use darker shades specifically for accessibility-critical navigation elements, independent of admin-set primary colors - **Browser Compatibility**: Fallbacks ensure compatibility with browsers that don't support `color-mix()` + + + diff --git a/docs/accessibility/WCAG_2.2_VPAT.pdf b/docs/accessibility/WCAG_2.2_VPAT.pdf new file mode 100644 index 000000000..b633877da Binary files /dev/null and b/docs/accessibility/WCAG_2.2_VPAT.pdf differ diff --git a/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.scss b/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.scss index 2d6572e78..5511f3533 100644 --- a/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.scss +++ b/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.scss @@ -23,11 +23,21 @@ .headline-6 { font-size: 19px !important; } + + &.directlink { + text-align: center; + margin: 0 auto 12px; + max-width: 520px; + width: 100%; + } } .div-after-logo { &.directlink { - margin: 0 16px 0px !important; + margin: 0 auto !important; + max-width: 520px; + padding: 0 16px; + text-align: center; .continue-btn { max-width: 470px; @@ -38,7 +48,9 @@ .tc-container{ margin-bottom: 65px !important; + text-align: center; } .password-container { margin-bottom: 50px !important; + text-align: center; } diff --git a/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.spec.ts b/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.spec.ts index f6f83b5f7..fbd0df7a5 100644 --- a/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.spec.ts +++ b/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.spec.ts @@ -1,70 +1,441 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { AuthRegistrationComponent } from './auth-registration.component'; -import { AuthService } from '@v3/app/services/auth.service'; -import { BrowserStorageService } from '@v3/app/services/storage.service'; -import { ExperienceService } from '@v3/app/services/experience.service'; +import { AuthService } from '@v3/services/auth.service'; +import { BrowserStorageService } from '@v3/services/storage.service'; +import { ExperienceService } from '@v3/services/experience.service'; +import { NotificationsService } from '@v3/services/notifications.service'; +import { UtilsService } from '@v3/services/utils.service'; +import { ModalController, IonicModule } from '@ionic/angular'; +import { ActivatedRoute } from '@angular/router'; +import { ReactiveFormsModule } from '@angular/forms'; import { of, throwError } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; describe('AuthRegistrationComponent', () => { let component: AuthRegistrationComponent; let fixture: ComponentFixture; - let authService: AuthService; - let storageService: BrowserStorageService; - let experienceService: ExperienceService; + let authService: jasmine.SpyObj; + let storageService: jasmine.SpyObj; + let experienceService: jasmine.SpyObj; + let notificationsService: jasmine.SpyObj; + let utilsService: jasmine.SpyObj; + let modalController: jasmine.SpyObj; + let activatedRoute: any; - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, RouterTestingModule], + beforeEach(waitForAsync(() => { + const authServiceSpy = jasmine.createSpyObj('AuthService', [ + 'verifyRegistration', + 'checkDomain', + 'saveRegistration', + 'authenticate' + ]); + const storageSpy = jasmine.createSpyObj('BrowserStorageService', [ + 'get', + 'set', + 'remove', + 'setUser' + ]); + const experienceSpy = jasmine.createSpyObj('ExperienceService', ['switchProgram']); + const notificationsSpy = jasmine.createSpyObj('NotificationsService', ['popUp', 'alert']); + const utilsSpy = jasmine.createSpyObj('UtilsService', ['setPageTitle', 'isMobile', 'find']); + const modalSpy = jasmine.createSpyObj('ModalController', ['create']); + + activatedRoute = { + queryParamMap: of(new Map()), + snapshot: { + paramMap: { + get: jasmine.createSpy('get') + } + } + }; + + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule, + RouterTestingModule, + IonicModule.forRoot(), + ReactiveFormsModule + ], declarations: [AuthRegistrationComponent], - providers: [AuthService, BrowserStorageService, ExperienceService], + providers: [ + { provide: AuthService, useValue: authServiceSpy }, + { provide: BrowserStorageService, useValue: storageSpy }, + { provide: ExperienceService, useValue: experienceSpy }, + { provide: NotificationsService, useValue: notificationsSpy }, + { provide: UtilsService, useValue: utilsSpy }, + { provide: ModalController, useValue: modalSpy }, + { provide: ActivatedRoute, useValue: activatedRoute } + ], }).compileComponents(); - }); + + authService = TestBed.inject(AuthService) as jasmine.SpyObj; + storageService = TestBed.inject(BrowserStorageService) as jasmine.SpyObj; + experienceService = TestBed.inject(ExperienceService) as jasmine.SpyObj; + notificationsService = TestBed.inject(NotificationsService) as jasmine.SpyObj; + utilsService = TestBed.inject(UtilsService) as jasmine.SpyObj; + modalController = TestBed.inject(ModalController) as jasmine.SpyObj; + + utilsService.isMobile.and.returnValue(false); + utilsService.setPageTitle.and.stub(); + })); beforeEach(() => { fixture = TestBed.createComponent(AuthRegistrationComponent); component = fixture.componentInstance; - authService = TestBed.inject(AuthService); - storageService = TestBed.inject(BrowserStorageService); - experienceService = TestBed.inject(ExperienceService); - fixture.detectChanges(); + storageService.get.and.returnValue(false); }); - it('should authenticate user and switch program on successful registration', async () => { - spyOn(authService, 'authenticate').and.returnValue(of({ data: { auth: { apikey: 'test-api-key', experience: {} } } } as any)); - spyOn(storageService, 'set'); - spyOn(storageService, 'remove'); - spyOn(experienceService, 'switchProgram').and.returnValue(Promise.resolve(of())); + describe('unRegisteredDirectLink === true scenarios', () => { + beforeEach(() => { + component.unRegisteredDirectLink = true; + component.user = { + email: 'test@example.com', + key: 'testkey123', + id: 456, + contact: null + }; + }); - await authService.authenticate({apikey: 'test-api-key'}); + describe('initialization', () => { + it('should set unRegisteredDirectLink to true when flag exists in storage', () => { + storageService.get.and.returnValue(true); - expect(authService.saveRegistration).toHaveBeenCalledWith({ - user_id: component.user.id, - key: component.user.key, - password: component.user.password, + component.ngOnInit(); + + expect(component.unRegisteredDirectLink).toBe(true); + expect(storageService.get).toHaveBeenCalledWith('unRegisteredDirectLink'); + }); }); - expect(authService.authenticate).toHaveBeenCalledWith({ - apikey: 'test-api-key', + + describe('validation', () => { + it('should validate successfully when terms are agreed', () => { + component.isAgreed = true; + + const result = component.validateRegistration(); + + expect(result).toBe(true); + expect(component.errors.length).toBe(0); + }); + + it('should fail validation when terms are not agreed', () => { + component.isAgreed = false; + + const result = component.validateRegistration(); + + expect(result).toBe(false); + expect(component.errors.length).toBe(1); + expect(component.errors[0]).toContain('agree with terms and Conditions'); + expect(component.isLoading).toBe(false); + }); + + it('should not require password validation for unRegisteredDirectLink', () => { + component.isAgreed = true; + component.password = ''; + + const result = component.validateRegistration(); + + expect(result).toBe(true); + }); }); - expect(component['showPopupMessages']).toHaveBeenCalledWith('shortMessage', $localize`Registration success!`, ['v3', 'home']); - expect(storageService.set).toHaveBeenCalledWith('isLoggedIn', true); - expect(storageService.remove).toHaveBeenCalledWith('unRegisteredDirectLink'); - expect(experienceService.switchProgram).toHaveBeenCalledWith({ experience: 'test' }); - }); - it('should show error message on failed registration', async () => { - spyOn(authService, 'saveRegistration').and.returnValue(throwError(new HttpErrorResponse({}))); - spyOn(authService, 'authenticate'); + describe('password auto-generation', () => { + it('should generate a secure random password with minimum 12 characters', () => { + const password = component['autoGeneratePassword'](); + + expect(password.length).toBeGreaterThanOrEqual(12); + expect(password.length).toBeLessThanOrEqual(16); + }); + + it('should generate unique passwords on multiple calls', () => { + const password1 = component['autoGeneratePassword'](); + const password2 = component['autoGeneratePassword'](); + + expect(password1).not.toBe(password2); + }); + + it('should generate password with mixed characters', () => { + const password = component['autoGeneratePassword'](); + + const uniqueChars = new Set(password.split('')); + expect(uniqueChars.size).toBeGreaterThan(5); + }); + }); + + describe('_setupPassword', () => { + it('should use user-provided password if available', () => { + component.password = 'UserPassword123!'; + + component['_setupPassword'](); + + expect(component.user.password).toBe('UserPassword123!'); + expect(component.confirmPassword).toBe('UserPassword123!'); + }); + + it('should auto-generate password when user does not provide one', () => { + component.password = ''; + + component['_setupPassword'](); + + expect(component.user.password).toBeDefined(); + expect(component.user.password.length).toBeGreaterThanOrEqual(12); + expect(component.confirmPassword).toBe(component.user.password); + }); + + it('should auto-generate password when password is undefined', () => { + component.password = undefined; + + component['_setupPassword'](); + + expect(component.user.password).toBeDefined(); + expect(component.confirmPassword).toBe(component.user.password); + }); + }); + + describe('registration flow', () => { + beforeEach(() => { + component.isAgreed = true; + }); + + it('should call _setupPassword when unRegisteredDirectLink is true', () => { + spyOn(component, '_setupPassword'); + authService.saveRegistration.and.returnValue(of({ + data: { apikey: 'test-api-key' } + })); + authService.authenticate.and.returnValue(of({ + data: { auth: { apikey: 'test-api-key', experience: {} } } + }) as any); + experienceService.switchProgram.and.returnValue(Promise.resolve()); + + component.register(); + + expect(component['_setupPassword']).toHaveBeenCalled(); + }); + + it('should successfully register without user-provided password', async () => { + component.password = ''; + authService.saveRegistration.and.returnValue(of({ + data: { apikey: 'test-api-key' } + })); + authService.authenticate.and.returnValue(of({ + data: { auth: { apikey: 'test-api-key', experience: { id: 1 } } } + }) as any); + experienceService.switchProgram.and.returnValue(Promise.resolve()); + + component.register(); + + await fixture.whenStable(); + + expect(authService.saveRegistration).toHaveBeenCalledWith({ + password: jasmine.any(String), + user_id: 456, + key: 'testkey123' + }); + expect(component.confirmPassword.length).toBeGreaterThanOrEqual(12); + }); + + it('should successfully register with user-provided password', async () => { + component.password = 'MySecurePass123!'; + authService.saveRegistration.and.returnValue(of({ + data: { apikey: 'test-api-key' } + })); + authService.authenticate.and.returnValue(of({ + data: { auth: { apikey: 'test-api-key', experience: { id: 1 } } } + }) as any); + experienceService.switchProgram.and.returnValue(Promise.resolve()); + + component.register(); + + await fixture.whenStable(); + + expect(authService.saveRegistration).toHaveBeenCalledWith({ + password: 'MySecurePass123!', + user_id: 456, + key: 'testkey123' + }); + }); + + it('should remove unRegisteredDirectLink flag from storage after successful registration', async () => { + component.password = ''; + authService.saveRegistration.and.returnValue(of({ + data: { apikey: 'test-api-key' } + })); + authService.authenticate.and.returnValue(of({ + data: { auth: { apikey: 'test-api-key', experience: { id: 1 } } } + }) as any); + experienceService.switchProgram.and.returnValue(Promise.resolve()); + + component.register(); + + await fixture.whenStable(); + + expect(storageService.remove).toHaveBeenCalledWith('unRegisteredDirectLink'); + expect(storageService.set).toHaveBeenCalledWith('isLoggedIn', true); + }); + + it('should handle password_compromised error for auto-generated password', async () => { + component.password = ''; + authService.saveRegistration.and.returnValue( + throwError(() => ({ + error: { + data: { + type: 'password_compromised' + } + } + } as HttpErrorResponse)) + ); + notificationsService.alert.and.returnValue(Promise.resolve()); + + component.register(); + + await fixture.whenStable(); + + expect(notificationsService.alert).toHaveBeenCalledWith({ + message: jasmine.stringContaining('insecure passwords'), + buttons: jasmine.any(Array) + }); + expect(component.isLoading).toBe(false); + }); + + it('should redirect to home after successful registration', async () => { + component.password = ''; + authService.saveRegistration.and.returnValue(of({ + data: { apikey: 'test-api-key' } + })); + authService.authenticate.and.returnValue(of({ + data: { auth: { apikey: 'test-api-key', experience: { id: 1 } } } + }) as any); + experienceService.switchProgram.and.returnValue(Promise.resolve()); + + component.register(); + + await fixture.whenStable(); + + expect(notificationsService.popUp).toHaveBeenCalledWith( + 'shortMessage', + { message: jasmine.stringContaining('Registration success') }, + ['v3', 'home'] + ); + }); + + it('should handle authentication error during registration', async () => { + component.password = ''; + authService.saveRegistration.and.returnValue(of({ + data: { apikey: 'test-api-key' } + })); + authService.authenticate.and.returnValue( + throwError(() => new Error('Auth failed')) + ); + + component.register(); + + await fixture.whenStable(); + + expect(component.isLoading).toBe(false); + expect(notificationsService.popUp).toHaveBeenCalledWith( + 'shortMessage', + { message: jasmine.stringContaining('Registration not complete') }, + false + ); + }); + + it('should set isLoading to true during registration', () => { + component.password = ''; + authService.saveRegistration.and.returnValue(of({ + data: { apikey: 'test-api-key' } + })); + authService.authenticate.and.returnValue(of({ + data: { auth: { apikey: 'test-api-key', experience: { id: 1 } } } + }) as any); + experienceService.switchProgram.and.returnValue(Promise.resolve()); + + expect(component.isLoading).toBe(false); + + component.register(); + + expect(component.isLoading).toBe(true); + }); + }); + + describe('terms and conditions modal', () => { + it('should open terms and conditions modal', async () => { + const modalSpy = jasmine.createSpyObj('Modal', ['present', 'onWillDismiss']); + modalSpy.onWillDismiss.and.returnValue(Promise.resolve({ + data: { isAgreed: true } + })); + modalController.create.and.returnValue(Promise.resolve(modalSpy)); + + await component.termsAndConditionsPopup(); + + expect(modalController.create).toHaveBeenCalledWith({ + component: jasmine.any(Function), + canDismiss: false, + backdropDismiss: false + }); + expect(modalSpy.present).toHaveBeenCalled(); + }); + + it('should set isAgreed to true when user agrees in modal', async () => { + const modalSpy = jasmine.createSpyObj('Modal', ['present', 'onWillDismiss']); + modalSpy.onWillDismiss.and.returnValue(Promise.resolve({ + data: { isAgreed: true } + })); + modalController.create.and.returnValue(Promise.resolve(modalSpy)); + + component.isAgreed = false; + await component.termsAndConditionsPopup(); + + expect(component.isAgreed).toBe(true); + }); + + it('should not change isAgreed when modal returns no data', async () => { + const modalSpy = jasmine.createSpyObj('Modal', ['present', 'onWillDismiss']); + modalSpy.onWillDismiss.and.returnValue(Promise.resolve({ + data: null + })); + modalController.create.and.returnValue(Promise.resolve(modalSpy)); + + component.isAgreed = false; + await component.termsAndConditionsPopup(); + + expect(component.isAgreed).toBe(false); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + component.isAgreed = true; + }); + + it('should handle generic registration errors', async () => { + component.password = ''; + authService.saveRegistration.and.returnValue( + throwError(() => ({ + error: { message: 'Generic error' } + } as HttpErrorResponse)) + ); + + component.register(); + + await fixture.whenStable(); + + expect(component.isLoading).toBe(false); + expect(notificationsService.popUp).toHaveBeenCalledWith( + 'shortMessage', + { message: jasmine.stringContaining('Registration not complete') }, + false + ); + }); + + it('should not proceed with registration when validation fails', () => { + component.isAgreed = false; - await authService.authenticate({ apikey: 'test-api-key' }); + component.register(); - expect(authService.saveRegistration).toHaveBeenCalledWith({ - user_id: component.user.id, - key: component.user.key, + expect(authService.saveRegistration).not.toHaveBeenCalled(); + expect(component.isLoading).toBe(false); + }); }); - expect(authService.authenticate).not.toHaveBeenCalled(); - expect(component['showPopupMessages']).toHaveBeenCalledWith('shortMessage', $localize`Registration not complete!`); }); }); diff --git a/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.ts b/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.ts index 17e87bdc1..11cb4e4eb 100644 --- a/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.ts +++ b/projects/v3/src/app/pages/auth/auth-registration/auth-registration.component.ts @@ -162,9 +162,19 @@ export class AuthRegistrationComponent implements OnInit, OnDestroy { } private autoGeneratePassword() { - const text = Md5.hashStr('').toString(); - const autoPass = text.substr(0, 8); - return autoPass; + // generate a secure random password that won't be flagged as compromised + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%'; + const timestamp = Date.now().toString(36); + const random = Math.random().toString(36).substring(2, 10); + let password = timestamp + random; + + // ensure minimum 12 characters with mixed case, numbers, and special chars + while (password.length < 12) { + password += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + // shuffle to avoid predictable patterns + return password.split('').sort(() => Math.random() - 0.5).join('').substring(0, 16); } openLink(): void { diff --git a/projects/v3/src/app/pipes/language.pipe.ts b/projects/v3/src/app/pipes/language.pipe.ts index 33df63ee1..9de3f2000 100644 --- a/projects/v3/src/app/pipes/language.pipe.ts +++ b/projects/v3/src/app/pipes/language.pipe.ts @@ -52,3 +52,6 @@ export class LanguageDetectionPipe implements PipeTransform { } } + + + diff --git a/projects/v3/src/environments/environment.custom.ts b/projects/v3/src/environments/environment.custom.ts index fd9a85715..f62a43158 100644 --- a/projects/v3/src/environments/environment.custom.ts +++ b/projects/v3/src/environments/environment.custom.ts @@ -1,25 +1,25 @@ export const environment = { - stackName: 'p2-stage', + stackName: '', authCacheDuration: 5 * 60 * 1000, // 5 minutes - production: true, + production: '', demo: false, - appkey: 'b11e7c189b', - pusherKey: 'c8f1e1cba0f717e24046', - pusherCluster: 'ap1', - env: 'test', - APIEndpoint: 'https://admin.p2-stage.practera.com/', - graphQL: 'https://core-graphql-api.p2-stage.practera.com', - chatGraphQL: 'https://chat-api.p2-stage.practera.com', - globalLoginUrl: 'https://app.login-stage.practera.com', - badgeProjectUrl: 'https://badge.p2-stage.practera.com', - stackUuid: '571c91b4-f0e1-498d-a5db-04f8d92d3693', - intercomAppId: 'pef1lmo8', + appkey: '', + pusherKey: '', + pusherCluster: '', + env: '', + APIEndpoint: '', + graphQL: '', + chatGraphQL: '', + globalLoginUrl: '', + badgeProjectUrl: '', + stackUuid: '', + intercomAppId: '', uppyConfig: { - tusUrl: 'https://tusd.practera.com/uploads/', + tusUrl: '', uploadPreset: 'practera', restrictions: { minFileSize: 0, // No minimum size - maxFileSize: 2147483648, // 2GB max size + maxFileSize: , // 2GB max size minNumberOfFiles: 1, // At least one file maxNumberOfFiles: 1, // max one file for now maxTotalFileSize: undefined, // No limit on total size @@ -27,36 +27,36 @@ export const environment = { } }, filestack: { - key: 'AO6F4C72uTPGRywaEijdLz', + key: '', s3Config: { location: 's3', - container: 'files.p2-stage.practera.com', + container: '', containerChina: '', - region: 'ap-southeast-2', + region: '', regionChina: '', paths: { - any: '/appv3/test/any/', - image: '/appv3/test/images/', - video: '/appv3/test/videos/' + any: '', + image: '', + video: '' }, workflows: [ - '3c38ef53-a9d0-4aa4-9234-617d9f03c0de', + '', ], }, - policy: 'eyJleHBpcnkiOjE3MzU2NTAwMDB9', - signature: '30323e4c80bb68e30afef26b32aa4dae401b0581b8e8ba9da93f3a01701be267', + policy: '', + signature: '', workflows: { - virusDetection: '3c38ef53-a9d0-4aa4-9234-617d9f03c0de', + virusDetection: '', }, }, hubspot: { - liveServerRegion: 'AU', - supportFormPortalId: '3404872', - supportFormId: '114bee73-67ac-4f23-8285-2b67e0e28df4' + liveServerRegion: '', + supportFormPortalId: '', + supportFormId: '' }, - defaultCountryModel: 'AUS', + defaultCountryModel: '', intercom: false, - newrelic: 'true', + newrelic: '', goMobile: false, helpline: '', featureToggles: {