Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<main aria-labelledby="verification-title" class="verification">
<mat-icon aria-hidden="true">
{{
state === 'verified'
? 'mark_email_read'
: state === 'error'
? 'error_outline'
: 'hourglass_top'
}}
</mat-icon>
<h1 id="verification-title">Additional notification email</h1>
<p aria-live="polite">{{ message }}</p>

@if (state === 'verified') {
<button color="primary" mat-flat-button type="button" (click)="openProfile()">
Review notification settings
</button>
} @else if (state === 'error') {
<button mat-stroked-button type="button" (click)="openProfile()">
Request a new verification link
</button>
}
</main>
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
:host {
display: block;
min-height: 100dvh;
padding: clamp(16px, 5vw, 48px);
}

.verification {
align-items: center;
border: 1px solid var(--ot-color-border);
border-radius: 16px;
display: flex;
flex-direction: column;
gap: 12px;
margin: min(15vh, 96px) auto 0;
max-width: 36rem;
padding: clamp(24px, 7vw, 48px);
text-align: center;
background: var(--ot-color-surface);
color: var(--ot-color-text);

mat-icon {
color: var(--ot-color-link);
font-size: 48px;
height: 48px;
width: 48px;
}

h1,
p {
margin: 0;
overflow-wrap: anywhere;
}

button {
margin-top: 8px;
min-height: 44px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {Router} from '@angular/router';
import {Subject, of, throwError} from 'rxjs';
import {AdditionalNotificationEmailService} from 'src/app/api/services/additional-notification-email.service';
import {AuthenticationService} from 'src/app/api/services/authentication.service';
import {
captureAndScrubAdditionalEmailVerification,
consumeAdditionalEmailVerificationToken,
} from 'src/app/security/additional-email-verification-callback';
import {VerifyAdditionalEmailComponent} from './verify-additional-email.component';

describe('VerifyAdditionalEmailComponent', () => {
let fixture: ComponentFixture<VerifyAdditionalEmailComponent>;
let component: VerifyAdditionalEmailComponent;
const service = {verify: vi.fn()};
const authentication = {isAuthenticated: vi.fn()};
const router = {navigateByUrl: vi.fn()};
let token: string | null = 'private-token';

const create = async (): Promise<void> => {
await TestBed.configureTestingModule({
declarations: [VerifyAdditionalEmailComponent],
imports: [MatButtonModule, MatIconModule],
providers: [
{provide: AdditionalNotificationEmailService, useValue: service},
{provide: AuthenticationService, useValue: authentication},
{provide: Router, useValue: router},
],
}).compileComponents();
fixture = TestBed.createComponent(VerifyAdditionalEmailComponent);
component = fixture.componentInstance;
if (token) {
captureAndScrubAdditionalEmailVerification(
`https://ontrack.example/verify_additional_email#token=${token}`,
vi.fn(),
);
}
fixture.detectChanges();
};

const rendered = (selector: string): HTMLElement | null =>
(fixture.nativeElement as HTMLElement).querySelector(selector);

beforeEach(() => {
TestBed.resetTestingModule();
vi.clearAllMocks();
consumeAdditionalEmailVerificationToken();
token = 'private-token';
service.verify.mockReturnValue(of(undefined));
authentication.isAuthenticated.mockReturnValue(false);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('consumes the pre-bootstrap token once and reports success', async () => {
await create();

expect(service.verify).toHaveBeenCalledWith('private-token');
expect(component.state).toBe('verified');
expect(consumeAdditionalEmailVerificationToken()).toBeNull();
});

it('shows the success message and next step when the response arrives after first render', async () => {
const response: Subject<void> = new Subject();
service.verify.mockReturnValue(response.asObservable());
await create();
expect(rendered('p')?.textContent).toContain('Verifying');
expect(rendered('button')).toBeNull();

response.next();
response.complete();
fixture.detectChanges();

expect(rendered('p')?.textContent).toContain('Your additional notification email is verified.');
expect(rendered('button')?.textContent).toContain('Review notification settings');
});

it('shows the error message and recovery step when a late response fails', async () => {
const response: Subject<void> = new Subject();
service.verify.mockReturnValue(response.asObservable());
await create();

response.error(new Error('expired'));
fixture.detectChanges();

expect(rendered('p')?.textContent).toContain('invalid, expired, or has already been used');
expect(rendered('button')?.textContent).toContain('Request a new verification link');
});

it('reloads the profile for a signed-out verifier so startup can restore a saved session', async () => {
const assign = vi.fn();
vi.stubGlobal('location', {assign});
await create();

rendered('button')?.click();

expect(assign).toHaveBeenCalledWith('/edit_profile');
expect(router.navigateByUrl).not.toHaveBeenCalled();
});

it('opens the profile directly for an already authenticated user', async () => {
const assign = vi.fn();
vi.stubGlobal('location', {assign});
authentication.isAuthenticated.mockReturnValue(true);
await create();

rendered('button')?.click();

expect(router.navigateByUrl).toHaveBeenCalledWith('/edit_profile');
expect(assign).not.toHaveBeenCalled();
});

it('does not call the API for an incomplete link', async () => {
token = null;
await create();

expect(service.verify).not.toHaveBeenCalled();
expect(component.state).toBe('error');
});

it('shows one safe error for expired or replayed links', async () => {
service.verify.mockReturnValue(throwError(() => new Error('expired')));
await create();

expect(component.state).toBe('error');
expect(component.message).toContain('invalid, expired, or has already been used');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {Router} from '@angular/router';
import {AdditionalNotificationEmailService} from 'src/app/api/services/additional-notification-email.service';
import {AuthenticationService} from 'src/app/api/services/authentication.service';
import {consumeAdditionalEmailVerificationToken} from 'src/app/security/additional-email-verification-callback';

type VerificationState = 'verifying' | 'verified' | 'error';

@Component({
selector: 'f-verify-additional-email',
templateUrl: './verify-additional-email.component.html',
styleUrl: './verify-additional-email.component.scss',
standalone: false,
})
export class VerifyAdditionalEmailComponent implements OnInit {
public state: VerificationState = 'verifying';
public message = 'Verifying your additional notification email…';

constructor(
private additionalEmailService: AdditionalNotificationEmailService,
private authentication: AuthenticationService,
private router: Router,
private changeDetector: ChangeDetectorRef,
) {}

public openProfile(): void {
if (this.authentication.isAuthenticated()) {
void this.router.navigateByUrl('/edit_profile');
} else {
// Startup skips the refresh-token login on this route, so a saved session
// is never restored here. A full load of the profile page runs the normal
// startup, which restores that session or saves the profile as the return
// URL and sends the user to sign in.
window.location.assign('/edit_profile');
}
}

public ngOnInit(): void {
const token = consumeAdditionalEmailVerificationToken();
if (!token) {
this.state = 'error';
this.message = 'This verification link is incomplete.';
return;
}

// The component is OnPush by default, so a response that arrives after the
// first render has to mark the view or the page stays on "Verifying".
this.additionalEmailService.verify(token).subscribe({
next: () => {
this.state = 'verified';
this.message = 'Your additional notification email is verified.';
this.changeDetector.markForCheck();
},
error: () => {
this.state = 'error';
this.message = 'This verification link is invalid, expired, or has already been used.';
this.changeDetector.markForCheck();
},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ <h1 class="mb-0.5">Campuses</h1>
</td>
<td *matFooterCellDef mat-footer-cell>
<div class="edit-actions right">
<button mat-icon-button (click)="submit()">
<button aria-label="Add campus" mat-icon-button (click)="submit()">
<mat-icon>add</mat-icon>
</button>
</div>
Expand All @@ -183,6 +183,15 @@ <h1 class="mb-0.5">Campuses</h1>

<tr *matHeaderRowDef="columns" mat-header-row></tr>
<tr *matRowDef="let row; columns: columns" mat-row></tr>
<tr *matNoDataRow class="mat-row">
<td class="mat-cell !border-b-0" [attr.colspan]="columns.length">
<f-empty-state
hint="Add a campus using the row below."
icon="location_off"
message="No campuses yet"
></f-empty-state>
</td>
</tr>
<tr *matFooterRowDef="columns" mat-footer-row [hidden]="selected"></tr>
</table>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ td.mat-column-options {
width: 95%;
}
.status-icon {
background-color: white;
background-color: var(--ot-color-surface);
border: none;
margin-top: 8px;
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ <h1 class="my-0">Teaching periods</h1>

<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr *matNoDataRow class="mat-row">
<td class="mat-cell !border-b-0" [attr.colspan]="displayedColumns.length">
<f-empty-state
hint="Add one with the button above."
icon="event_busy"
message="No teaching periods yet"
></f-empty-state>
</td>
</tr>
</table>
<mat-paginator
aria-label="Select page of teaching periods"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ <h1 mat-dialog-title>Import Units Into {{ data.teachingPeriod.name }}</h1>

<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns;" mat-row></tr>
<tr *matNoDataRow class="mat-row">
<td class="mat-cell !border-b-0" [attr.colspan]="displayedColumns.length">
<f-empty-state
hint="Enter unit codes below and select Add."
icon="school"
message="No units to import"
></f-empty-state>
</td>
</tr>
</table>
</div>
<div class="flex-column flex" mat-dialog-actions>
Expand Down
Loading