11import { Injectable } from '@angular/core' ;
2- import { Observable , BehaviorSubject } from 'rxjs' ;
2+ import { Observable , BehaviorSubject , EMPTY , Subscription , timer } from 'rxjs' ;
33
44import { JwtService } from './jwt.service' ;
5- import { map , distinctUntilChanged , tap , shareReplay } from 'rxjs/operators' ;
6- import { HttpClient } from '@angular/common/http' ;
5+ import { map , distinctUntilChanged , tap , shareReplay , catchError } from 'rxjs/operators' ;
6+ import { HttpClient , HttpErrorResponse } from '@angular/common/http' ;
77import { User } from '../user.model' ;
88import { Router } from '@angular/router' ;
99
10+ export type AuthState = 'authenticated' | 'unauthenticated' | 'unavailable' | 'loading' ;
11+
12+ /**
13+ * UserService - Manages authentication state for the current user.
14+ *
15+ * ## Endpoints
16+ *
17+ * This service uses GET /user (not /users/:id or /profiles/:username):
18+ * - GET /user → Returns the authenticated user's own data (JWT token identifies who you are)
19+ * - GET /profiles/:username → Different endpoint for viewing any user's public profile (see ProfileService)
20+ *
21+ * ## Auth States
22+ *
23+ * - 'loading': Initial state, checking if stored token is valid
24+ * - 'authenticated': Token valid, user data loaded
25+ * - 'unauthenticated': No token or token invalid (4XX error)
26+ * - 'unavailable': Server error (5XX), token kept for retry
27+ *
28+ * ## Error Handling on GET /user
29+ *
30+ * - 4XX errors (400, 401, 403, 404): Token is invalid → logout, clear token
31+ * - 5XX errors (500, 503) or network errors: Server is down → keep token, auto-retry
32+ *
33+ * The distinction matters: a 401 means "your token is bad" (clear it), while a 500
34+ * means "server is broken" (keep the token, it might work when server recovers).
35+ *
36+ * ## Auto-Retry
37+ *
38+ * When in 'unavailable' state, the service automatically retries with exponential
39+ * backoff: 2s → 4s → 8s → 16s → 16s → ... (capped at 16s, retries indefinitely).
40+ * The UI shows "Connecting..." while retrying. User can also just reload the page.
41+ *
42+ * ## Global 401 Handling
43+ *
44+ * For other endpoints (not /user), 401 errors are caught by errorInterceptor
45+ * which calls purgeAuth() - this handles "token expired mid-session" scenarios.
46+ */
1047@Injectable ( { providedIn : 'root' } )
1148export class UserService {
1249 private currentUserSubject = new BehaviorSubject < User | null > ( null ) ;
1350 public currentUser = this . currentUserSubject . asObservable ( ) . pipe ( distinctUntilChanged ( ) ) ;
1451
52+ private authStateSubject = new BehaviorSubject < AuthState > ( 'loading' ) ;
53+ public authState = this . authStateSubject . asObservable ( ) . pipe ( distinctUntilChanged ( ) ) ;
54+
1555 public isAuthenticated = this . currentUser . pipe ( map ( user => ! ! user ) ) ;
1656
57+ private retryAttempt = 0 ;
58+ private retrySubscription : Subscription | null = null ;
59+
1760 constructor (
1861 private readonly http : HttpClient ,
1962 private readonly jwtService : JwtService ,
@@ -39,12 +82,71 @@ export class UserService {
3982 return this . http . get < { user : User } > ( '/user' ) . pipe (
4083 tap ( {
4184 next : ( { user } ) => this . setAuth ( user ) ,
42- error : ( ) => this . purgeAuth ( ) ,
85+ error : ( err : HttpErrorResponse ) => this . handleAuthError ( err ) ,
4386 } ) ,
4487 shareReplay ( 1 ) ,
88+ catchError ( ( ) => EMPTY ) ,
4589 ) ;
4690 }
4791
92+ /**
93+ * Handle errors from /api/user endpoint
94+ * - 4XX errors: Token is invalid, logout the user
95+ * - 5XX/network errors: Server issue, enter "unavailable" mode (keep token, show placeholder)
96+ */
97+ private handleAuthError ( err : HttpErrorResponse ) : void {
98+ const status = err . status ;
99+
100+ if ( status >= 400 && status < 500 ) {
101+ // 4XX: Client error (invalid token, forbidden, etc.) - logout
102+ this . purgeAuth ( ) ;
103+ } else {
104+ // 5XX or network error (status 0) - auth temporarily unavailable
105+ this . setAuthUnavailable ( ) ;
106+ }
107+ }
108+
109+ /**
110+ * Set auth state to unavailable (server error, but keep token for retry)
111+ */
112+ private setAuthUnavailable ( ) : void {
113+ this . currentUserSubject . next ( null ) ;
114+ this . authStateSubject . next ( 'unavailable' ) ;
115+ this . scheduleRetry ( ) ;
116+ }
117+
118+ /**
119+ * Schedule auto-retry with exponential backoff: 2s, 4s, 8s, 16s, 16s, 16s...
120+ */
121+ private scheduleRetry ( ) : void {
122+ this . cancelRetry ( ) ;
123+
124+ if ( ! this . jwtService . getToken ( ) ) {
125+ return ; // No token, nothing to retry
126+ }
127+
128+ // Calculate delay: 2, 4, 8, 16, 16, 16... (capped at 16s)
129+ const delaySeconds = Math . min ( 2 * Math . pow ( 2 , this . retryAttempt ) , 16 ) ;
130+ this . retryAttempt ++ ;
131+
132+ this . retrySubscription = timer ( delaySeconds * 1000 ) . subscribe ( ( ) => {
133+ if ( this . jwtService . getToken ( ) ) {
134+ this . authStateSubject . next ( 'loading' ) ;
135+ this . getCurrentUser ( ) . subscribe ( ) ;
136+ }
137+ } ) ;
138+ }
139+
140+ /**
141+ * Cancel any pending retry
142+ */
143+ private cancelRetry ( ) : void {
144+ if ( this . retrySubscription ) {
145+ this . retrySubscription . unsubscribe ( ) ;
146+ this . retrySubscription = null ;
147+ }
148+ }
149+
48150 update ( user : Partial < User > ) : Observable < { user : User } > {
49151 return this . http . put < { user : User } > ( '/user' , { user } ) . pipe (
50152 tap ( ( { user } ) => {
@@ -54,12 +156,18 @@ export class UserService {
54156 }
55157
56158 setAuth ( user : User ) : void {
159+ this . cancelRetry ( ) ;
160+ this . retryAttempt = 0 ;
57161 this . jwtService . saveToken ( user . token ) ;
58162 this . currentUserSubject . next ( user ) ;
163+ this . authStateSubject . next ( 'authenticated' ) ;
59164 }
60165
61166 purgeAuth ( ) : void {
167+ this . cancelRetry ( ) ;
168+ this . retryAttempt = 0 ;
62169 this . jwtService . destroyToken ( ) ;
63170 this . currentUserSubject . next ( null ) ;
171+ this . authStateSubject . next ( 'unauthenticated' ) ;
64172 }
65173}
0 commit comments