first commit
This commit is contained in:
289
libs/web/identity/application/src/lib/auth.store.ts
Normal file
289
libs/web/identity/application/src/lib/auth.store.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
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<AuthGateway['password']>,
|
||||
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)
|
||||
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<string | null> | 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<string | null> {
|
||||
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());
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user