diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 6bbdeea..1ec3385 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -13,6 +13,17 @@ export const AuthGuard = () => { return auth; }; +export const VerifiedGuard = () => { + const authService = inject(AuthService); + const router = inject(Router); + + const user = authService.getUser(); + if (!user?.verified) { + router.navigate(['/verification']); + } + return true; +}; + export const AvailableGuard = () => { const authService = inject(AuthService); return authService.isAvailable$(); @@ -44,12 +55,12 @@ export const routes: Routes = [ { path: 'ride-online', loadComponent: () => import('./pages/ride-online/ride-online.component').then(m => m.RideOnlineComponent), - canActivate: [AuthGuard, AvailableGuard] + canActivate: [AuthGuard, VerifiedGuard, AvailableGuard] }, { path: 'server/:id', loadComponent: () => import('./pages/server/server.component').then(m => m.ServerComponent), - canActivate: [AuthGuard, AvailableGuard] + canActivate: [AuthGuard, VerifiedGuard, AvailableGuard] }, { path: 'local-event', @@ -58,13 +69,13 @@ export const routes: Routes = [ { path: 'online-event/:id', loadComponent: () => import('./pages/online-event/online-event.component').then(m => m.OnlineEventComponent), - canActivate: [AuthGuard, AvailableGuard] + canActivate: [AuthGuard, VerifiedGuard, AvailableGuard] }, { path: 'create-online-event/:serverId', loadComponent: () => import('./pages/create-online-event/create-online-event.component').then(m => m.CreateOnlineEventComponent), - canActivate: [AuthGuard, AvailableGuard] + canActivate: [AuthGuard, VerifiedGuard, AvailableGuard] }, { path: 'manage-tracks', @@ -75,7 +86,7 @@ export const routes: Routes = [ path: 'manage-online-tracks', loadComponent: () => import('./pages/manage-tracks/manage-tracks.component').then(m => m.ManageTracksComponent), data: { type: 'online' }, - canActivate: [AuthGuard, AvailableGuard] + canActivate: [AuthGuard, VerifiedGuard, AvailableGuard] }, { path: 'race', @@ -86,7 +97,7 @@ export const routes: Routes = [ path: 'online-race/:eventId', loadComponent: () => import('./pages/race/race.component').then(m => m.RaceComponent), data: { type: 'online' }, - canActivate: [AuthGuard, AvailableGuard] + canActivate: [AuthGuard, VerifiedGuard, AvailableGuard] }, { path: 'reset-password', @@ -100,5 +111,15 @@ export const routes: Routes = [ import('./pages/reset-password/reset-password.component').then(m => m.ResetPasswordComponent), canActivate: [AvailableGuard] }, + { + path: 'verification', + loadComponent: () => import('./pages/verification/verification.component').then(m => m.VerificationComponent), + canActivate: [AvailableGuard] + }, + { + path: 'verification/:token', + loadComponent: () => import('./pages/verification/verification.component').then(m => m.VerificationComponent), + canActivate: [AvailableGuard] + }, { path: '**', pathMatch: 'full', redirectTo: '' } ]; diff --git a/src/app/common/models/user.ts b/src/app/common/models/user.ts index 7f13b1e..5ea97c3 100644 --- a/src/app/common/models/user.ts +++ b/src/app/common/models/user.ts @@ -4,17 +4,19 @@ export class User { public id: string; public name: string; public email: string; + public verified: boolean; public premium: boolean; - constructor(id: string, name: string, email: string, premium: boolean) { + constructor(id: string, name: string, email: string, verified: boolean, premium: boolean) { this.id = id; this.name = name; this.email = email; + this.verified = verified; this.premium = premium; } public static buildFromRecord(record: RecordModel): User { // biome-ignore lint/complexity/useLiteralKeys: - return new User(record.id, record['name'], record['email'], record['premium']); + return new User(record.id, record['name'], record['email'], record['verified'], record['premium']); } } diff --git a/src/app/common/services/auth.service.ts b/src/app/common/services/auth.service.ts index e74b5f8..8f865c0 100644 --- a/src/app/common/services/auth.service.ts +++ b/src/app/common/services/auth.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { catchError, from, map, of, type Observable } from 'rxjs'; +import { catchError, from, map, of, switchMap, type Observable } from 'rxjs'; import { environment } from '../../../environments/environment'; import type { RecordAuthResponse, RecordModel } from 'pocketbase'; import { User } from '../models/user'; @@ -49,7 +49,8 @@ export class AuthService { map(newUser => { newUser.email = email; return newUser; - }) + }), + switchMap(user => of(this.sendVerificationMail(user.email)).pipe(map(() => user))) ); } @@ -59,6 +60,13 @@ export class AuthService { : null; } + public getRefreshedUser$(): Observable { + return from(environment.pb.collection('users').authRefresh()).pipe( + map(() => User.buildFromRecord(environment.pb.authStore.record as RecordModel)), + catchError(() => of(null)) + ); + } + public sendResetPasswordMail(email: string): Promise { return environment.pb.collection('users').requestPasswordReset(email); } @@ -66,4 +74,15 @@ export class AuthService { public changePassword(password: string, passwordConfirm: string, token: string): Promise { return environment.pb.collection('users').confirmPasswordReset(token, password, passwordConfirm); } + + public verifyUser(token: string): Promise { + return environment.pb.collection('users').confirmVerification(token); + } + + private sendVerificationMail(email: string): Promise { + return environment.pb + .collection('users') + .requestVerification(email) + .catch(() => false); + } } diff --git a/src/app/pages/profile/profile.component.html b/src/app/pages/profile/profile.component.html index b78c9c0..99ad8c2 100644 --- a/src/app/pages/profile/profile.component.html +++ b/src/app/pages/profile/profile.component.html @@ -5,17 +5,40 @@
Profile
-
-
{{ user?.name }}
- +
+
{{ user()?.name }}
+
- @if(!user?.premium) { - -
Profile upgrade will soon be available to all riders
+
Verification status
+ @if(user()?.verified) { +
+ Your profile is verified. +
+ } @else { +
+ You still need to complete the verification process. +
+ + } +
Profile type
+ @if(user()?.premium) { +
+ You ride with a premium profile. +
+ } @else { } @if(!user()?.premium) { +
+ You’re currently riding with a standard profile. +
+
+ Upgrade to Premium to unlock the full community experience! Enjoy + creating your own servers, events and tracks to ride the slopes with + your friends like never before. +
+ }
diff --git a/src/app/pages/profile/profile.component.scss b/src/app/pages/profile/profile.component.scss index 62c3cc4..835d57a 100644 --- a/src/app/pages/profile/profile.component.scss +++ b/src/app/pages/profile/profile.component.scss @@ -8,12 +8,12 @@ display: flex; justify-content: center; flex-direction: column; - padding: 2rem; - gap: 1.5rem; + padding: 1rem; + gap: 1rem; background-color: var(--color-primary-lightest); color: var(--color-primary); align-items: center; - border-radius: var(--border-radius-large); + border-radius: var(--border-radius-large); margin-bottom: 1rem; &.premium { @@ -31,6 +31,10 @@ font-weight: 500; } + .email { + font-size: var(--text-small); + } + .account-badge { font-size: var(--text-small); font-weight: 500; diff --git a/src/app/pages/profile/profile.component.ts b/src/app/pages/profile/profile.component.ts index 27eb9bb..5ba8087 100644 --- a/src/app/pages/profile/profile.component.ts +++ b/src/app/pages/profile/profile.component.ts @@ -1,14 +1,15 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { AuthService } from '../../common/services/auth.service'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { ToolbarComponent } from '../../common/components/toolbar/toolbar.component'; import { ButtonIconComponent } from '../../common/components/button-icon/button-icon.component'; import { Location } from '@angular/common'; +import { toSignal } from '@angular/core/rxjs-interop'; @Component({ selector: 'app-profile', standalone: true, - imports: [ButtonIconComponent, ToolbarComponent], + imports: [ButtonIconComponent, RouterLink, ToolbarComponent], templateUrl: './profile.component.html', styleUrl: './profile.component.scss', changeDetection: ChangeDetectionStrategy.OnPush @@ -19,7 +20,7 @@ export class ProfileComponent { private readonly router = inject(Router); private readonly location = inject(Location); - protected user = this.authService.getUser(); + protected user = toSignal(this.authService.getRefreshedUser$()); protected logout(): void { this.authService.logout(); diff --git a/src/app/pages/verification/verification.component.html b/src/app/pages/verification/verification.component.html new file mode 100644 index 0000000..11f3d33 --- /dev/null +++ b/src/app/pages/verification/verification.component.html @@ -0,0 +1,45 @@ + + + + + +
+ @if(token && verificationDone()) { +
+ mail sent icon +
+
Profile verified
+
+ Thank you for completing the verification process! +
+
+ Welcome to the mountains, you’re now officially a RetroSki community + rider! Enjoy online adventures and connect with your friends. +
+
+ Start your online riding experience by joining your friends on their + servers. Or take your experience to the next level by + upgrading to a premium profile + to create your own servers, host events, and design custom tracks. +
+ + } @else { +
+ mail sent icon +
+
Almost there!
+
+ Thank you for joining the RetroSki online community! We’re thrilled to + have you on board. +
+
+ A verification email has been sent to your inbox. Please check your + email and click the verification button to activate your account. Once + verified, you’ll be ready to hit the slopes and ride with us! +
+ } +
diff --git a/src/app/pages/verification/verification.component.scss b/src/app/pages/verification/verification.component.scss new file mode 100644 index 0000000..6d0d248 --- /dev/null +++ b/src/app/pages/verification/verification.component.scss @@ -0,0 +1,6 @@ +:host { + display: flex; + flex: 1 1 auto; + flex-direction: column; + padding: 1rem; +} diff --git a/src/app/pages/verification/verification.component.ts b/src/app/pages/verification/verification.component.ts new file mode 100644 index 0000000..525b492 --- /dev/null +++ b/src/app/pages/verification/verification.component.ts @@ -0,0 +1,30 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { ButtonIconComponent } from '../../common/components/button-icon/button-icon.component'; +import { ToolbarComponent } from '../../common/components/toolbar/toolbar.component'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { AuthService } from '../../common/services/auth.service'; +import { from, type Observable, of } from 'rxjs'; +import { toSignal } from '@angular/core/rxjs-interop'; +import type { User } from '../../common/models/user'; + +@Component({ + selector: 'app-verification', + imports: [ButtonIconComponent, RouterLink, ToolbarComponent], + templateUrl: './verification.component.html', + styleUrl: './verification.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class VerificationComponent { + private route = inject(ActivatedRoute); + private authService = inject(AuthService); + + protected readonly token = (this.route.snapshot.params as { token: string }).token; + protected verificationDone = toSignal(this.verifyToken(this.token)); + + private verifyToken(token: string): Observable { + if (token) { + return from(this.authService.verifyUser(token)); + } + return of(false); + } +} diff --git a/src/assets/icons/safety_check.svg b/src/assets/icons/safety_check.svg new file mode 100644 index 0000000..3887b36 --- /dev/null +++ b/src/assets/icons/safety_check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/verified.svg b/src/assets/icons/verified.svg new file mode 100644 index 0000000..ff404e4 --- /dev/null +++ b/src/assets/icons/verified.svg @@ -0,0 +1 @@ + \ No newline at end of file