import { HttpErrorResponse } from '@angular/common/http'; import { computed, inject } from '@angular/core'; import { Router } from '@angular/router'; import { mapResponse } from '@ngrx/operators'; import { signalStore, withComputed, withHooks, withMethods, withState, } from '@ngrx/signals'; import { Dispatcher, Events, on, withEventHandlers, withReducer, } from '@ngrx/signals/events'; import { AuthConfig, AuthGateway, AuthorizationFlow, CurrentUser, isExpired, ProfileGateway, Session, SessionStorage, } from '@task-stream/web/identity/domain'; import { exhaustMap, firstValueFrom, from, map, switchMap, tap } from 'rxjs'; import { authApiEvents, authPageEvents, sessionEvents } from './auth.events'; export type AuthStatus = | 'restoring' | 'anonymous' | 'signing-in' | 'authenticated'; interface AuthState { status: AuthStatus; session: Session | null; user: CurrentUser | null; /** undefined = not loaded yet, null = only personal access tokens. */ config: AuthConfig | null | undefined; error: string | null; } const initialState: AuthState = { status: 'restoring', session: null, user: null, config: undefined, error: null, }; function describe(error: unknown): string { if (error instanceof HttpErrorResponse) { return ( (error.error as { message?: string } | null)?.message ?? error.message ); } return error instanceof Error ? error.message : String(error); } export const AuthStore = signalStore( { providedIn: 'root' }, withState(initialState), withComputed(({ status, user }) => ({ isAuthenticated: computed(() => status() === 'authenticated'), initials: computed(() => (user()?.name ?? '') .split(/\s+/) .map((part) => part[0]) .join('') .slice(0, 2) .toUpperCase(), ), })), withReducer( on(authApiEvents.configLoaded, ({ payload }) => ({ config: payload })), on( authPageEvents.signInWithPassword, authPageEvents.signInWithPireon, authPageEvents.callbackReceived, () => ({ status: 'signing-in' as const, error: null }), ), on( authApiEvents.signedIn, authApiEvents.sessionRestored, ({ payload }) => ({ status: 'authenticated' as const, session: payload.session, user: payload.user, error: null, }), ), on(authApiEvents.sessionRefreshed, ({ payload }) => ({ session: payload })), on(authApiEvents.signInFailed, ({ payload }) => ({ status: 'anonymous' as const, session: null, user: null, error: payload, })), on(authApiEvents.signedOut, authApiEvents.anonymous, () => ({ status: 'anonymous' as const, session: null, user: null, })), ), withEventHandlers( ( store, events = inject(Events), auth = inject(AuthGateway), profile = inject(ProfileGateway), flow = inject(AuthorizationFlow), storage = inject(SessionStorage), router = inject(Router), ) => { /** Token → who is it → signed in. */ const signIn = ( session$: ReturnType, returnUrl: string, ) => session$.pipe( switchMap((session) => profile .me(session.accessToken) .pipe(map((user) => ({ session, user }))), ), mapResponse({ next: ({ session, user }) => authApiEvents.signedIn({ session, user, returnUrl }), error: (error) => authApiEvents.signInFailed(describe(error)), }), ); return { loadConfig$: events.on(authPageEvents.opened).pipe( exhaustMap(() => auth.config().pipe( mapResponse({ next: (config) => authApiEvents.configLoaded(config), error: () => authApiEvents.configLoaded(null), }), ), ), ), redirectToPireon$: events.on(authPageEvents.signInWithPireon).pipe( exhaustMap(({ payload }) => { const config = store.config(); if (!config?.clientId) return [ authApiEvents.signInFailed( 'Pireon sign-in is not configured on the API', ), ]; return from(flow.start(config, payload.returnUrl)).pipe( map(() => null), ); }), ), password$: events .on(authPageEvents.signInWithPassword) .pipe( exhaustMap(({ payload }) => signIn( auth.password(payload.username, payload.password), payload.returnUrl, ), ), ), callback$: events.on(authPageEvents.callbackReceived).pipe( exhaustMap(({ payload }) => { try { const response = flow.complete( new URLSearchParams(payload.query), ); return signIn( auth.exchangeCode( response.code, response.codeVerifier, response.redirectUri, ), response.returnUrl, ); } catch (error) { return [authApiEvents.signInFailed(describe(error))]; } }), ), persist$: events .on(authApiEvents.signedIn, authApiEvents.sessionRefreshed) .pipe( tap(() => { const session = store.session(); if (session) storage.save(session); }), ), navigateIn$: events .on(authApiEvents.signedIn) .pipe( tap( ({ payload }) => void router.navigateByUrl(payload.returnUrl || '/'), ), ), signOut$: events .on(sessionEvents.signOutRequested, sessionEvents.expired) .pipe( tap(() => storage.clear()), map(() => authApiEvents.signedOut()), ), navigateOut$: events .on(authApiEvents.signedOut) .pipe(tap(() => void router.navigateByUrl('/login'))), }; }, ), withMethods( (store, auth = inject(AuthGateway), dispatcher = inject(Dispatcher)) => { let refreshing: Promise | null = null; return { /** * A valid access token for API calls: refreshed when about to expire * (once, even if many requests ask at the same time). */ async validAccessToken(): Promise { const session = store.session(); if (!session) return null; if (!isExpired(session)) return session.accessToken; if (!session.refreshToken) { dispatcher.dispatch(sessionEvents.expired()); return null; } refreshing ??= firstValueFrom(auth.refresh(session.refreshToken)) .then((fresh) => { dispatcher.dispatch(authApiEvents.sessionRefreshed(fresh)); return fresh.accessToken; }) .catch(() => { dispatcher.dispatch(sessionEvents.expired()); return null; }) .finally(() => (refreshing = null)); return refreshing; }, expire(): void { if (store.status() === 'authenticated') dispatcher.dispatch(sessionEvents.expired()); }, }; }, ), withHooks({ /** Restores the stored session (and checks it is still accepted). */ onInit(store) { const storage = inject(SessionStorage); const profile = inject(ProfileGateway); const dispatcher = inject(Dispatcher); const stored = storage.load(); if (!stored) { dispatcher.dispatch(authApiEvents.anonymous()); return; } // Seed the token so validAccessToken() can refresh it if needed. dispatcher.dispatch(authApiEvents.sessionRefreshed(stored)); void store.validAccessToken().then((token) => { if (!token) { dispatcher.dispatch(authApiEvents.anonymous()); return; } profile.me(token).subscribe({ next: (user) => dispatcher.dispatch( authApiEvents.sessionRestored({ session: store.session() ?? stored, user, }), ), error: () => { storage.clear(); dispatcher.dispatch(authApiEvents.anonymous()); }, }); }); }, }), );