first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
import { type } from '@ngrx/signals';
import { eventGroup } from '@ngrx/signals/events';
import {
AuthConfig,
CurrentUser,
Session,
} from '@task-stream/web/identity/domain';
/** What the user does on the sign-in screens. */
export const authPageEvents = eventGroup({
source: 'Auth Page',
events: {
opened: type<void>(),
signInWithPireon: type<{ returnUrl: string }>(),
signInWithPassword: type<{
username: string;
password: string;
returnUrl: string;
}>(),
callbackReceived: type<{ query: string }>(),
},
});
/** Anywhere in the app. */
export const sessionEvents = eventGroup({
source: 'Session',
events: {
signOutRequested: type<void>(),
/** The API rejected the token (expired / revoked / user deactivated). */
expired: type<void>(),
},
});
/** Results of talking to the API / identity server. */
export const authApiEvents = eventGroup({
source: 'Auth API',
events: {
configLoaded: type<AuthConfig | null>(),
signedIn: type<{
session: Session;
user: CurrentUser;
returnUrl: string;
}>(),
signInFailed: type<string>(),
sessionRestored: type<{ session: Session; user: CurrentUser }>(),
sessionRefreshed: type<Session>(),
signedOut: type<void>(),
/** Startup without a usable stored session (no redirect: guards decide). */
anonymous: type<void>(),
},
});

View File

@@ -0,0 +1,65 @@
import { computed, inject, Injectable } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import { authPageEvents, sessionEvents } from './auth.events';
import { AuthStore } from './auth.store';
/**
* Entry point to the session for presenters (and other contexts): read state
* as signals, express intentions as methods. Behind it, each method dispatches
* an event that the `AuthStore` reduces and handles.
*/
@Injectable({ providedIn: 'root' })
export class AuthFacade {
private readonly store = inject(AuthStore);
private readonly page = injectDispatch(authPageEvents);
private readonly session = injectDispatch(sessionEvents);
readonly status = this.store.status;
readonly user = this.store.user;
/** undefined = not loaded yet, null = only personal access tokens. */
readonly config = this.store.config;
readonly error = this.store.error;
readonly isAuthenticated = this.store.isAuthenticated;
readonly initials = this.store.initials;
readonly signingIn = computed(() => this.store.status() === 'signing-in');
/** False until the stored session has been restored (or discarded). */
readonly settled = computed(
() => !['restoring', 'signing-in'].includes(this.store.status()),
);
/** The sign-in screen opened: loads how the API lets users sign in. */
openSignIn(): void {
this.page.opened();
}
signInWithPireon(returnUrl: string): void {
this.page.signInWithPireon({ returnUrl });
}
signInWithPassword(
username: string,
password: string,
returnUrl: string,
): void {
this.page.signInWithPassword({ username, password, returnUrl });
}
/** Back from Pireon with `?code=…&state=…`. */
completeSignIn(query: string): void {
this.page.callbackReceived({ query });
}
signOut(): void {
this.session.signOutRequested();
}
/** A valid access token for API calls, refreshed when about to expire. */
validAccessToken(): Promise<string | null> {
return this.store.validAccessToken();
}
/** The API rejected the token. */
expire(): void {
this.store.expire();
}
}

View 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());
},
});
});
},
}),
);