Skip to content

Commit b6a47a2

Browse files
authored
Add Auth Interceptor (#742)
* Add Auth Interceptor to handle Bearer Token * Improve BYPASS_LOADING context * client,server: update packages
1 parent bdbeb63 commit b6a47a2

File tree

14 files changed

+804
-660
lines changed

14 files changed

+804
-660
lines changed

client/package-lock.json

Lines changed: 194 additions & 193 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"canvas-confetti": "^1.9.3",
5454
"date-fns": "^4.1.0",
5555
"echarts": "^5.6.0",
56-
"firebase": "^11.3.1",
56+
"firebase": "^11.4.0",
5757
"js-cookie": "^3.0.5",
5858
"ngx-echarts": "^19.0.0",
5959
"rxjs": "~7.8.2",
@@ -77,7 +77,7 @@
7777
"eslint": "^9.21.0",
7878
"eslint-config-prettier": "^10.0.2",
7979
"eslint-plugin-prettier": "^5.2.3",
80-
"firebase-tools": "^13.31.2",
80+
"firebase-tools": "^13.32.0",
8181
"jest": "^29.7.0",
8282
"jest-preset-angular": "^14.5.3",
8383
"postcss": "^8.5.3",

client/src/app/app.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { provideRouter, withComponentInputBinding, withInMemoryScrolling, withVi
55
import { environment } from '../environments/environment';
66
import { routes } from './app.routes';
77
import { provideAuth } from './shared/auth';
8+
import { authInterceptor } from './shared/auth/auth.interceptor';
89
import { provideBaseHref } from './shared/base-href';
910
import { provideFirebaseEmulator } from './shared/firebase';
1011
import { provideLanguage } from './shared/i18n/language';
@@ -23,7 +24,7 @@ export const appConfig: ApplicationConfig = {
2324
withViewTransitions(),
2425
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
2526
),
26-
provideHttpClient(withFetch(), withInterceptors([loadingInterceptor])),
27+
provideHttpClient(withFetch(), withInterceptors([authInterceptor, loadingInterceptor])),
2728
provideAnimationsAsync(),
2829
provideMatPaginatorIntl(),
2930
provideBaseHref(),
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
import { HttpContext, HttpContextToken } from '@angular/common/http';
2+
13
export const AUTH_REDIRECT_PARAM = 'redirect';
24

35
export const AUTH_REDIRECT_BYPASS_URL = '/home';
6+
7+
export const BYPASS_AUTHORIZATION = new HttpContextToken(() => false);
8+
9+
export const BYPASS_AUTHORIZATION_CONTEXT = new HttpContext().set(BYPASS_AUTHORIZATION, true);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { HttpInterceptorFn } from '@angular/common/http';
2+
import { inject } from '@angular/core';
3+
import { concatMap } from 'rxjs';
4+
import { environment } from '../../../environments/environment';
5+
import { BYPASS_AUTHORIZATION } from './auth.config';
6+
import { AuthService } from './auth.service';
7+
8+
export const authInterceptor: HttpInterceptorFn = (req, next) => {
9+
if (!req.url.startsWith(environment.apiBaseUrl) || req.context.get(BYPASS_AUTHORIZATION)) {
10+
return next(req);
11+
}
12+
return inject(AuthService)
13+
.getIdToken()
14+
.pipe(concatMap((idToken) => next(req.clone({ headers: req.headers.set('Authorization', `Bearer ${idToken}`) }))));
15+
};

client/src/app/shared/auth/auth.service.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
signInWithEmailAndPassword,
1111
signInWithPopup,
1212
} from 'firebase/auth';
13-
import { Observable, catchError, concatMap, filter, first, from, map, of, switchMap, tap } from 'rxjs';
13+
import { Observable, catchError, concatMap, filter, first, from, map, of, tap } from 'rxjs';
1414
import { FirebaseService } from '../firebase';
1515
import { AUTH_REDIRECT_PARAM } from './auth.config';
1616
import { UserStatus } from './auth.types';
@@ -112,11 +112,4 @@ export class AuthService {
112112
getIdToken(): Observable<string | null> {
113113
return from(this._user()?.getIdToken() ?? Promise.resolve(null));
114114
}
115-
116-
withBearerIdToken<T>(requestFactory: (headers: { Authorization: string }) => Observable<T>) {
117-
return this.getIdToken().pipe(
118-
map((idToken) => ({ Authorization: `Bearer ${idToken}` })),
119-
switchMap(requestFactory),
120-
);
121-
}
122115
}

client/src/app/shared/employee/employee.service.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { HttpClient, HttpContext } from '@angular/common/http';
1+
import { HttpClient } from '@angular/common/http';
22
import { Injectable, computed, inject, signal } from '@angular/core';
33
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
44
import { EMPTY, filter, switchMap, tap } from 'rxjs';
55
import { environment } from '../../../environments/environment';
66
import { AuthService } from '../auth';
7-
import { BYPASS_LOADING } from '../loading';
7+
import { BYPASS_LOADING_CONTEXT } from '../loading';
88
import { UpdateManagerDto } from './employee.dto';
99
import { EmployeeData } from './employee.types';
1010
import { isManager, updateEmployeeData } from './employee.utils';
@@ -40,21 +40,14 @@ export class EmployeeService {
4040
}
4141

4242
private fetchData() {
43-
return this.authService.withBearerIdToken((headers) =>
44-
this.httpClient.get<EmployeeData>(`${this.apiBaseUrl}/employee`, {
45-
headers,
46-
// This request is executed when the page is loaded and is used initially to control the display of the
47-
// "Manager" link in the header. This should not block the UI because of the loading spinner.
48-
context: new HttpContext().set(BYPASS_LOADING, true),
49-
}),
50-
);
43+
// This request is executed when the page is loaded and is used initially to control the display of the
44+
// "Manager" link in the header. This should not block the UI because of the loading spinner.
45+
return this.httpClient.get<EmployeeData>(`${this.apiBaseUrl}/employee`, { context: BYPASS_LOADING_CONTEXT });
5146
}
5247

5348
updateManager(managerEmail: string) {
54-
return this.authService.withBearerIdToken((headers) =>
55-
this.httpClient
56-
.post<void>(`${this.apiBaseUrl}/employee/manager`, { managerEmail } as UpdateManagerDto, { headers })
57-
.pipe(tap(() => this._data.update((data) => updateEmployeeData(data, { managerEmail })))),
58-
);
49+
return this.httpClient
50+
.post<void>(`${this.apiBaseUrl}/employee/manager`, { managerEmail } as UpdateManagerDto)
51+
.pipe(tap(() => this._data.update((data) => updateEmployeeData(data, { managerEmail }))));
5952
}
6053
}

