From be085782de3c6e3a708b29861cd1a5aa4dff611f Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Fri, 11 Sep 2026 01:01:59 +0300 Subject: [PATCH 1/3] feat(auth): add resend confirmation url page --- src/app/app.routes.ts | 8 +++ src/app/core/services/auth.service.ts | 7 +++ .../auth/models/resend-confirmation.ts | 3 ++ .../resend/resend-confirmation.component.html | 31 +++++++++++ .../resend/resend-confirmation.component.scss | 17 ++++++ .../resend-confirmation.component.spec.ts | 30 +++++++++++ .../resend/resend-confirmation.component.ts | 54 +++++++++++++++++++ src/assets/i18n/en.json | 10 +++- 8 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/app/features/auth/models/resend-confirmation.ts create mode 100644 src/app/features/auth/pages/resend/resend-confirmation.component.html create mode 100644 src/app/features/auth/pages/resend/resend-confirmation.component.scss create mode 100644 src/app/features/auth/pages/resend/resend-confirmation.component.spec.ts create mode 100644 src/app/features/auth/pages/resend/resend-confirmation.component.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index ace56b109..36b92bc58 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -67,6 +67,14 @@ export const routes: Routes = [ (mod) => mod.ChooseRepositoryComponent ), }, + { + path: 'resend', + loadComponent: () => + import('./features/auth/pages/resend/resend-confirmation.component').then( + (mod) => mod.ResendConfirmationComponent + ), + data: { skipBreadcrumbs: true }, + }, { path: 'search', loadComponent: () => import('./features/search/search.component').then((mod) => mod.SearchComponent), diff --git a/src/app/core/services/auth.service.ts b/src/app/core/services/auth.service.ts index 09f73bbb8..b1c2ff2b4 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/core/services/auth.service.ts @@ -117,4 +117,11 @@ export class AuthService { return this.jsonApiService.post(baseUrl, body); } + + resendConfirmationUrl(email: string) { + const baseUrl = `${this.apiUrl}/resend_confirmation/`; + const params: Record = { email }; + + return this.jsonApiService.get(baseUrl, params); + } } diff --git a/src/app/features/auth/models/resend-confirmation.ts b/src/app/features/auth/models/resend-confirmation.ts new file mode 100644 index 000000000..9dfc48661 --- /dev/null +++ b/src/app/features/auth/models/resend-confirmation.ts @@ -0,0 +1,3 @@ +import { FormControl, FormGroup } from '@angular/forms'; + +export type ResendConfirmationFormGroupType = FormGroup<{ email: FormControl }>; diff --git a/src/app/features/auth/pages/resend/resend-confirmation.component.html b/src/app/features/auth/pages/resend/resend-confirmation.component.html new file mode 100644 index 000000000..46fbaf3aa --- /dev/null +++ b/src/app/features/auth/pages/resend/resend-confirmation.component.html @@ -0,0 +1,31 @@ +
+
+

{{ 'auth.resendConfirmation.title' | translate }}

+

{{ 'auth.resendConfirmation.description' | translate }}

