import { HttpClient } from '@angular/common/http'; import {DestroyRef, inject, Injectable } from '@angular/core'; import {catchError, of, ReplaySubject, throwError} from 'rxjs'; import {filter, map, switchMap, tap} from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Preferences } from '../_models/preferences/preferences'; import { User } from '../_models/user'; import { Router } from '@angular/router'; import { EVENTS, MessageHubService } from './message-hub.service'; import { ThemeService } from './theme.service'; import { InviteUserResponse } from '../_models/auth/invite-user-response'; import { UserUpdateEvent } from '../_models/events/user-update-event'; import { AgeRating } from '../_models/metadata/age-rating'; import { AgeRestriction } from '../_models/metadata/age-restriction'; import { TextResonse } from '../_types/text-response'; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; export enum Role { Admin = 'Admin', ChangePassword = 'Change Password', Bookmark = 'Bookmark', Download = 'Download', ChangeRestriction = 'Change Restriction', ReadOnly = 'Read Only' } @Injectable({ providedIn: 'root' }) export class AccountService { private readonly destroyRef = inject(DestroyRef); baseUrl = environment.apiUrl; userKey = 'kavita-user'; public static lastLoginKey = 'kavita-lastlogin'; public static localeKey = 'kavita-locale'; private currentUser: User | undefined; // Stores values, when someone subscribes gives (1) of last values seen. private currentUserSource = new ReplaySubject(1); public currentUser$ = this.currentUserSource.asObservable(); private hasValidLicenseSource = new ReplaySubject(1); /** * Does the user have an active license */ public hasValidLicense$ = this.hasValidLicenseSource.asObservable(); /** * SetTimeout handler for keeping track of refresh token call */ private refreshTokenTimeout: ReturnType | undefined; private isOnline: boolean = true; constructor(private httpClient: HttpClient, private router: Router, private messageHub: MessageHubService, private themeService: ThemeService) { messageHub.messages$.pipe(filter(evt => evt.event === EVENTS.UserUpdate), map(evt => evt.payload as UserUpdateEvent), filter(userUpdateEvent => userUpdateEvent.userName === this.currentUser?.username), switchMap(() => this.refreshAccount())) .subscribe(() => {}); window.addEventListener("offline", (e) => { this.isOnline = false; }); window.addEventListener("online", (e) => { this.isOnline = true; this.refreshToken().subscribe(); }); } hasAdminRole(user: User) { return user && user.roles.includes(Role.Admin); } hasChangePasswordRole(user: User) { return user && user.roles.includes(Role.ChangePassword); } hasChangeAgeRestrictionRole(user: User) { return user && user.roles.includes(Role.ChangeRestriction); } hasDownloadRole(user: User) { return user && user.roles.includes(Role.Download); } hasBookmarkRole(user: User) { return user && user.roles.includes(Role.Bookmark); } hasReadOnlyRole(user: User) { return user && user.roles.includes(Role.ReadOnly); } getRoles() { return this.httpClient.get(this.baseUrl + 'account/roles'); } deleteLicense() { return this.httpClient.delete(this.baseUrl + 'license', TextResonse); } resetLicense(license: string, email: string) { return this.httpClient.post(this.baseUrl + 'license/reset', {license, email}, TextResonse); } hasValidLicense(forceCheck: boolean = false) { return this.httpClient.get(this.baseUrl + 'license/valid-license?forceCheck=' + forceCheck, TextResonse) .pipe( map(res => res === "true"), tap(res => { this.hasValidLicenseSource.next(res) }), catchError(error => { this.hasValidLicenseSource.next(false); return throwError(error); // Rethrow the error to propagate it further }) ); } hasAnyLicense() { return this.httpClient.get(this.baseUrl + 'license/has-license', TextResonse) .pipe( map(res => res === "true"), ); } updateUserLicense(license: string, email: string, discordId?: string) { return this.httpClient.post(this.baseUrl + 'license', {license, email, discordId}, TextResonse) .pipe(map(res => res === "true")); } login(model: {username: string, password: string, apiKey?: string}) { return this.httpClient.post(this.baseUrl + 'account/login', model).pipe( map((response: User) => { const user = response; if (user) { this.setCurrentUser(user); } }), takeUntilDestroyed(this.destroyRef) ); } setCurrentUser(user?: User) { if (user) { user.roles = []; const roles = this.getDecodedToken(user.token).role; Array.isArray(roles) ? user.roles = roles : user.roles.push(roles); localStorage.setItem(this.userKey, JSON.stringify(user)); localStorage.setItem(AccountService.lastLoginKey, user.username); if (user.preferences && user.preferences.theme) { this.themeService.setTheme(user.preferences.theme.name); } else { this.themeService.setTheme(this.themeService.defaultTheme); } } else { this.themeService.setTheme(this.themeService.defaultTheme); } this.currentUser = user; this.currentUserSource.next(user); this.stopRefreshTokenTimer(); if (this.currentUser) { this.messageHub.stopHubConnection(); this.messageHub.createHubConnection(this.currentUser); this.hasValidLicense().subscribe(); this.startRefreshTokenTimer(); } } logout() { localStorage.removeItem(this.userKey); this.currentUserSource.next(undefined); this.currentUser = undefined; this.stopRefreshTokenTimer(); this.messageHub.stopHubConnection(); // Upon logout, perform redirection this.router.navigateByUrl('/login'); } /** * Registers the first admin on the account. Only used for that. All other registrations must occur through invite * @param model * @returns */ register(model: {username: string, password: string, email: string}) { return this.httpClient.post(this.baseUrl + 'account/register', model).pipe( map((user: User) => { return user; }), takeUntilDestroyed(this.destroyRef) ); } isEmailConfirmed() { return this.httpClient.get(this.baseUrl + 'account/email-confirmed'); } isEmailValid() { return this.httpClient.get(this.baseUrl + 'account/is-email-valid', TextResonse) .pipe(map(res => res == "true")); } confirmMigrationEmail(model: {email: string, token: string}) { return this.httpClient.post(this.baseUrl + 'account/confirm-migration-email', model); } resendConfirmationEmail(userId: number) { return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}); } inviteUser(model: {email: string, roles: Array, libraries: Array, ageRestriction: AgeRestriction}) { return this.httpClient.post(this.baseUrl + 'account/invite', model); } confirmEmail(model: {email: string, username: string, password: string, token: string}) { return this.httpClient.post(this.baseUrl + 'account/confirm-email', model); } confirmEmailUpdate(model: {email: string, token: string}) { return this.httpClient.post(this.baseUrl + 'account/confirm-email-update', model); } /** * Given a user id, returns a full url for setting up the user account * @param userId * @param withBaseUrl Should base url be included in invite url * @returns */ getInviteUrl(userId: number, withBaseUrl: boolean = true) { return this.httpClient.get(this.baseUrl + 'account/invite-url?userId=' + userId + '&withBaseUrl=' + withBaseUrl, TextResonse); } getDecodedToken(token: string) { return JSON.parse(atob(token.split('.')[1])); } requestResetPasswordEmail(email: string) { return this.httpClient.post(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, TextResonse); } confirmResetPasswordEmail(model: {email: string, token: string, password: string}) { return this.httpClient.post(this.baseUrl + 'account/confirm-password-reset', model, TextResonse); } resetPassword(username: string, password: string, oldPassword: string) { return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, TextResonse); } update(model: {email: string, roles: Array, libraries: Array, userId: number, ageRestriction: AgeRestriction}) { return this.httpClient.post(this.baseUrl + 'account/update', model); } updateEmail(email: string, password: string) { return this.httpClient.post(this.baseUrl + 'account/update/email', {email, password}); } updateAgeRestriction(ageRating: AgeRating, includeUnknowns: boolean) { return this.httpClient.post(this.baseUrl + 'account/update/age-restriction', {ageRating, includeUnknowns}); } /** * This will get latest preferences for a user and cache them into user store * @returns */ getPreferences() { return this.httpClient.get(this.baseUrl + 'users/get-preferences').pipe(map(pref => { if (this.currentUser !== undefined && this.currentUser !== null) { this.currentUser.preferences = pref; this.setCurrentUser(this.currentUser); } return pref; }), takeUntilDestroyed(this.destroyRef)); } updatePreferences(userPreferences: Preferences) { return this.httpClient.post(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => { if (this.currentUser !== undefined && this.currentUser !== null) { this.currentUser.preferences = settings; this.setCurrentUser(this.currentUser); // Update the locale on disk (for logout and compact-number pipe) localStorage.setItem(AccountService.localeKey, this.currentUser.preferences.locale); } return settings; }), takeUntilDestroyed(this.destroyRef)); } getUserFromLocalStorage(): User | undefined { const userString = localStorage.getItem(this.userKey); if (userString) { return JSON.parse(userString) } return undefined; } resetApiKey() { return this.httpClient.post(this.baseUrl + 'account/reset-api-key', {}, TextResonse).pipe(map(key => { const user = this.getUserFromLocalStorage(); if (user) { user.apiKey = key; localStorage.setItem(this.userKey, JSON.stringify(user)); this.currentUserSource.next(user); this.currentUser = user; } return key; })); } getOpdsUrl() { return this.httpClient.get(this.baseUrl + 'account/opds-url', TextResonse); } refreshAccount() { if (this.currentUser === null || this.currentUser === undefined) return of(); return this.httpClient.get(this.baseUrl + 'account/refresh-account').pipe(map((user: User) => { if (user) { this.currentUser = {...user}; } this.setCurrentUser(this.currentUser); return user; })); } private refreshToken() { if (this.currentUser === null || this.currentUser === undefined || !this.isOnline) return of(); return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token', {token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => { if (this.currentUser) { this.currentUser.token = user.token; this.currentUser.refreshToken = user.refreshToken; } this.setCurrentUser(this.currentUser); return user; })); } /** * Every 10 mins refresh the token */ private startRefreshTokenTimer() { if (this.currentUser === null || this.currentUser === undefined) { this.stopRefreshTokenTimer(); return; } this.stopRefreshTokenTimer(); this.refreshTokenTimeout = setInterval(() => this.refreshToken().subscribe(() => {}), (60 * 10_000)); } private stopRefreshTokenTimer() { if (this.refreshTokenTimeout !== undefined) { clearInterval(this.refreshTokenTimeout); } } }