diff --git a/src/client/src/app/app.config.ts b/src/client/src/app/app.config.ts index 007f1ec..ae97e9c 100644 --- a/src/client/src/app/app.config.ts +++ b/src/client/src/app/app.config.ts @@ -1,8 +1,10 @@ -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { ApplicationConfig, provideExperimentalZonelessChangeDetection, isDevMode, + APP_INITIALIZER, + inject, } from '@angular/core'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; @@ -20,7 +22,9 @@ import { provideUsersState } from './+state/users'; import { provideApi } from './api/services'; import { routes } from './app.routes'; import { environment } from './environments/environment'; -import { provideAuth } from './services/auth.service'; +import { AuthInterceptor } from './services/auth.interceptor'; +import { AuthService } from './services/auth.service'; +import { WebPushService } from './services/web-push.service'; export const appConfig: ApplicationConfig = { providers: [ @@ -41,10 +45,26 @@ export const appConfig: ApplicationConfig = { providePlayerEventsState(), provideUserSettingsState(), environment.getProviders(), - provideAuth(), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000', }), + { + provide: HTTP_INTERCEPTORS, + multi: true, + useClass: AuthInterceptor, + }, + { + provide: APP_INITIALIZER, + multi: true, + useFactory: () => { + const authService = inject(AuthService); + const webPushService = inject(WebPushService); + return () => { + authService.init(); + webPushService.init(); + }; + }, + }, ], }; diff --git a/src/client/src/app/components/app/app.component.ts b/src/client/src/app/components/app/app.component.ts index 89d12e2..05a71bc 100644 --- a/src/client/src/app/components/app/app.component.ts +++ b/src/client/src/app/components/app/app.component.ts @@ -45,7 +45,6 @@ export class AppComponent { const primengConfig = inject(PrimeNGConfig); primengConfig.ripple = true; - this._webPushService.init(); this._webPushService.onPromptNotification .pipe(takeUntilDestroyed()) .subscribe(() => this._notificationPromptDialog().open()); diff --git a/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.html b/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.html index 6fb5a12..68baab7 100644 --- a/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.html +++ b/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.html @@ -2,7 +2,7 @@ [visible]="visible()" (visibleChange)="visible.set($event)" [modal]="true" - [closable]="!isLoading()" + [closable]="false" [draggable]="false" > diff --git a/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.ts b/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.ts index 04b762b..9d3c487 100644 --- a/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.ts +++ b/src/client/src/app/components/app/notification-prompt-dialog/notification-prompt-dialog.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/cor import { ButtonModule } from 'primeng/button'; import { DialogModule } from 'primeng/dialog'; -import { setHasRejectedPush } from '../../../services/storage'; +import { setHasConfiguredPush } from '../../../services/storage'; import { TranslateService } from '../../../services/translate.service'; import { WebPushService } from '../../../services/web-push.service'; @@ -35,7 +35,7 @@ export class NotificationPromptDialogComponent { } protected onReject() { - setHasRejectedPush(true); + setHasConfiguredPush(true); this.visible.set(false); } } diff --git a/src/client/src/app/components/user-settings/user-settings.component.html b/src/client/src/app/components/user-settings/user-settings.component.html index 7f3a46b..40c744c 100644 --- a/src/client/src/app/components/user-settings/user-settings.component.html +++ b/src/client/src/app/components/user-settings/user-settings.component.html @@ -72,24 +72,31 @@

{{ translations.nav_settings() }}

-
-
- {{ translations.settings_notifications_eanbled() }} -
-
- - +
+
+
+ {{ translations.settings_notifications_eanbled() }} +
+
+ + +
+ @if (!notificationsPossible) { + {{ + translations.settings_notifications_errors_notSupported() + }} + }
@if (settings.enableNotifications && notificationsEnabled()) { @@ -131,7 +138,8 @@

{{ translations.settings_notifications_notify_title() }}

-
+ + -
+ +
}
diff --git a/src/client/src/app/i18n/de.json b/src/client/src/app/i18n/de.json index 304c8bd..6d3803a 100644 --- a/src/client/src/app/i18n/de.json +++ b/src/client/src/app/i18n/de.json @@ -41,6 +41,7 @@ "enabledOnAllDevices": "Auf allen Geräten aktiviert", "eanbled": "Auf diesem Gerät aktiviert", "errors": { + "notSupported": "Benachrichtigungen werden von diesem Browser nicht unterstützt.", "notGranted": { "title": "Erlaubnis über Benachrichtigungen nicht erteilt", "description": "Um Benachrichtigungen erhalten zu können, musst du dies in den Systemeinstellungen aktivieren, oder die App erneut installieren." diff --git a/src/client/src/app/i18n/en.json b/src/client/src/app/i18n/en.json index 358e295..70177b0 100644 --- a/src/client/src/app/i18n/en.json +++ b/src/client/src/app/i18n/en.json @@ -41,6 +41,7 @@ "enabledOnAllDevices": "Enabled on all devices", "eanbled": "Enabled on this device", "errors": { + "notSupported": "Notifications are not supported by this browser.", "notGranted": { "title": "Notification permission not granted", "description": "To receive notifications, you must activate this in the system settings or reinstall the app." diff --git a/src/client/src/app/services/auth.guard.ts b/src/client/src/app/services/auth.guard.ts index a61c6a8..c8f3063 100644 --- a/src/client/src/app/services/auth.guard.ts +++ b/src/client/src/app/services/auth.guard.ts @@ -3,7 +3,7 @@ import { Router, RouterStateSnapshot } from '@angular/router'; import { AuthService } from './auth.service'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class AuthGuard { private readonly _router = inject(Router); private readonly _authService = inject(AuthService); diff --git a/src/client/src/app/services/auth.interceptor.ts b/src/client/src/app/services/auth.interceptor.ts index 27c2fd0..06e231d 100644 --- a/src/client/src/app/services/auth.interceptor.ts +++ b/src/client/src/app/services/auth.interceptor.ts @@ -1,9 +1,10 @@ import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { inject } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { AuthService } from './auth.service'; +@Injectable() export class AuthInterceptor implements HttpInterceptor { private readonly _authService = inject(AuthService); diff --git a/src/client/src/app/services/auth.service.ts b/src/client/src/app/services/auth.service.ts index 6de791a..c4d09af 100644 --- a/src/client/src/app/services/auth.service.ts +++ b/src/client/src/app/services/auth.service.ts @@ -1,6 +1,5 @@ -import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { - APP_INITIALIZER, + EventEmitter, Injectable, OnDestroy, computed, @@ -10,8 +9,6 @@ import { } from '@angular/core'; import { Router } from '@angular/router'; -import { AuthGuard } from './auth.guard'; -import { AuthInterceptor } from './auth.interceptor'; import { AuthTokenInfo, getAuthTokenInfo, @@ -19,26 +16,25 @@ import { setAuthTokenInfo, setLoginToken, } from './storage'; -import { WebPushService } from './web-push.service'; import { AuthenticationService } from '../api/services'; import { environment } from '../environments/environment'; import { assertBody } from '../utils/http.utils'; export type SignInResult = 'success' | 'invalid-token' | 'error'; -@Injectable() +@Injectable({ providedIn: 'root' }) export class AuthService implements OnDestroy { private readonly _api = inject(AuthenticationService); private readonly _router = inject(Router); - private readonly _webPushService = inject(WebPushService); private readonly _token = signal(undefined); private _tokenRefreshTimeout?: any; - public token = this._token.asReadonly(); - public user = computed(() => this.token()?.user); - public isAuthorized = computed(() => !!this._token()); + public readonly token = this._token.asReadonly(); + public readonly user = computed(() => this.token()?.user); + public readonly isAuthorized = computed(() => !!this._token()); + public readonly onBeforeSignOut = new EventEmitter(); constructor() { effect(() => { @@ -53,7 +49,7 @@ export class AuthService implements OnDestroy { }); } - public async initialize() { + public async init() { if (this._token() !== undefined) { throw new Error('AuthService already initialized'); } @@ -113,7 +109,7 @@ export class AuthService implements OnDestroy { public async signOut() { if (!environment.authenticationRequired) return; - await this._webPushService.disable(true); + this.onBeforeSignOut.emit(); setLoginToken(null); this._token.set(null); @@ -143,21 +139,3 @@ export class AuthService implements OnDestroy { this._tokenRefreshTimeout = undefined; } } - -export function provideAuth() { - return [ - AuthService, - AuthGuard, - { - provide: HTTP_INTERCEPTORS, - multi: true, - useClass: AuthInterceptor, - }, - { - provide: APP_INITIALIZER, - multi: true, - useFactory: (authService: AuthService) => () => authService.initialize(), - deps: [AuthService], - }, - ]; -} diff --git a/src/client/src/app/services/storage.ts b/src/client/src/app/services/storage.ts index fdf2380..d71511b 100644 --- a/src/client/src/app/services/storage.ts +++ b/src/client/src/app/services/storage.ts @@ -2,6 +2,7 @@ import { User } from '../models/parsed-models'; const TOKEN_KEY = 'access_token_info'; const LOGIN_TOKEN_KEY = 'login_token'; +const HAS_CONFIGURED_PUSH = 'has_configured_push'; function getLocalStorage(key: string): string | null { return localStorage.getItem(key); @@ -42,10 +43,10 @@ export function setLoginToken(token: string | null) { setLocalStorage(LOGIN_TOKEN_KEY, token); } -export function getHasRejectedPush(): boolean { - const hasRejected = getLocalStorage('hasRejectedPush'); +export function getHasConfiguredPush(): boolean { + const hasRejected = getLocalStorage(HAS_CONFIGURED_PUSH); return hasRejected === 'true'; } -export function setHasRejectedPush(hasRejected: boolean) { - setLocalStorage('hasRejectedPush', hasRejected ? 'true' : 'false'); +export function setHasConfiguredPush(hasConfigured: boolean) { + setLocalStorage(HAS_CONFIGURED_PUSH, hasConfigured ? 'true' : 'false'); } diff --git a/src/client/src/app/services/web-push.service.ts b/src/client/src/app/services/web-push.service.ts index 370fec1..fd8c670 100644 --- a/src/client/src/app/services/web-push.service.ts +++ b/src/client/src/app/services/web-push.service.ts @@ -1,10 +1,18 @@ -import { computed, DestroyRef, EventEmitter, inject, Injectable, signal } from '@angular/core'; +import { + computed, + DestroyRef, + EventEmitter, + inject, + Injectable, + Injector, + signal, +} from '@angular/core'; import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { SwPush } from '@angular/service-worker'; import { combineLatest, filter, first, startWith, pairwise, firstValueFrom, map } from 'rxjs'; import { AuthService } from './auth.service'; -import { getHasRejectedPush, setHasRejectedPush } from './storage'; +import { getHasConfiguredPush, setHasConfiguredPush } from './storage'; import { TranslateService } from './translate.service'; import { WellKnownService } from './well-known.service'; import { NotificationsService } from '../api/services'; @@ -21,6 +29,7 @@ export class WebPushService { private readonly _notificationsService = inject(NotificationsService); private readonly _wellKnownService = inject(WellKnownService); private readonly _destroyRef = inject(DestroyRef); + private readonly _injector = inject(Injector); private readonly _subscription = toSignal(this._swPush.subscription); @@ -37,16 +46,18 @@ export class WebPushService { public init(): void { if (!this.notificationsSupported) return; - if (!getHasRejectedPush()) { + if (!getHasConfiguredPush()) { combineLatest([ - toObservable(this._authService.isAuthorized), + toObservable(this._authService.isAuthorized, { injector: this._injector }), this._swPush.subscription, - toObservable(this.notificationsPermission), ]) .pipe( filter( - ([isLoggedIn, subscription, permission]) => - isLoggedIn && !subscription && permission !== 'denied' + ([isLoggedIn, subscription]) => + isLoggedIn && + !subscription && + this.notificationsPermission() !== 'denied' && + !getHasConfiguredPush() ), first(), takeUntilDestroyed(this._destroyRef) @@ -55,8 +66,8 @@ export class WebPushService { } combineLatest([ - toObservable(this._authService.user), - toObservable(this._translateService.language), + toObservable(this._authService.user, { injector: this._injector }), + toObservable(this._translateService.language, { injector: this._injector }), this._swPush.subscription, ]) .pipe( @@ -71,6 +82,10 @@ export class WebPushService { this.unregisterSubscription(oldSub); } }); + + this._authService.onBeforeSignOut + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe(() => this.disable(true)); } public async enable(): Promise { @@ -88,13 +103,15 @@ export class WebPushService { const subscription = this._subscription(); if (subscription) { await this.registerSubscription(this._translateService.language(), subscription); + setHasConfiguredPush(true); return true; } const { vapidPublicKey } = await firstValueFrom(this._wellKnownService.wellKnown$); await this._swPush.requestSubscription({ serverPublicKey: vapidPublicKey }); + setHasConfiguredPush(true); return true; } else { - setHasRejectedPush(true); + setHasConfiguredPush(true); return false; } } @@ -109,6 +126,7 @@ export class WebPushService { await this.unregisterSubscription(subscription); } else { await this._swPush.unsubscribe(); + setHasConfiguredPush(true); } }