+ +
+ + + +
+
+ +
+ @if (message()) { + + {{ message()!.content | translate }} + + } +
+
diff --git a/src/app/features/auth/pages/resend/resend-confirmation.component.scss b/src/app/features/auth/pages/resend/resend-confirmation.component.scss new file mode 100644 index 000000000..45db00704 --- /dev/null +++ b/src/app/features/auth/pages/resend/resend-confirmation.component.scss @@ -0,0 +1,17 @@ +@use "styles/mixins" as mix; + +:host { + @include mix.flex-center; + flex: 1; + background: var(--gradient-3); +} + +.resend-confirmation-container { + @include mix.flex-column; + background: var(--white); + border-radius: mix.rem(12px); + box-shadow: 0 2px 4px var(--grey-outline); + max-width: mix.rem(448px); + gap: mix.rem(24px); + flex: 1; +} diff --git a/src/app/features/auth/pages/resend/resend-confirmation.component.spec.ts b/src/app/features/auth/pages/resend/resend-confirmation.component.spec.ts new file mode 100644 index 000000000..5ca077770 --- /dev/null +++ b/src/app/features/auth/pages/resend/resend-confirmation.component.spec.ts @@ -0,0 +1,30 @@ +import { MockComponent, MockProvider } from 'ng-mocks'; + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AuthService } from '@core/services/auth.service'; +import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; + +import { provideOSFCore } from '@testing/osf.testing.provider'; + +import { ResendConfirmationComponent } from './resend-confirmation.component'; + +describe('ResendConfirmationComponent', () => { + let component: ResendConfirmationComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ResendConfirmationComponent, MockComponent(TextInputComponent)], + providers: [provideOSFCore(), MockProvider(AuthService)], + }); + + fixture = TestBed.createComponent(ResendConfirmationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/auth/pages/resend/resend-confirmation.component.ts b/src/app/features/auth/pages/resend/resend-confirmation.component.ts new file mode 100644 index 000000000..27f731804 --- /dev/null +++ b/src/app/features/auth/pages/resend/resend-confirmation.component.ts @@ -0,0 +1,54 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Message } from 'primeng/message'; + +import { Component, inject, signal } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { AuthService } from '@core/services/auth.service'; +import { MessageInfo } from '@osf/features/auth/models'; +import { ResendConfirmationFormGroupType } from '@osf/features/auth/models/resend-confirmation'; +import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; +import { InputLimits } from '@osf/shared/constants/input-limits.const'; +import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper'; + +@Component({ + selector: 'osf-resend-confirmation', + imports: [ReactiveFormsModule, Button, Message, TextInputComponent, TranslatePipe], + templateUrl: './resend-confirmation.component.html', + styleUrl: './resend-confirmation.component.scss', +}) +export class ResendConfirmationComponent { + private readonly fb = inject(FormBuilder); + private readonly authService = inject(AuthService); + + readonly emailLimit = InputLimits.email.maxLength; + + resendConfirmationForm: ResendConfirmationFormGroupType = this.fb.group({ + email: ['', [CustomValidators.requiredTrimmed(), Validators.email]], + }); + + message = signal(null); + + onSubmit(): void { + if (this.resendConfirmationForm.invalid) { + return; + } + + const emailForm = this.resendConfirmationForm.getRawValue(); + + this.authService.resendConfirmationUrl(emailForm.email).subscribe(() => { + this.resendConfirmationForm.reset(); + + this.message.set({ + severity: 'success', + content: 'auth.resendConfirmation.messages.success', + }); + }); + } + + onCloseMessage(): void { + this.message.set(null); + } +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index da47adf5c..eb6e70e29 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -240,7 +240,8 @@ "required": "Password is required." } }, - "resetPassword": "Reset password" + "resetPassword": "Reset password", + "resendConfirmation": "Resend" }, "forgotPassword": { "description": "Enter your email address and we'll send a link to reset your password", @@ -249,6 +250,13 @@ }, "title": "Forgot Your Password?" }, + "resendConfirmation": { + "description": "Enter your email address and we'll resend your confirmation link.", + "messages": { + "success": "Thanks. Check your email for confirmation link." + }, + "title": "Resend Confirmation Email" + }, "resetPassword": { "success": { "backToSignin": "Back to Sign In", From 9dec76d903f3d2b7e8da15e358f2a0add5b6a039 Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Fri, 11 Sep 2026 16:17:43 +0300 Subject: [PATCH 2/3] feat(auth): add tests and small fixes --- .../resend/resend-confirmation.component.html | 6 +-- .../resend-confirmation.component.spec.ts | 47 ++++++++++++++++++- src/testing/providers/auth-service.mock.ts | 2 + 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/app/features/auth/pages/resend/resend-confirmation.component.html b/src/app/features/auth/pages/resend/resend-confirmation.component.html index 46fbaf3aa..f283449cf 100644 --- a/src/app/features/auth/pages/resend/resend-confirmation.component.html +++ b/src/app/features/auth/pages/resend/resend-confirmation.component.html @@ -22,9 +22,9 @@

{{ 'auth.resendConfirmation.title' | translate }}

