valtech-components 2.0.724 → 2.0.726

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ import { HttpClient } from '@angular/common/http';
2
+ import { Router } from '@angular/router';
3
+ import { Observable } from 'rxjs';
4
+ import { AuthService } from './auth.service';
5
+ import { ValtechAuthConfig } from './types';
6
+ import * as i0 from "@angular/core";
7
+ /**
8
+ * Name of the query param that carries the handoff token. Apps may override
9
+ * via `detectAndExchangeHandoff({ tokenParam })` but the default keeps the
10
+ * convention consistent across the factory.
11
+ */
12
+ export declare const HANDOFF_TOKEN_PARAM = "handoff";
13
+ /**
14
+ * Name of the query param that carries the post-exchange route.
15
+ */
16
+ export declare const HANDOFF_ROUTE_PARAM = "route";
17
+ /**
18
+ * Options for `HandoffService.detectAndExchangeHandoff`.
19
+ */
20
+ export interface DetectAndExchangeOptions {
21
+ /** Override the query param name. Default: `'handoff'`. */
22
+ tokenParam?: string;
23
+ /** Override the route param name. Default: `'route'`. */
24
+ routeParam?: string;
25
+ /** Where to navigate if no route param is present. Default: `'/'`. */
26
+ defaultRoute?: string;
27
+ /** Where to navigate on exchange error. Default: app's configured `loginRoute`. */
28
+ errorRoute?: string;
29
+ }
30
+ /**
31
+ * Request body for `POST /v2/auth/handoff`.
32
+ *
33
+ * Both fields are optional and stored for audit only — the exchange step
34
+ * does not enforce that the redeeming app matches `targetAppId`.
35
+ */
36
+ export interface HandoffCreateRequest {
37
+ /** Target app the handoff is intended for (e.g. `"myvaltech"`). Audit-only. */
38
+ targetAppId?: string;
39
+ /** Route the target app should navigate to after exchange. Echoed back. */
40
+ route?: string;
41
+ }
42
+ /**
43
+ * Response body from `POST /v2/auth/handoff`.
44
+ */
45
+ export interface HandoffCreateResponse {
46
+ operationId: string;
47
+ /** Raw token to embed in the redirect URL as `?handoff=<token>`. 30s TTL, single-use. */
48
+ token: string;
49
+ /** RFC3339 expiration timestamp. */
50
+ expiresAt: string;
51
+ /** Echoed `route` from the request, when provided. */
52
+ route?: string;
53
+ }
54
+ /**
55
+ * Response body from `POST /v2/auth/handoff/exchange`.
56
+ *
57
+ * Same shape as `SigninResponse` — installable via `AuthService.setExternalAuth`.
58
+ */
59
+ export interface HandoffExchangeResponse {
60
+ operationId: string;
61
+ accessToken: string;
62
+ refreshToken: string;
63
+ firebaseToken?: string;
64
+ expiresIn: number;
65
+ tokenType: string;
66
+ userId: string;
67
+ }
68
+ /**
69
+ * HandoffService — cross-app session transfer.
70
+ *
71
+ * Implements the OAuth Authorization Code pattern, applied internally between
72
+ * apps that share the same backend.
73
+ *
74
+ * **Origin app (user is authenticated):**
75
+ *
76
+ * ```typescript
77
+ * const { token, route } = await firstValueFrom(
78
+ * handoff.createHandoff({ targetAppId: 'myvaltech', route: '/app/dashboard' })
79
+ * );
80
+ * window.location.href = `https://myvaltech.app/?handoff=${token}&route=${encodeURIComponent(route ?? '/')}`;
81
+ * ```
82
+ *
83
+ * **Target app (boot):** call `exchangeHandoff(token)`. On success, the session
84
+ * is installed (`AuthService.setExternalAuth`) and the user is authenticated.
85
+ * See `detectAndExchangeHandoff` helper for the typical bootstrap pattern.
86
+ *
87
+ * Security notes:
88
+ * - Token is single-use and short-lived (30s) — enforced server-side.
89
+ * - Token in URL must be removed from history after exchange to avoid log leakage.
90
+ * - The exchange endpoint is public; do NOT add auth header. The
91
+ * `HttpClient` request below avoids triggering the auth interceptor since the
92
+ * user isn't logged in yet on the target app.
93
+ */
94
+ export declare class HandoffService {
95
+ private config;
96
+ private http;
97
+ private auth;
98
+ private router;
99
+ private detected;
100
+ constructor(config: ValtechAuthConfig, http: HttpClient, auth: AuthService, router: Router);
101
+ /**
102
+ * Create a handoff token. Caller must be authenticated.
103
+ *
104
+ * @param request Optional metadata: target app id and intended route.
105
+ * @returns Observable emitting `{ token, expiresAt, route? }`.
106
+ */
107
+ createHandoff(request?: HandoffCreateRequest): Observable<HandoffCreateResponse>;
108
+ /**
109
+ * Exchange a handoff token for a session and install it.
110
+ *
111
+ * On success, the response is piped through `AuthService.setExternalAuth`
112
+ * so the user becomes authenticated. Subsequent navigation can proceed.
113
+ *
114
+ * On failure, the observable errors. The caller is responsible for
115
+ * showing an error and routing to `/login`.
116
+ *
117
+ * @param token Raw handoff token read from the URL.
118
+ */
119
+ exchangeHandoff(token: string): Observable<HandoffExchangeResponse>;
120
+ /**
121
+ * Bootstrap helper — reads the handoff token from the current URL, exchanges
122
+ * it for a session, and navigates to the intended route with the token
123
+ * stripped from history.
124
+ *
125
+ * Idempotent: subsequent calls are no-ops. Wire from an
126
+ * `APP_INITIALIZER`/`provideAppInitializer` factory in `main.ts`.
127
+ *
128
+ * ```typescript
129
+ * // main.ts
130
+ * provideAppInitializer(() => inject(HandoffService).detectAndExchangeHandoff())
131
+ * ```
132
+ *
133
+ * On error (expired/used/invalid token), redirects to `errorRoute` (default:
134
+ * the app's configured `loginRoute`). The URL is always cleaned, even on
135
+ * error, so the token cannot be retried by refreshing the page.
136
+ *
137
+ * @returns `true` if a handoff was detected and processed (success or fail).
138
+ * `false` if no token was present (cold boot, normal flow).
139
+ */
140
+ detectAndExchangeHandoff(options?: DetectAndExchangeOptions): Promise<boolean>;
141
+ /**
142
+ * Persist the session in `AuthService`. Mirrors the install flow used by
143
+ * the normal signin path so timers, Firebase, and tab-sync all kick in.
144
+ */
145
+ private installSession;
146
+ private get baseUrl();
147
+ static ɵfac: i0.ɵɵFactoryDeclaration<HandoffService, never>;
148
+ static ɵprov: i0.ɵɵInjectableDeclaration<HandoffService>;
149
+ }
@@ -81,3 +81,7 @@ export { DeviceService } from './device.service';
81
81
  export { SessionService } from './session.service';
