first commit
This commit is contained in:
22
libs/web/identity/application/README.md
Normal file
22
libs/web/identity/application/README.md
Normal 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).
|
||||
34
libs/web/identity/application/eslint.config.mjs
Normal file
34
libs/web/identity/application/eslint.config.mjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
20
libs/web/identity/application/project.json
Normal file
20
libs/web/identity/application/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
2
libs/web/identity/application/src/index.ts
Normal file
2
libs/web/identity/application/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './lib/auth.facade';
|
||||
export type { AuthStatus } from './lib/auth.store';
|
||||
51
libs/web/identity/application/src/lib/auth.events.ts
Normal file
51
libs/web/identity/application/src/lib/auth.events.ts
Normal 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>(),
|
||||
},
|
||||
});
|
||||
65
libs/web/identity/application/src/lib/auth.facade.ts
Normal file
65
libs/web/identity/application/src/lib/auth.facade.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
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());
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
5
libs/web/identity/application/src/test-setup.ts
Normal file
5
libs/web/identity/application/src/test-setup.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-snapshots';
|
||||
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
|
||||
|
||||
setupTestBed();
|
||||
30
libs/web/identity/application/tsconfig.json
Normal file
30
libs/web/identity/application/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
libs/web/identity/application/tsconfig.lib.json
Normal file
26
libs/web/identity/application/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
29
libs/web/identity/application/tsconfig.spec.json
Normal file
29
libs/web/identity/application/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
29
libs/web/identity/application/vite.config.mts
Normal file
29
libs/web/identity/application/vite.config.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user