- @if (message()) { - - {{ message()!.content | translate }} + @if (message(); as msg) { + + {{ msg.content | translate }} }
diff --git a/src/app/features/auth/pages/resend/resend-confirmation.component.spec.ts b/src/app/features/auth/pages/resend/resend-confirmation.component.spec.ts index 5ca077770..f5877b0de 100644 --- a/src/app/features/auth/pages/resend/resend-confirmation.component.spec.ts +++ b/src/app/features/auth/pages/resend/resend-confirmation.component.spec.ts @@ -6,17 +6,21 @@ import { AuthService } from '@core/services/auth.service'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { provideOSFCore } from '@testing/osf.testing.provider'; +import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.mock'; import { ResendConfirmationComponent } from './resend-confirmation.component'; describe('ResendConfirmationComponent', () => { let component: ResendConfirmationComponent; let fixture: ComponentFixture; + let authService: AuthServiceMockType; beforeEach(() => { + authService = AuthServiceMock.simple(); + TestBed.configureTestingModule({ imports: [ResendConfirmationComponent, MockComponent(TextInputComponent)], - providers: [provideOSFCore(), MockProvider(AuthService)], + providers: [provideOSFCore(), MockProvider(AuthService, authService)], }); fixture = TestBed.createComponent(ResendConfirmationComponent); @@ -27,4 +31,45 @@ describe('ResendConfirmationComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should not call resendConfirmationUrl when form is invalid', () => { + component.resendConfirmationForm.setValue({ email: '' }); + + component.onSubmit(); + + expect(authService.resendConfirmationUrl).not.toHaveBeenCalled(); + expect(component.message()).toBeNull(); + }); + + it('should not call resendConfirmationUrl when email is not a valid email address', () => { + component.resendConfirmationForm.setValue({ email: 'not-an-email' }); + + component.onSubmit(); + + expect(authService.resendConfirmationUrl).not.toHaveBeenCalled(); + expect(component.message()).toBeNull(); + }); + + it('should call resendConfirmationUrl, reset the form, and set the success message when form is valid', () => { + component.resendConfirmationForm.setValue({ email: 'user@example.com' }); + + component.onSubmit(); + + expect(authService.resendConfirmationUrl).toHaveBeenCalledWith('user@example.com'); + expect(component.resendConfirmationForm.getRawValue()).toEqual({ email: null }); + expect(component.message()).toEqual({ + severity: 'success', + content: 'auth.resendConfirmation.messages.success', + }); + }); + + it('should clear the message on onCloseMessage', () => { + component.resendConfirmationForm.setValue({ email: 'user@example.com' }); + component.onSubmit(); + expect(component.message()).not.toBeNull(); + + component.onCloseMessage(); + + expect(component.message()).toBeNull(); + }); }); diff --git a/src/testing/providers/auth-service.mock.ts b/src/testing/providers/auth-service.mock.ts index 6bd0cd2d9..f79b5a234 100644 --- a/src/testing/providers/auth-service.mock.ts +++ b/src/testing/providers/auth-service.mock.ts @@ -15,6 +15,7 @@ export type AuthServiceMockType = Partial & { register: Mock; forgotPassword: Mock; resetPassword: Mock; + resendConfirmationUrl: Mock; }; export const AuthServiceMock = { @@ -30,6 +31,7 @@ export const AuthServiceMock = { register: vi.fn().mockReturnValue(of({})), forgotPassword: vi.fn().mockReturnValue(of({})), resetPassword: vi.fn().mockReturnValue(of({})), + resendConfirmationUrl: vi.fn().mockReturnValue(of({})), }; }, }; From 5ca984c9775c1f26c071b20f128395e6e4171fc1 Mon Sep 17 00:00:00 2001 From: Bohdan Odintsov Date: Fri, 11 Sep 2026 23:36:46 +0300 Subject: [PATCH 3/3] feat(auth): rename --- .../{resend-confirmation.ts => resend-confirmation.model.ts} | 0 .../features/auth/pages/resend/resend-confirmation.component.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/app/features/auth/models/{resend-confirmation.ts => resend-confirmation.model.ts} (100%) diff --git a/src/app/features/auth/models/resend-confirmation.ts b/src/app/features/auth/models/resend-confirmation.model.ts similarity index 100% rename from src/app/features/auth/models/resend-confirmation.ts rename to src/app/features/auth/models/resend-confirmation.model.ts diff --git a/src/app/features/auth/pages/resend/resend-confirmation.component.ts b/src/app/features/auth/pages/resend/resend-confirmation.component.ts index 27f731804..f2713271e 100644 --- a/src/app/features/auth/pages/resend/resend-confirmation.component.ts +++ b/src/app/features/auth/pages/resend/resend-confirmation.component.ts @@ -8,7 +8,7 @@ import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { AuthService } from '@core/services/auth.service'; import { MessageInfo } from '@osf/features/auth/models'; -import { ResendConfirmationFormGroupType } from '@osf/features/auth/models/resend-confirmation'; +import { ResendConfirmationFormGroupType } from '@osf/features/auth/models/resend-confirmation.model'; import { TextInputComponent } from '@osf/shared/components/text-input/text-input.component'; import { InputLimits } from '@osf/shared/constants/input-limits.const'; import { CustomValidators } from '@osf/shared/helpers/custom-form-validators.helper';