82
82
  export { OAuthService } from './oauth.service';
83
83
  export { OAuthCallbackComponent } from './oauth-callback.component';
84
+ export { HandoffService, HANDOFF_TOKEN_PARAM, HANDOFF_ROUTE_PARAM } from './handoff.service';
85
+ export type { HandoffCreateRequest, HandoffCreateResponse, HandoffExchangeResponse, DetectAndExchangeOptions, } from './handoff.service';
86
+ export { OrgSwitchService } from './org-switch.service';
87
+ export type { OrgChangedEvent, SwitchOrgOptions } from './org-switch.service';
@@ -0,0 +1,127 @@
1
+ import { Observable } from 'rxjs';
2
+ import { AuthService } from './auth.service';
3
+ import * as i0 from "@angular/core";
4
+ /**
5
+ * Event emitted when the active organization changes.
6
+ */
7
+ export interface OrgChangedEvent {
8
+ /** Org the user was on before the switch. May be empty on first sign-in. */
9
+ previousOrg: string;
10
+ /** Org the user just switched to. */
11
+ newOrg: string;
12
+ }
13
+ /**
14
+ * Options for `OrgSwitchService.switchTo`.
15
+ */
16
+ export interface SwitchOrgOptions {
17
+ /**
18
+ * If `true`, after the switch succeeds the page is fully reloaded via
19
+ * `window.location.reload()`. Useful for apps with significant in-memory
20
+ * state tied to the previous org that's hard to invalidate piece by piece.
21
+ *
22
+ * Trade-off: reload loses all in-memory state (forms, scroll position).
23
+ * Default: `false` — relies on `orgChanged$` and `auth.user()` signal
24
+ * propagation for components to react.
25
+ */
26
+ reload?: boolean;
27
+ }
28
+ /**
29
+ * OrgSwitchService — orchestrates active organization changes across the app.
30
+ *
31
+ * Built on top of `AuthService.switchOrg`, which already:
32
+ * - Hits `POST /v2/auth/switch-org` and receives a new Firebase custom token.
33
+ * - Re-authenticates Firebase Auth with the new token (RBAC claims update).
34
+ * - Broadcasts `ORG_SWITCH` to other tabs via `AuthSyncService` (multi-tab sync).
35
+ *
36
+ * What this service adds on top:
37
+ * - A `switching` signal so the UI can show a loading indicator (1-2s switch).
38
+ * - An `orgChanged$` Observable that components subscribe to in order to
39
+ * invalidate their org-scoped caches (e.g. drop old query results, reset
40
+ * page state) without a full page reload.
41
+ * - Optional `reload: true` for apps where invalidating in-memory state piece
42
+ * by piece is impractical.
43
+ *
44
+ * **What this service does NOT do automatically:**
45
+ *
46
+ * 1. **Teardown Firestore listeners.** Listeners are owned by their subscribing
47
+ * components (typically via `takeUntilDestroyed` or async pipe). When the
48
+ * component re-renders or unsubscribes, the listener disposes. If a
49
+ * component does NOT unsubscribe on org change, its listener will keep
50
+ * pointing at the previous org's path and may start failing rules. The fix
51
+ * is component-level: subscribe to `orgChanged$` and reset state, or use
52
+ * the `reload: true` option.
53
+ *
54
+ * 2. **Re-instantiate routed components.** Angular keeps mounted components
55
+ * alive across navigations. If you need fresh state, either subscribe to
56
+ * `orgChanged$` in the component, or use `reload: true`.
57
+ *
58
+ * @example Basic switch with loading state
59
+ * ```typescript
60
+ * private orgSwitch = inject(OrgSwitchService);
61
+ *
62
+ * async onSwitchOrg(orgId: string) {
63
+ * await this.orgSwitch.switchTo(orgId);
64
+ * // Components subscribed to orgChanged$ have already reset their state.
65
+ * }
66
+ *
67
+ * // In template:
68
+ * @if (orgSwitch.switching()) { <val-loading /> }
69
+ * ```
70
+ *
71
+ * @example Component reacting to org change
72
+ * ```typescript
73
+ * private orgSwitch = inject(OrgSwitchService);
74
+ *
75
+ * constructor() {
76
+ * this.orgSwitch.orgChanged$
77
+ * .pipe(takeUntilDestroyed())
78
+ * .subscribe(() => this.resetState());
79
+ * }
80
+ * ```
81
+ *
82
+ * @example Brutal reload mode
83
+ * ```typescript
84
+ * await this.orgSwitch.switchTo(orgId, { reload: true });
85
+ * // window.location.reload() — clean slate, loses scroll position
86
+ * ```
87
+ */
88
+ export declare class OrgSwitchService {
89
+ private auth;
90
+ private readonly _switching;
91
+ private readonly _orgChanged;
92
+ /**
93
+ * `true` while a switch is in flight. UI should disable interactions
94
+ * with org-scoped data and show a loading indicator.
95
+ */
96
+ readonly switching: import("@angular/core").Signal<boolean>;
97
+ /**
98
+ * Fires after a successful switch, with the previous and new org ids.
99
+ * Components subscribe to invalidate caches / reset state.
100
+ *
101
+ * Fires AFTER the Firebase re-auth completes — listeners attached here
102
+ * see the updated Firebase user / activeOrg claim.
103
+ */
104
+ readonly orgChanged$: Observable<OrgChangedEvent>;
105
+ constructor(auth: AuthService);
106
+ /**
107
+ * Switch the user's active organization.
108
+ *
109
+ * Re-entrant safe: while a switch is in flight, additional calls are
110
+ * rejected silently (returns immediately). Inspect `switching()` to gate UI.
111
+ *
112
+ * @param orgId Target organization id. Must be one the user has a role in
113
+ * — backend rejects otherwise with `PERMISSION_DENIED`.
114
+ * @param options See `SwitchOrgOptions`.
115
+ *
116
+ * @throws The error from `auth.switchOrg` if the backend call fails.
117
+ * `switching` returns to `false` before the error propagates.
118
+ */
119
+ switchTo(orgId: string, options?: SwitchOrgOptions): Promise<void>;
120
+ /**
121
+ * Read the current `activeOrg` from the auth user signal.
122
+ * Falls back to empty string if the user isn't loaded yet.
123
+ */
124
+ private currentActiveOrg;
125
+ static ɵfac: i0.ɵɵFactoryDeclaration<OrgSwitchService, never>;
126
+ static ɵprov: i0.ɵɵInjectableDeclaration<OrgSwitchService>;
127
+ }
@@ -16,6 +16,17 @@ export interface CollectionOptions {
16
16
  * Default: true
17
17
  */
18
18
  timestamps?: boolean;
19
+ /**
20
+ * Si true, el path se trata como global y NO se prefija con `apps/{appId}/`.
21
+ *
22
+ * Usar para colecciones cross-app a nivel user/global, p.ej.
23
+ * `users/{uid}/notifications` (inbox global), `profiles/{uid}` o `public/...`.
24
+ *
25
+ * Las reglas de Firestore deben permitir el path absoluto correspondiente.
26
+ *
27
+ * Default: false (path prefijado a `apps/{appId}/...`).
28
+ */
29
+ skipAppPrefix?: boolean;
19
30
  }