client/src/app/shared/feedback/feedback.service.ts

Lines changed: 44 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http';
22
import { Injectable, inject } from '@angular/core';
33
import { Observable, catchError, map, of } from 'rxjs';
44
import { environment } from '../../../environments/environment';
5-
import { AuthService } from '../auth';
5+
import { BYPASS_AUTHORIZATION_CONTEXT } from '../auth';
66
import {
77
FeedbackArchiveRequestDto,
88
FeedbackRequestAgainDto,
@@ -31,72 +31,66 @@ import {
3131
export class FeedbackService {
3232
private httpClient = inject(HttpClient);
3333

34-
private authService = inject(AuthService);
35-
3634
private apiBaseUrl = environment.apiBaseUrl;
3735

3836
// ----- Request feedback and give requested feedback -----
3937

4038
// The cookie `app-locale-id` must be provided (using the `withCredentials` option)
4139
// so that the server can determine the language to be used in the emails.
4240
request(dto: FeedbackRequestDto): Observable<{ error: boolean; message?: 'invalid_email' }> {
43-
return this.authService.withBearerIdToken((headers) =>
44-
this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/request`, dto, { headers, withCredentials: true }).pipe(
45-
map(() => ({ error: false })),
46-
catchError(({ error }: HttpErrorResponse) => of({ error: true, message: error?.message })),
47-
),
41+
return this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/request`, dto, { withCredentials: true }).pipe(
42+
map(() => ({ error: false })),
43+
catchError(({ error }: HttpErrorResponse) => of({ error: true, message: error?.message })),
4844
);
4945
}
5046

5147
// The cookie `app-locale-id` must be provided (using the `withCredentials` option)
5248
// so that the server can determine the language to be used in the emails.
5349
requestAgain(feedbackId: string) {
54-
return this.authService.withBearerIdToken((headers) =>
55-
this.httpClient.post<void>(
56-
`${this.apiBaseUrl}/feedback/request-again`,
57-
{ feedbackId } satisfies FeedbackRequestAgainDto,
58-
{ headers, withCredentials: true },
59-
),
50+
return this.httpClient.post<void>(
51+
`${this.apiBaseUrl}/feedback/request-again`,
52+
{ feedbackId } satisfies FeedbackRequestAgainDto,
53+
{ withCredentials: true },
6054
);
6155
}
6256

6357
archiveRequest(feedbackId: string): Observable<{ error: boolean; message?: 'Forbidden' }> {
64-
return this.authService.withBearerIdToken((headers) =>
65-
this.httpClient
66-
.post<void>(`${this.apiBaseUrl}/feedback/archive-request`, { feedbackId } satisfies FeedbackArchiveRequestDto, {
67-
headers,
68-
})
69-
.pipe(
70-
map(() => ({ error: false })),
71-
catchError(({ error }: HttpErrorResponse) => {
72-
const isForbidden = (error?.message as 'Bad Request' | 'Forbidden') === 'Forbidden';
73-
return of({ error: true, message: isForbidden ? ('Forbidden' as const) : undefined });
74-
}),
75-
),
76-
);
58+
return this.httpClient
59+
.post<void>(`${this.apiBaseUrl}/feedback/archive-request`, { feedbackId } satisfies FeedbackArchiveRequestDto)
60+
.pipe(
61+
map(() => ({ error: false })),
62+
catchError(({ error }: HttpErrorResponse) => {
63+
const isForbidden = (error?.message as 'Bad Request' | 'Forbidden') === 'Forbidden';
64+
return of({ error: true, message: isForbidden ? ('Forbidden' as const) : undefined });
65+
}),
66+
);
7767
}
7868

7969
checkRequest(token: string) {
8070
return this.httpClient.get<{ request: FeedbackRequest; draft?: FeedbackRequestDraft }>(
8171
`${this.apiBaseUrl}/feedback/check-request/${token}`,
72+
{ context: BYPASS_AUTHORIZATION_CONTEXT },
8273
);
8374
}
8475

8576
revealRequestTokenId(feedbackId: string) {
86-
return this.authService.withBearerIdToken((headers) =>
87-
this.httpClient.get<TokenObject>(`${this.apiBaseUrl}/feedback/reveal-request-token/${feedbackId}`, { headers }),
88-
);
77+
return this.httpClient.get<TokenObject>(`${this.apiBaseUrl}/feedback/reveal-request-token/${feedbackId}`);
8978
}
9079

9180
giveRequestedDraft(dto: GiveRequestedFeedbackDto) {
92-
return this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/give-requested/draft`, dto);
81+
return this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/give-requested/draft`, dto, {
82+
context: BYPASS_AUTHORIZATION_CONTEXT,
83+
});
9384
}
9485

9586
// The cookie `app-locale-id` must be provided (using the `withCredentials` option)
9687
// so that the server can determine the language to be used in the emails.
9788
giveRequested(dto: GiveRequestedFeedbackDto) {
9889
return this.httpClient
99-
.post<void>(`${this.apiBaseUrl}/feedback/give-requested`, dto, { withCredentials: true })
90+
.post<void>(`${this.apiBaseUrl}/feedback/give-requested`, dto, {
91+
context: BYPASS_AUTHORIZATION_CONTEXT,
92+
withCredentials: true,
93+
})
10094
.pipe(
10195
map(() => true),
10296
catchError(() => of(false)),
@@ -107,30 +101,24 @@ export class FeedbackService {
107101

108102
// Note: use the `FeedbackDraftService` wrapper to access this method
109103
getDraftList() {
110-
return this.authService.withBearerIdToken((headers) =>
111-
this.httpClient.get<FeedbackDraft[]>(`${this.apiBaseUrl}/feedback/give/draft`, { headers }),
112-
);
104+
return this.httpClient.get<FeedbackDraft[]>(`${this.apiBaseUrl}/feedback/give/draft`);
113105
}
114106

115107
// Note: use the `FeedbackDraftService` wrapper to access this method
116108
giveDraft(dto: GiveFeedbackDto) {
117-
return this.authService.withBearerIdToken((headers) =>
118-
this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/give/draft`, dto, { headers }),
119-
);
109+
return this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/give/draft`, dto);
120110
}
121111

122112
// The cookie `app-locale-id` must be provided (using the `withCredentials` option)
123113
// so that the server can determine the language to be used in the emails.
124114
give(dto: GiveFeedbackDto): Observable<IdObject | { id: undefined; error: true; message?: 'invalid_email' }> {
125-
return this.authService.withBearerIdToken((headers) =>
126-
this.httpClient.post<IdObject>(`${this.apiBaseUrl}/feedback/give`, dto, { headers, withCredentials: true }).pipe(
127-
catchError(({ error }: HttpErrorResponse) =>
128-
of({
129-
id: undefined,
130-
error: true as const,
131-
message: error.message,
132-
}),
133-
),
115+
return this.httpClient.post<IdObject>(`${this.apiBaseUrl}/feedback/give`, dto, { withCredentials: true }).pipe(
116+
catchError(({ error }: HttpErrorResponse) =>
117+
of({
118+
id: undefined,
119+
error: true as const,
120+
message: error.message,
121+
}),
134122
),
135123
);
136124
}
@@ -139,50 +127,34 @@ export class FeedbackService {
139127

140128
// Note: use the `FeedbackDraftService` wrapper to access this method
141129
deleteDraft(type: FeedbackDraftType | FeedbackRequestDraftType, receiverEmailOrToken: string) {
142-
return this.authService.withBearerIdToken((headers) =>
143-
this.httpClient.delete<void>(`${this.apiBaseUrl}/feedback/draft/${type}/${receiverEmailOrToken}`, { headers }),
144-
);
130+
return this.httpClient.delete<void>(`${this.apiBaseUrl}/feedback/draft/${type}/${receiverEmailOrToken}`);
145131
}
146132

147133
// ----- Archive feedback (with status "done") -----
148134

149135
archive(feedbackId: string) {
150-
return this.authService.withBearerIdToken((headers) =>
151-
this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/archive/${feedbackId}`, {}, { headers }),
152-
);
136+
return this.httpClient.post<void>(`${this.apiBaseUrl}/feedback/archive/${feedbackId}`, {});
153137
}
154138

155139
// ----- View feedbacks (requested and given) -----
156140

157141
getListMap(types: FeedbackListType[]) {
158-
return this.authService.withBearerIdToken((headers) =>
159-
this.httpClient.get<FeedbackListMap>(`${this.apiBaseUrl}/feedback/list-map`, {
160-
headers,
161-
params: { types: types.join() },
162-
}),
163-
);
142+
return this.httpClient.get<FeedbackListMap>(`${this.apiBaseUrl}/feedback/list-map`, {
143+
params: { types: types.join() },
144+
});
164145
}
165146

166147
getDocument(id: string) {
167-
return this.authService.withBearerIdToken((headers) =>
168-
this.httpClient.get<Feedback | FeedbackRequest | null>(`${this.apiBaseUrl}/feedback/document/${id}`, { headers }),
169-
);
148+
return this.httpClient.get<Feedback | FeedbackRequest | null>(`${this.apiBaseUrl}/feedback/document/${id}`);
170149
}
171150

172151
getSharedFeedbackList(managedEmail: string) {
173-
return this.authService.withBearerIdToken((headers) =>
174-
this.httpClient.get<(FeedbackItem | FeedbackRequestItem)[]>(
175-
`${this.apiBaseUrl}/feedback/shared/list/${managedEmail}`,
176-
{ headers },
177-
),
152+
return this.httpClient.get<(FeedbackItem | FeedbackRequestItem)[]>(
153+
`${this.apiBaseUrl}/feedback/shared/list/${managedEmail}`,
178154
);
179155
}
180156

181157
getSharedFeedbackDocument(id: string) {
182-
return this.authService.withBearerIdToken((headers) =>
183-
this.httpClient.get<Feedback | FeedbackRequest>(`${this.apiBaseUrl}/feedback/shared/document/${id}`, {
184-
headers,
185-
}),
186-
);
158+
return this.httpClient.get<Feedback | FeedbackRequest>(`${this.apiBaseUrl}/feedback/shared/document/${id}`);
187159
}
188160
}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1-
import { HttpContextToken } from '@angular/common/http';
1+
import { HttpContext, HttpContextToken } from '@angular/common/http';
22

33
export const BYPASS_LOADING = new HttpContextToken(() => false);
4+
5+
export const BYPASS_LOADING_CONTEXT = new HttpContext().set(BYPASS_LOADING, true);
Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { HttpClient, HttpContext } from '@angular/common/http';
1+
import { HttpClient } from '@angular/common/http';
22
import { Injectable, inject } from '@angular/core';
33
import { Observable, catchError, of } from 'rxjs';
44
import { environment } from '../../../environments/environment';
5-
import { AuthService } from '../auth/auth.service';
6-
import { BYPASS_LOADING } from '../loading';
5+
import { BYPASS_LOADING_CONTEXT } from '../loading';
76
import { Person } from './people.types';
87

98
@Injectable({
@@ -12,19 +11,14 @@ import { Person } from './people.types';
1211
export class PeopleService {
1312
private httpClient = inject(HttpClient);
1413

15-
private authService = inject(AuthService);
16-
1714
private apiBaseUrl = environment.apiBaseUrl;
1815

1916
search(query: string): Observable<Person[]> {
20-
return this.authService
21-
.withBearerIdToken((headers) =>
22-
this.httpClient.get<Person[]>(`${this.apiBaseUrl}/people/search`, {
23-
headers,
24-
params: { query },
25-
context: new HttpContext().set(BYPASS_LOADING, true),
26-
}),
27-
)
17+
return this.httpClient
18+
.get<Person[]>(`${this.apiBaseUrl}/people/search`, {
19+
params: { query },
20+
context: BYPASS_LOADING_CONTEXT,
21+
})
2822
.pipe(catchError(() => of([])));
2923
}
3024
}

0 commit comments

Comments
 (0)