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,22 @@
# web-identity-application
Estado de autenticación (NgRx Signals con eventos) detrás de una facade.
- **Import**: `@task-stream/web/identity/application`
- **Capa**: `application` · **Tags**: `scope:web-identity`, `type:application`
## Contenido
- `AuthFacade` (lo único que se exporta, junto al tipo `AuthStatus`): sesión y usuario como señales; `signInWith*()`,
`completeSignIn()`, `signOut()`, `validAccessToken()`.
- Interno: `AuthStore` (login con PKCE o email y contraseña de Pireon; restaura la sesión al arrancar y renueva el
token) y eventos `authPageEvents`, `authApiEvents`, `sessionEvents`.
## Comandos
```sh
npx nx lint web-identity-application
npx nx test web-identity-application # Vitest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ts',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ts',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "web-identity-application",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/identity/application/src",
"prefix": "ts",
"projectType": "library",
"tags": ["scope:web-identity", "type:application"],
"targets": {
"test": {
"executor": "@nx/vitest:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "coverage/libs/web/identity/application"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/auth.facade';
export type { AuthStatus } from './lib/auth.store';

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

View File

@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed();

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,29 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: import.meta.dirname,
cacheDir: '../../../../node_modules/.vite/libs/web/identity/application',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: () => [ nxViteTsPaths() ],
// },
test: {
name: 'web-identity-application',
watch: false,
passWithNoTests: true,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../../coverage/libs/web/identity/application',
provider: 'v8' as const,
},
},
}));