20
31
  /**
21
32
  * Referencia a una sub-colección tipada.
@@ -73,8 +84,8 @@ export declare class FirestoreCollectionFactory {
73
84
  */
74
85
  export declare class TypedCollection<T extends FirestoreDocument> {
75
86
  private firestore;
76
- private collectionPath;
77
87
  private readonly options;
88
+ private readonly collectionPath;
78
89
  constructor(firestore: FirestoreService, collectionPath: string, options?: CollectionOptions);
79
90
  /**
80
91
  * Obtiene un documento por ID.
@@ -2,6 +2,19 @@ import { Firestore, FieldValue } from '@angular/fire/firestore';
2
2
  import { Observable } from 'rxjs';
3
3
  import { FirestoreDocument, PaginatedResult, QueryOptions } from './types';
4
4
  import * as i0 from "@angular/core";
5
+ /**
6
+ * Internal sentinel used to mark a collection path as global cross-app —
7
+ * i.e. it should NOT be prefixed with `apps/{appId}/`.
8
+ *
9
+ * Apps consume this via `CollectionOptions.skipAppPrefix`; the sentinel
10
+ * stays in `TypedCollection` and is stripped here before Firestore sees it.
11
+ *
12
+ * Format keeps it impossible to collide with a real Firestore path (`:` is
13
+ * not allowed in collection segments).
14
+ *
15
+ * @internal
16
+ */
17
+ export declare const ABS_PATH_SENTINEL = "__abs__:";
5
18
  /**
6
19
  * Servicio para operaciones CRUD en Firestore.
7
20
  *
@@ -43,6 +56,11 @@ export declare class FirestoreService {
43
56
  * Prefija el path de colección con el appId si está configurado.
44
57
  * Si no hay appId, retorna el path sin modificar (backward compatible).
45
58
  *
59
+ * Si el path empieza con `ABS_PATH_SENTINEL` (`__abs__:`), se asume que el
60
+ * caller quiere un path global cross-app (ej. `users/{uid}/notifications` —
61
+ * el inbox global de notificaciones). El sentinel se strippea y el resto
62
+ * del path se pasa verbatim, sin prefijo `apps/{appId}/`.
63
+ *
46
64
  * @internal
47
65
  */
48
66
  private prefixCollectionPath;
@@ -9,54 +9,87 @@ import { AppId, EmulatorConfig, FirebaseConfig, ValtechFirebaseConfig } from './
9
9
  import type { AnalyticsConfig } from './analytics-types';
10
10
  export type { AppId } from './types';
11
11
  /**
12
- * Genera paths de Storage con namespace por app.
12
+ * Genera paths de Storage alineados con storage.rules.
13
+ *
14
+ * Convenciones (ver frontend/firebase/storage.rules):
15
+ * - Avatar global del user (cross-app): /users/{uid}/avatar.jpg
16
+ * - Files privados del user (cross-app): /users/{uid}/files/{path}
17
+ * - Avatar per-app del user: /apps/{appId}/users/{uid}/avatar.jpg
18
+ * - Files per-app del user: /apps/{appId}/users/{uid}/files/{path}
19
+ * - Public per-app: /apps/{appId}/public/{path}
20
+ * - Shared per-app (auth-only): /apps/{appId}/shared/{path}
21
+ * - Public global: /public/{path}
13
22
  *
14
23
  * @example
15
- * // Path específico de la app
16
- * storagePaths.forApp('showcase', 'uploads', 'image.jpg')
17
- * // => 'showcase/uploads/image.jpg'
24
+ * storagePaths.forApp('showcase', 'public', 'banner.jpg')
25
+ * // => 'apps/showcase/public/banner.jpg'
26
+ *
27
+ * storagePaths.userAvatar('user123')
28
+ * // => 'users/user123/avatar.jpg'
18
29
  *
19
- * // Path compartido
20
- * storagePaths.shared.profilePhoto('user123', 'avatar.jpg')
21
- * // => 'profile-photos/user123/avatar.jpg'
30
+ * storagePaths.appUserFile('showcase', 'user123', 'doc.pdf')
31
+ * // => 'apps/showcase/users/user123/files/doc.pdf'
22
32
  */
23
33
  export declare const storagePaths: {
24
- /** Carpeta específica de la app: {appId}/{...paths} */
34
+ /** Path per-app: apps/{appId}/{...paths} */
25
35
  forApp: (appId: AppId, ...paths: string[]) => string;
26
- /** Carpetas compartidas (sin namespace) */
27
- shared: {
28
- /** Foto de perfil de usuario */
29
- profilePhoto: (userId: string, fileName: string) => string;
30
- /** Archivos públicos accesibles sin autenticación */
31
- public: (...paths: string[]) => string;
32
- };
36
+ /** Avatar global del user (cross-app) */
37
+ userAvatar: (userId: string) => string;
38
+ /** Thumbnail global del user (cross-app) */
39
+ userThumb: (userId: string) => string;
40
+ /** File privado global del user (cross-app) */
41
+ userFile: (userId: string, ...paths: string[]) => string;
42
+ /** Avatar per-app del user */
43
+ appUserAvatar: (appId: AppId, userId: string) => string;
44
+ /** File per-app del user */
45
+ appUserFile: (appId: AppId, userId: string, ...paths: string[]) => string;
46
+ /** Public global (acceso sin auth, write admin-only) */
47
+ public: (...paths: string[]) => string;
33
48
  };
34
49
  /**
35
- * Genera paths de colecciones con namespace por app.
50
+ * Genera paths de colecciones alineados con firestore.rules.
36
51
  *
37
- * IMPORTANTE: La estructura es /apps/{appId}/{collection}/{docId}
38
- * Firestore requiere número impar de segmentos para paths de colección.
52
+ * Convenciones (ver frontend/firebase/firestore.rules):
53
+ * - Cross-app shared:
54
+ * /users/{uid} (privado), /profiles/{uid} (público global),
55
+ * /users/{uid}/notifications (INBOX GLOBAL cross-app cross-org)
56
+ * - Per-app:
57
+ * /apps/{appId}/{collection}, /apps/{appId}/profiles/{uid}
58
+ * - Per-app per-user (notifications NO viven aquí):
59
+ * /apps/{appId}/users/{uid}/{collection}
60
+ * - Per-app per-org:
61
+ * /apps/{appId}/orgs/{orgId}/{collection}
39
62
  *
40
63
  * @example
41
- * // Colección específica de la app
42
64
  * collections.forApp('showcase', 'items')
43
65
  * // => 'apps/showcase/items'
44
66
  *
45
- * // Colección compartida
46
- * collections.shared.users
47
- * // => 'users'
67
+ * collections.appUser('showcase', 'user123', 'drafts')
68
+ * // => 'apps/showcase/users/user123/drafts'
69
+ *
70
+ * collections.appOrg('showcase', 'org_abc', 'posts')
71
+ * // => 'apps/showcase/orgs/org_abc/posts'
72
+ *
73
+ * collections.userNotifications('user123')
74
+ * // => 'users/user123/notifications'
48
75
  */
49
76
  export declare const collections: {
50
- /** Colección específica de la app: apps/{appId}/{collection} */
77
+ /** Per-app cross-user: apps/{appId}/{collection} */
51
78
  forApp: (appId: AppId, collectionName: string) => string;
52
- /** Colecciones compartidas (sin namespace, nivel raíz) */
79
+ /** Per-app per-user: apps/{appId}/users/{uid}/{collection} */
80
+ appUser: (appId: AppId, userId: string, collectionName: string) => string;
81
+ /** Per-app per-org: apps/{appId}/orgs/{orgId}/{collection} */
82
+ appOrg: (appId: AppId, orgId: string, collectionName: string) => string;
83
+ /** Per-app perfil público del user: apps/{appId}/profiles/{uid} */
84
+ appProfile: (appId: AppId, userId: string) => string;
85
+ /** Inbox global del user (cross-app cross-org) */
86
+ userNotifications: (userId: string) => string;
87
+ /** Cross-app shared (sin namespace, nivel raíz) */
53
88
  shared: {
54
- /** Usuarios del sistema */
89
+ /** Doc privado del user — /users/{uid} */
55
90
  users: string;
56
- /** Perfiles públicos */
91
+ /** Perfiles públicos globales — /profiles/{uid} */
57
92
  profiles: string;
58
- /** Notificaciones de usuarios */
59
- notifications: string;
60
93
  };
61
94
  };
62
95
  /**
package/lib/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Current version of valtech-components.
3
3
  * This is automatically updated during the publish process.
4
4
  */
5
- export declare const VERSION = "2.0.724";
5
+ export declare const VERSION = "2.0.726";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "valtech-components",
3
- "version": "2.0.724",
3
+ "version": "2.0.726",
4
4
  "private": false,
5
5
  "bin": {
6
6
  "valtech-firebase-config": "./src/lib/services/firebase/scripts/generate-sw-config.js"