Skip to content

Commit

Permalink
onboard auth service,Angular template syntax
Browse files Browse the repository at this point in the history
  • Loading branch information
mucsi96 committed Feb 27, 2024
1 parent 9f624d2 commit 8efe6c3
Show file tree
Hide file tree
Showing 24 changed files with 406 additions and 42 deletions.
1 change: 0 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
{
"name": "Ansible, Java & PostgreSQL",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
Expand Down
4 changes: 4 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- Migrate to Angular templates. Get rid of "ngIf=" (in progress)
- Create a dropdown button for sign out (in progress)
- Update all dependencies
- Rebuild dev container using dev container features
2 changes: 2 additions & 0 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,5 @@ To get more help on the Angular CLI use `ng help` or go check out the [Angular C
- https://flowbite.com/docs/components/tables/
- https://hslpicker.com/
- https://softchris.github.io/books/rxjs/cascading-calls/
- https://github.com/search?q=%40ungap%2Fcustom-elements+language%3ATypeScript&type=code&l=TypeScript
- https://github.com/milieuinfo/uigov-web-components?tab=readme-ov-file#documentatie
15 changes: 14 additions & 1 deletion client/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
@if ($vm | async; as vm) {
<header app-header>
<nav>
<a app-header-menu routerLink="/"><h1 app-heading>W6</h1></a>
@if (vm.isSignedIn) {
<a
app-header-menu
[routerLink]="routerTokens.WEEK"
[routerLinkActiveOptions]="{ exact: true }"
routerLinkActive
ariaCurrentWhenActive="page"
>Week</a
Expand All @@ -29,14 +32,24 @@
ariaCurrentWhenActive="page"
>All time</a
>
<span app-badge>{{ vm.userName }}</span>
<button app-button color="red" (click)="onSignout()" type="button">
Sign out
</button>
} @else {
<button app-button (click)="onSignin()" type="button">Sign in</button>
}
</nav>
</header>
<main app-main>
<a app-badge href="/db" *ngIf="$lastBackupTime | async as lastBackupTime" class="lastBackup"
@if ($lastBackupTime | async; as lastBackupTime) {
<a app-badge href="/db" class="lastBackup"
>Last backup {{ lastBackupTime | relativeTime }}</a
>
}
<section class="main">
<router-outlet></router-outlet>
</section>
</main>
}
<ul app-notifications></ul>
31 changes: 29 additions & 2 deletions client/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import { NotificationService } from './common-components/notification.service';
import { BackupService } from './backup/backup.service';
import { RelativeTimePipe } from './utils/relative-time.pipe';
import { provideHttpClient } from '@angular/common/http';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import {
IsActiveMatchOptions,
RouterLink,
RouterLinkActive,
RouterOutlet,
} from '@angular/router';
import { AuthService } from './auth/auth.service';

@Directive({
standalone: true,
Expand All @@ -18,6 +24,18 @@ class MockRouterLink {
routerLink?: string;
}

@Directive({
standalone: true,
selector: '[routerLinkActive]',
})
class MockRouterLinkActive {
@Input()
routerLinkActive?: boolean;

@Input()
routerLinkActiveOptions?: IsActiveMatchOptions;
}

@Component({
standalone: true,
selector: 'router-outlet',
Expand All @@ -29,9 +47,17 @@ async function setup() {
const mockBackupService: jasmine.SpyObj<BackupService> = jasmine.createSpyObj(
['getLastBackupTime']
);
const mockAuthService: jasmine.SpyObj<AuthService> = jasmine.createSpyObj([
'isSignedIn',
'getUserName',
'signin',
'signout',
]);
mockBackupService.getLastBackupTime.and.returnValue(
of(new Date(Date.now() - 5 * 60 * 1000))
);
mockAuthService.getUserName.and.returnValue(of('Igor'));
mockAuthService.isSignedIn.and.returnValue(of(true));

await TestBed.configureTestingModule({
imports: [RelativeTimePipe],
Expand All @@ -40,6 +66,7 @@ async function setup() {
provideHttpClient(),
NotificationService,
{ provide: BackupService, useValue: mockBackupService },
{ provide: AuthService, useValue: mockAuthService },
],
}).compileComponents();

Expand All @@ -48,7 +75,7 @@ async function setup() {
imports: [RouterOutlet, RouterLink, RouterLinkActive],
},
add: {
imports: [MockRouterOutlet, MockRouterLink],
imports: [MockRouterOutlet, MockRouterLink, MockRouterLinkActive],
},
});

Expand Down
26 changes: 25 additions & 1 deletion client/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { MainComponent } from './common-components/main/main.component';
import { NotificationsComponent } from './common-components/notifications/notifications.component';
import { BackupService } from './backup/backup.service';
import { RelativeTimePipe } from './utils/relative-time.pipe';
import { AuthService } from './auth/auth.service';
import { combineLatest, map } from 'rxjs';
import { ButtonComponent } from './common-components/button/button.component';

@Component({
standalone: true,
Expand All @@ -20,6 +23,7 @@ import { RelativeTimePipe } from './utils/relative-time.pipe';
RouterLink,
RouterLinkActive,
RelativeTimePipe,
ButtonComponent,
HeadingComponent,
HeaderComponent,
HeaderMenuComponent,
Expand All @@ -35,6 +39,26 @@ import { RelativeTimePipe } from './utils/relative-time.pipe';
export class AppComponent {
readonly routerTokens = RouterTokens;
readonly $lastBackupTime = this.backupService.getLastBackupTime();
$isSignedIn = this.authService.isSignedIn();
$userName = this.authService.getUserName();

constructor(private readonly backupService: BackupService) {}
$vm = combineLatest([this.$isSignedIn, this.$userName]).pipe(
map(([isSignedIn, userName]) => ({
isSignedIn,
userName,
}))
);

constructor(
private readonly authService: AuthService,
private readonly backupService: BackupService
) {}

onSignin(): void {
this.authService.signin();
}

onSignout(): void {
this.authService.signout();
}
}
2 changes: 2 additions & 0 deletions client/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RideService } from './ride/ride.service';
import { StravaService } from './strava/strava.service';
import { WeightService } from './weight/weight.service';
import { WithingsService } from './withings/withings.service';
import { AuthService } from './auth/auth.service';

function provideECharts(): Provider {
return {
Expand All @@ -35,6 +36,7 @@ export const appConfig: ApplicationConfig = {
),
provideLocation(),
provideECharts(),
AuthService,
StravaService,
RideService,
WeightService,
Expand Down
21 changes: 17 additions & 4 deletions client/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,49 @@
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { SigninComponent } from './auth/signin.component';
import { SigninRedirectCallbackComponent } from './auth/signin-redirect-callback.component';
import { hasRole } from './auth/hasRole';

export enum RouterTokens {
WEEK = 'week',
SIGNIN = 'signin',
SIGNIN_REDIRECT_CALLBACK = 'signin-redirect-callback',
WEEK = '',
MONTH = 'month',
YEAR = 'year',
ALL_TIME = 'all-time',
}

export const routes: Routes = [
{
path: '',
redirectTo: RouterTokens.WEEK,
pathMatch: 'full',
path: RouterTokens.SIGNIN,
component: SigninComponent,
},
{
path: RouterTokens.SIGNIN_REDIRECT_CALLBACK,
component: SigninRedirectCallbackComponent,
},
{
path: RouterTokens.WEEK,
component: HomeComponent,
pathMatch: 'full',
data: { period: 7 },
canActivate: [() => hasRole('user')],
},
{
path: RouterTokens.MONTH,
component: HomeComponent,
data: { period: 30 },
canActivate: [() => hasRole('user')],
},
{
path: RouterTokens.YEAR,
component: HomeComponent,
data: { period: 365 },
canActivate: [() => hasRole('user')],
},
{
path: RouterTokens.ALL_TIME,
component: HomeComponent,
canActivate: [() => hasRole('user')],
},
];
92 changes: 92 additions & 0 deletions client/src/app/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { LocationStrategy } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import {
BehaviorSubject,
catchError,
map,
of,
shareReplay,
switchMap,
tap,
} from 'rxjs';
import { RouterTokens } from '../app.routes';

type UserInfo = { sub: string; name: string; groups: string[] };

@Injectable()
export class AuthService {
redirectUri =
location.origin +
this.locationStrategy.prepareExternalUrl(
RouterTokens.SIGNIN_REDIRECT_CALLBACK
);
signinsAndSignouts = new BehaviorSubject<RouterTokens[] | undefined>(
undefined
);
$userInfo = this.signinsAndSignouts.asObservable().pipe(
switchMap((nextRoute) =>
this.http.get<UserInfo>('/auth/user-info').pipe(
catchError(() => {
return of({} as UserInfo);
}),
tap(() => nextRoute && this.router.navigate(nextRoute))
)
),
shareReplay(1)
);

constructor(
private readonly http: HttpClient,
private readonly router: Router,
private readonly locationStrategy: LocationStrategy
) {}

getUserInfo() {
return this.$userInfo;
}

async signin() {
this.http
.post<{
authorizationUrl: string;
}>('/auth/authorize', { redirectUri: this.redirectUri })
.subscribe(({ authorizationUrl }) => {
location.href = authorizationUrl;
});
}

async handleSigninRedirectCallback() {
this.http
.post<void>('/auth/get-token', {
callbackUrl: location.href.toString(),
redirectUri: this.redirectUri,
})
.subscribe(() => this.signinsAndSignouts.next([RouterTokens.WEEK]));
}

isSignedIn() {
return this.getUserInfo().pipe(map((userInfo) => !!userInfo.sub));
}

getUserName() {
return this.getUserInfo().pipe(map((userInfo) => userInfo.name));
}

getRoles() {
return this.getUserInfo().pipe(
map((userInfo) => (userInfo.groups ?? []) as string[])
);
}

hasRole(role: string) {
return this.getRoles().pipe(map((roles) => roles.includes(role)));
}

signout() {
this.http
.post('/auth/logout', {})
.subscribe(() => this.signinsAndSignouts.next([RouterTokens.SIGNIN]));
}
}
20 changes: 20 additions & 0 deletions client/src/app/auth/hasRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { inject } from '@angular/core';
import { AuthService } from './auth.service';
import { Router } from '@angular/router';
import { tap } from 'rxjs';
import { RouterTokens } from '../app.routes';

export function hasRole(role: string) {
const authService = inject(AuthService);
const router = inject(Router);

return authService.hasRole(role).pipe(
tap((authorized) => {
if (!authorized) {
router.navigate([RouterTokens.SIGNIN]);
}

return authorized;
})
);
}
15 changes: 15 additions & 0 deletions client/src/app/auth/signin-redirect-callback.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
import { AuthService } from './auth.service';

@Component({
standalone: true,
selector: 'signin-redirect-callback',
template: '',
})
export class SigninRedirectCallbackComponent implements OnInit {
constructor(private readonly authService: AuthService) {}

ngOnInit(): void {
this.authService.handleSigninRedirectCallback();
}
}
9 changes: 9 additions & 0 deletions client/src/app/auth/signin.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Component } from '@angular/core';

@Component({
standalone: true,
selector: 'signin',
imports: [],
template: '',
})
export class SigninComponent {}
5 changes: 5 additions & 0 deletions client/src/app/auth/user-info.component.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
nav {
display: grid;
grid-template-columns: 1fr auto;
gap: 20px;
}
11 changes: 11 additions & 0 deletions client/src/app/auth/user-info.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@if ($vm | async; as vm) {
<nav>
@if (vm.isSignedIn) {
<h1 app-heading>Hello {{ vm.userName }}!</h1>
<button app-button color="red" (click)="onSignout()" type="button">Sign out</button>
} @else {
<h1 app-heading>Demo</h1>
<button app-button (click)="onSignin()" type="button">Sign in</button>
}
</nav>
}
Loading

0 comments on commit 8efe6c3

Please sign in to comment.