Skip to content

Commit 8efe6c3

Browse files
committed
onboard auth service,Angular template syntax
1 parent 9f624d2 commit 8efe6c3

24 files changed

+406
-42
lines changed

.devcontainer/devcontainer.json

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
{
2-
"name": "Ansible, Java & PostgreSQL",
32
"dockerComposeFile": "docker-compose.yml",
43
"service": "app",
54
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",

TODO.md

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- Migrate to Angular templates. Get rid of "ngIf=" (in progress)
2+
- Create a dropdown button for sign out (in progress)
3+
- Update all dependencies
4+
- Rebuild dev container using dev container features

client/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,5 @@ To get more help on the Angular CLI use `ng help` or go check out the [Angular C
3333
- https://flowbite.com/docs/components/tables/
3434
- https://hslpicker.com/
3535
- https://softchris.github.io/books/rxjs/cascading-calls/
36+
- https://github.com/search?q=%40ungap%2Fcustom-elements+language%3ATypeScript&type=code&l=TypeScript
37+
- https://github.com/milieuinfo/uigov-web-components?tab=readme-ov-file#documentatie

client/src/app/app.component.html

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
@if ($vm | async; as vm) {
12
<header app-header>
23
<nav>
34
<a app-header-menu routerLink="/"><h1 app-heading>W6</h1></a>
5+
@if (vm.isSignedIn) {
46
<a
57
app-header-menu
68
[routerLink]="routerTokens.WEEK"
9+
[routerLinkActiveOptions]="{ exact: true }"
710
routerLinkActive
811
ariaCurrentWhenActive="page"
912
>Week</a
@@ -29,14 +32,24 @@
2932
ariaCurrentWhenActive="page"
3033
>All time</a
3134
>
35+
<span app-badge>{{ vm.userName }}</span>
36+
<button app-button color="red" (click)="onSignout()" type="button">
37+
Sign out
38+
</button>
39+
} @else {
40+
<button app-button (click)="onSignin()" type="button">Sign in</button>
41+
}
3242
</nav>
3343
</header>
3444
<main app-main>
35-
<a app-badge href="/db" *ngIf="$lastBackupTime | async as lastBackupTime" class="lastBackup"
45+
@if ($lastBackupTime | async; as lastBackupTime) {
46+
<a app-badge href="/db" class="lastBackup"
3647
>Last backup {{ lastBackupTime | relativeTime }}</a
3748
>
49+
}
3850
<section class="main">
3951
<router-outlet></router-outlet>
4052
</section>
4153
</main>
54+
}
4255
<ul app-notifications></ul>

client/src/app/app.component.spec.ts

+29-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import { NotificationService } from './common-components/notification.service';
77
import { BackupService } from './backup/backup.service';
88
import { RelativeTimePipe } from './utils/relative-time.pipe';
99
import { provideHttpClient } from '@angular/common/http';
10-
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
10+
import {
11+
IsActiveMatchOptions,
12+
RouterLink,
13+
RouterLinkActive,
14+
RouterOutlet,
15+
} from '@angular/router';
16+
import { AuthService } from './auth/auth.service';
1117

1218
@Directive({
1319
standalone: true,
@@ -18,6 +24,18 @@ class MockRouterLink {
1824
routerLink?: string;
1925
}
2026

27+
@Directive({
28+
standalone: true,
29+
selector: '[routerLinkActive]',
30+
})
31+
class MockRouterLinkActive {
32+
@Input()
33+
routerLinkActive?: boolean;
34+
35+
@Input()
36+
routerLinkActiveOptions?: IsActiveMatchOptions;
37+
}
38+
2139
@Component({
2240
standalone: true,
2341
selector: 'router-outlet',
@@ -29,9 +47,17 @@ async function setup() {
2947
const mockBackupService: jasmine.SpyObj<BackupService> = jasmine.createSpyObj(
3048
['getLastBackupTime']
3149
);
50+
const mockAuthService: jasmine.SpyObj<AuthService> = jasmine.createSpyObj([
51+
'isSignedIn',
52+
'getUserName',
53+
'signin',
54+
'signout',
55+
]);
3256
mockBackupService.getLastBackupTime.and.returnValue(
3357
of(new Date(Date.now() - 5 * 60 * 1000))
3458
);
59+
mockAuthService.getUserName.and.returnValue(of('Igor'));
60+
mockAuthService.isSignedIn.and.returnValue(of(true));
3561

3662
await TestBed.configureTestingModule({
3763
imports: [RelativeTimePipe],
@@ -40,6 +66,7 @@ async function setup() {
4066
provideHttpClient(),
4167
NotificationService,
4268
{ provide: BackupService, useValue: mockBackupService },
69+
{ provide: AuthService, useValue: mockAuthService },
4370
],
4471
}).compileComponents();
4572

@@ -48,7 +75,7 @@ async function setup() {
4875
imports: [RouterOutlet, RouterLink, RouterLinkActive],
4976
},
5077
add: {
51-
imports: [MockRouterOutlet, MockRouterLink],
78+
imports: [MockRouterOutlet, MockRouterLink, MockRouterLinkActive],
5279
},
5380
});
5481

client/src/app/app.component.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { MainComponent } from './common-components/main/main.component';
1111
import { NotificationsComponent } from './common-components/notifications/notifications.component';
1212
import { BackupService } from './backup/backup.service';
1313
import { RelativeTimePipe } from './utils/relative-time.pipe';
14+
import { AuthService } from './auth/auth.service';
15+
import { combineLatest, map } from 'rxjs';
16+
import { ButtonComponent } from './common-components/button/button.component';
1417

1518
@Component({
1619
standalone: true,
@@ -20,6 +23,7 @@ import { RelativeTimePipe } from './utils/relative-time.pipe';
2023
RouterLink,
2124
RouterLinkActive,
2225
RelativeTimePipe,
26+
ButtonComponent,
2327
HeadingComponent,
2428
HeaderComponent,
2529
HeaderMenuComponent,
@@ -35,6 +39,26 @@ import { RelativeTimePipe } from './utils/relative-time.pipe';
3539
export class AppComponent {
3640
readonly routerTokens = RouterTokens;
3741
readonly $lastBackupTime = this.backupService.getLastBackupTime();
42+
$isSignedIn = this.authService.isSignedIn();
43+
$userName = this.authService.getUserName();
3844

39-
constructor(private readonly backupService: BackupService) {}
45+
$vm = combineLatest([this.$isSignedIn, this.$userName]).pipe(
46+
map(([isSignedIn, userName]) => ({
47+
isSignedIn,
48+
userName,
49+
}))
50+
);
51+
52+
constructor(
53+
private readonly authService: AuthService,
54+
private readonly backupService: BackupService
55+
) {}
56+
57+
onSignin(): void {
58+
this.authService.signin();
59+
}
60+
61+
onSignout(): void {
62+
this.authService.signout();
63+
}
4064
}

client/src/app/app.config.ts

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { RideService } from './ride/ride.service';
1414
import { StravaService } from './strava/strava.service';
1515
import { WeightService } from './weight/weight.service';
1616
import { WithingsService } from './withings/withings.service';
17+
import { AuthService } from './auth/auth.service';
1718

1819
function provideECharts(): Provider {
1920
return {
@@ -35,6 +36,7 @@ export const appConfig: ApplicationConfig = {
3536
),
3637
provideLocation(),
3738
provideECharts(),
39+
AuthService,
3840
StravaService,
3941
RideService,
4042
WeightService,

client/src/app/app.routes.ts

+17-4
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,49 @@
11
import { Routes } from '@angular/router';
22
import { HomeComponent } from './home/home.component';
3+
import { SigninComponent } from './auth/signin.component';
4+
import { SigninRedirectCallbackComponent } from './auth/signin-redirect-callback.component';
5+
import { hasRole } from './auth/hasRole';
36

47
export enum RouterTokens {
5-
WEEK = 'week',
8+
SIGNIN = 'signin',
9+
SIGNIN_REDIRECT_CALLBACK = 'signin-redirect-callback',
10+
WEEK = '',
611
MONTH = 'month',
712
YEAR = 'year',
813
ALL_TIME = 'all-time',
914
}
1015

1116
export const routes: Routes = [
1217
{
13-
path: '',
14-
redirectTo: RouterTokens.WEEK,
15-
pathMatch: 'full',
18+
path: RouterTokens.SIGNIN,
19+
component: SigninComponent,
20+
},
21+
{
22+
path: RouterTokens.SIGNIN_REDIRECT_CALLBACK,
23+
component: SigninRedirectCallbackComponent,
1624
},
1725
{
1826
path: RouterTokens.WEEK,
1927
component: HomeComponent,
28+
pathMatch: 'full',
2029
data: { period: 7 },
30+
canActivate: [() => hasRole('user')],
2131
},
2232
{
2333
path: RouterTokens.MONTH,
2434
component: HomeComponent,
2535
data: { period: 30 },
36+
canActivate: [() => hasRole('user')],
2637
},
2738
{
2839
path: RouterTokens.YEAR,
2940
component: HomeComponent,
3041
data: { period: 365 },
42+
canActivate: [() => hasRole('user')],
3143
},
3244
{
3345
path: RouterTokens.ALL_TIME,
3446
component: HomeComponent,
47+
canActivate: [() => hasRole('user')],
3548
},
3649
];

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

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { LocationStrategy } from '@angular/common';
2+
import { HttpClient } from '@angular/common/http';
3+
import { Injectable } from '@angular/core';
4+
import { Router } from '@angular/router';
5+
import {
6+
BehaviorSubject,
7+
catchError,
8+
map,
9+
of,
10+
shareReplay,
11+
switchMap,
12+
tap,
13+
} from 'rxjs';
14+
import { RouterTokens } from '../app.routes';
15+
16+
type UserInfo = { sub: string; name: string; groups: string[] };
17+
18+
@Injectable()
19+
export class AuthService {
20+
redirectUri =
21+
location.origin +
22+
this.locationStrategy.prepareExternalUrl(
23+
RouterTokens.SIGNIN_REDIRECT_CALLBACK
24+
);
25+
signinsAndSignouts = new BehaviorSubject<RouterTokens[] | undefined>(
26+
undefined
27+
);
28+
$userInfo = this.signinsAndSignouts.asObservable().pipe(
29+
switchMap((nextRoute) =>
30+
this.http.get<UserInfo>('/auth/user-info').pipe(
31+
catchError(() => {
32+
return of({} as UserInfo);
33+
}),
34+
tap(() => nextRoute && this.router.navigate(nextRoute))
35+
)
36+
),
37+
shareReplay(1)
38+
);
39+
40+
constructor(
41+
private readonly http: HttpClient,
42+
private readonly router: Router,
43+
private readonly locationStrategy: LocationStrategy
44+
) {}
45+
46+
getUserInfo() {
47+
return this.$userInfo;
48+
}
49+
50+
async signin() {
51+
this.http
52+
.post<{
53+
authorizationUrl: string;
54+
}>('/auth/authorize', { redirectUri: this.redirectUri })
55+
.subscribe(({ authorizationUrl }) => {
56+
location.href = authorizationUrl;
57+
});
58+
}
59+
60+
async handleSigninRedirectCallback() {
61+
this.http
62+
.post<void>('/auth/get-token', {
63+
callbackUrl: location.href.toString(),
64+
redirectUri: this.redirectUri,
65+
})
66+
.subscribe(() => this.signinsAndSignouts.next([RouterTokens.WEEK]));
67+
}
68+
69+
isSignedIn() {
70+
return this.getUserInfo().pipe(map((userInfo) => !!userInfo.sub));
71+
}
72+
73+
getUserName() {
74+
return this.getUserInfo().pipe(map((userInfo) => userInfo.name));
75+
}
76+
77+
getRoles() {
78+
return this.getUserInfo().pipe(
79+
map((userInfo) => (userInfo.groups ?? []) as string[])
80+
);
81+
}
82+
83+
hasRole(role: string) {
84+
return this.getRoles().pipe(map((roles) => roles.includes(role)));
85+
}
86+
87+
signout() {
88+
this.http
89+
.post('/auth/logout', {})
90+
.subscribe(() => this.signinsAndSignouts.next([RouterTokens.SIGNIN]));
91+
}
92+
}

client/src/app/auth/hasRole.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { inject } from '@angular/core';
2+
import { AuthService } from './auth.service';
3+
import { Router } from '@angular/router';
4+
import { tap } from 'rxjs';
5+
import { RouterTokens } from '../app.routes';
6+
7+
export function hasRole(role: string) {
8+
const authService = inject(AuthService);
9+
const router = inject(Router);
10+
11+
return authService.hasRole(role).pipe(
12+
tap((authorized) => {
13+
if (!authorized) {
14+
router.navigate([RouterTokens.SIGNIN]);
15+
}
16+
17+
return authorized;
18+
})
19+
);
20+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Component, OnInit } from '@angular/core';
2+
import { AuthService } from './auth.service';
3+
4+
@Component({
5+
standalone: true,
6+
selector: 'signin-redirect-callback',
7+
template: '',
8+
})
9+
export class SigninRedirectCallbackComponent implements OnInit {
10+
constructor(private readonly authService: AuthService) {}
11+
12+
ngOnInit(): void {
13+
this.authService.handleSigninRedirectCallback();
14+
}
15+
}
+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Component } from '@angular/core';
2+
3+
@Component({
4+
standalone: true,
5+
selector: 'signin',
6+
imports: [],
7+
template: '',
8+
})
9+
export class SigninComponent {}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
nav {
2+
display: grid;
3+
grid-template-columns: 1fr auto;
4+
gap: 20px;
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@if ($vm | async; as vm) {
2+
<nav>
3+
@if (vm.isSignedIn) {
4+
<h1 app-heading>Hello {{ vm.userName }}!</h1>
5+
<button app-button color="red" (click)="onSignout()" type="button">Sign out</button>
6+
} @else {
7+
<h1 app-heading>Demo</h1>
8+
<button app-button (click)="onSignin()" type="button">Sign in</button>
9+
}
10+
</nav>
11+
}

0 commit comments

Comments
 (0)