@atlasauth/angular 0.1.0

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/LICENSE +21 -0
  4. package/dist/README.md +195 -0
  5. package/dist/esm2022/atlasauth-angular.mjs +5 -0
  6. package/dist/esm2022/lib/appearance.mjs +92 -0
  7. package/dist/esm2022/lib/atlas.config.mjs +8 -0
  8. package/dist/esm2022/lib/atlas.service.mjs +329 -0
  9. package/dist/esm2022/lib/atlas.ui.mjs +36 -0
  10. package/dist/esm2022/lib/components/attempt-flow.component.mjs +180 -0
  11. package/dist/esm2022/lib/components/flow-controller.mjs +88 -0
  12. package/dist/esm2022/lib/components/organization-profile.component.mjs +56 -0
  13. package/dist/esm2022/lib/components/organization-switcher.component.mjs +63 -0
  14. package/dist/esm2022/lib/components/sign-in.component.mjs +59 -0
  15. package/dist/esm2022/lib/components/sign-up.component.mjs +54 -0
  16. package/dist/esm2022/lib/components/user-button.component.mjs +94 -0
  17. package/dist/esm2022/lib/components/user-profile.component.mjs +81 -0
  18. package/dist/esm2022/lib/directives/atlas-root.directive.mjs +42 -0
  19. package/dist/esm2022/lib/directives/loaded.directive.mjs +23 -0
  20. package/dist/esm2022/lib/directives/loading.directive.mjs +23 -0
  21. package/dist/esm2022/lib/directives/protect.directive.mjs +82 -0
  22. package/dist/esm2022/lib/directives/signed-in.directive.mjs +29 -0
  23. package/dist/esm2022/lib/directives/signed-out.directive.mjs +29 -0
  24. package/dist/esm2022/lib/directives/toggle.directive.mjs +42 -0
  25. package/dist/esm2022/lib/fapi.mjs +9 -0
  26. package/dist/esm2022/lib/guards/atlas.guard.mjs +54 -0
  27. package/dist/esm2022/lib/i18n.mjs +78 -0
  28. package/dist/esm2022/lib/protect.mjs +9 -0
  29. package/dist/esm2022/lib/provide-atlas.mjs +85 -0
  30. package/dist/esm2022/lib/types.mjs +6 -0
  31. package/dist/esm2022/public-api.mjs +35 -0
  32. package/dist/fesm2022/atlasauth-angular.mjs +1557 -0
  33. package/dist/fesm2022/atlasauth-angular.mjs.map +1 -0
  34. package/dist/index.d.ts +5 -0
  35. package/dist/lib/appearance.d.ts +85 -0
  36. package/dist/lib/atlas.config.d.ts +31 -0
  37. package/dist/lib/atlas.service.d.ts +127 -0
  38. package/dist/lib/atlas.ui.d.ts +22 -0
  39. package/dist/lib/components/attempt-flow.component.d.ts +48 -0
  40. package/dist/lib/components/flow-controller.d.ts +34 -0
  41. package/dist/lib/components/organization-profile.component.d.ts +16 -0
  42. package/dist/lib/components/organization-switcher.component.d.ts +15 -0
  43. package/dist/lib/components/sign-in.component.d.ts +21 -0
  44. package/dist/lib/components/sign-up.component.d.ts +21 -0
  45. package/dist/lib/components/user-button.component.d.ts +19 -0
  46. package/dist/lib/components/user-profile.component.d.ts +15 -0
  47. package/dist/lib/directives/atlas-root.directive.d.ts +26 -0
  48. package/dist/lib/directives/loaded.directive.d.ts +13 -0
  49. package/dist/lib/directives/loading.directive.d.ts +12 -0
  50. package/dist/lib/directives/protect.directive.d.ts +41 -0
  51. package/dist/lib/directives/signed-in.directive.d.ts +17 -0
  52. package/dist/lib/directives/signed-out.directive.d.ts +17 -0
  53. package/dist/lib/directives/toggle.directive.d.ts +25 -0
  54. package/dist/lib/fapi.d.ts +8 -0
  55. package/dist/lib/guards/atlas.guard.d.ts +43 -0
  56. package/dist/lib/i18n.d.ts +23 -0
  57. package/dist/lib/protect.d.ts +8 -0
  58. package/dist/lib/provide-atlas.d.ts +46 -0
  59. package/dist/lib/types.d.ts +48 -0
  60. package/dist/public-api.d.ts +28 -0
  61. package/package.json +58 -0
@@ -0,0 +1,1557 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, inject, PLATFORM_ID, computed, Injectable, TemplateRef, ViewContainerRef, Directive, Input, ElementRef, Renderer2, RendererStyleFlags2, EventEmitter, Output, ChangeDetectionStrategy, Component, signal, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER, NgModule } from '@angular/core';
3
+ import { isPlatformBrowser } from '@angular/common';
4
+ import { toSignal } from '@angular/core/rxjs-interop';
5
+ import { BehaviorSubject, combineLatest, firstValueFrom } from 'rxjs';
6
+ import { map, distinctUntilChanged, filter, take } from 'rxjs/operators';
7
+ import { TokenCache, FapiClient, getProviderToken, readRedirectResult, stripRedirectParams, readExpiry, msUntilRefresh, nextStep, flowFromPending, initialFlow, advance, shouldKeepPolling, pollAttempt } from '@atlasauth/js';
8
+ export { FapiClient, advance, flowFromPending, getProviderToken, initialFlow, pollAttempt, prepareFactor, requestForStep, shouldKeepPolling } from '@atlasauth/js';
9
+ import { evaluate } from '@atlasauth/authz';
10
+ export { evaluate, hasFromClaims } from '@atlasauth/authz';
11
+ import { Router } from '@angular/router';
12
+
13
+ /**
14
+ * DI token carrying the resolved {@link AtlasOptions}. Populated by
15
+ * {@link provideAtlas} (standalone) or {@link AtlasModule.forRoot} (NgModule)
16
+ * and read by {@link AtlasService}. Applications rarely inject this directly.
17
+ */
18
+ const ATLAS_OPTIONS = new InjectionToken('ATLAS_OPTIONS');
19
+
20
+ /**
21
+ * §10.3 theming.
22
+ *
23
+ * "appearance prop: design tokens (colors, radius, font, spacing) + per-element
24
+ * class overrides (elements: { formButtonPrimary: "…" }). No iframe — components
25
+ * render in the host DOM so Tailwind/CSS just works."
26
+ *
27
+ * Rendering in the host DOM is the decision everything else follows from. An
28
+ * iframe would isolate styles perfectly and be unusable: it cannot be sized to
29
+ * its content reliably, it breaks password managers, it breaks autofill, and a
30
+ * Tailwind class the customer writes has no effect inside it. Sharing the DOM
31
+ * means the customer's CSS reaches our markup, which is exactly what they want
32
+ * and what makes an override system necessary rather than decorative.
33
+ *
34
+ * Element classes are APPENDED to ours, never replacing them. A customer adding
35
+ * `className="rounded-xl"` wants a rounder button, not an unstyled one — and
36
+ * replacement is a support burden where every override starts by rebuilding the
37
+ * component's base styles from scratch.
38
+ */
39
+ const DEFAULT_TOKENS = {
40
+ colorPrimary: '#5b5bd6',
41
+ colorText: '#1c1c1f',
42
+ colorBackground: '#ffffff',
43
+ colorDanger: '#d64545',
44
+ colorBorder: '#e4e4e9',
45
+ borderRadius: '8px',
46
+ fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
47
+ spacingUnit: '4px',
48
+ };
49
+ /**
50
+ * §10.3: "prebuilt themes shipped (default, dark, neobrutalist as a demo of the
51
+ * override system)". Neobrutalist exists to prove the token set is expressive
52
+ * enough for a theme that looks nothing like the default — a theming system
53
+ * that only produces tasteful variations of itself is not a theming system.
54
+ */
55
+ const THEMES = {
56
+ default: DEFAULT_TOKENS,
57
+ dark: {
58
+ ...DEFAULT_TOKENS,
59
+ colorText: '#e6e9ef',
60
+ colorBackground: '#14171c',
61
+ colorBorder: '#242932',
62
+ colorPrimary: '#6ea8fe',
63
+ },
64
+ neobrutalist: {
65
+ ...DEFAULT_TOKENS,
66
+ colorPrimary: '#ffe600',
67
+ colorText: '#000000',
68
+ colorBackground: '#ffffff',
69
+ colorBorder: '#000000',
70
+ borderRadius: '0px',
71
+ fontFamily: '"Courier New", ui-monospace, monospace',
72
+ },
73
+ };
74
+ /**
75
+ * Resolve tokens: defaults, then the base theme, then explicit variables.
76
+ *
77
+ * Later wins, and `undefined` never overwrites. A customer setting one variable
78
+ * on the dark theme expects the other eleven to stay dark, not silently revert
79
+ * to the light defaults.
80
+ */
81
+ function resolveTokens(appearance) {
82
+ const base = appearance?.baseTheme ? THEMES[appearance.baseTheme] : DEFAULT_TOKENS;
83
+ const overrides = appearance?.variables ?? {};
84
+ const resolved = { ...DEFAULT_TOKENS, ...base };
85
+ for (const [key, value] of Object.entries(overrides)) {
86
+ if (value !== undefined)
87
+ resolved[key] = value;
88
+ }
89
+ return resolved;
90
+ }
91
+ /** Tokens as CSS custom properties, for the host DOM to inherit. */
92
+ function cssVariables(tokens) {
93
+ const vars = {};
94
+ for (const [key, value] of Object.entries(tokens)) {
95
+ // camelCase → --atlas-kebab-case, which is what a customer writes in CSS.
96
+ vars[`--atlas-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`] = value;
97
+ }
98
+ return vars;
99
+ }
100
+ /**
101
+ * Combine our base class with the customer's override.
102
+ *
103
+ * Appended, never replaced — a customer adding `rounded-xl` wants a rounder
104
+ * button, not an unstyled one. Theirs comes last so it wins on equal
105
+ * specificity, which is the behaviour anyone writing Tailwind expects.
106
+ */
107
+ function classFor(element, appearance, base) {
108
+ const override = appearance?.elements?.[element];
109
+ return override ? `${base} ${override}` : base;
110
+ }
111
+
112
+ /**
113
+ * §10.3: "Every string passes through an i18n catalog; en-US ships, catalog
114
+ * format documented for community locales."
115
+ *
116
+ * A flat key-to-string map rather than nested objects, because nesting looks
117
+ * tidier and makes a missing key a runtime crash on `undefined.of.a.chain`
118
+ * rather than a visible fallback.
119
+ *
120
+ * A missing key renders the KEY, not an empty string. A blank button is a bug
121
+ * nobody can report; a button reading `signIn.submit` is one anyone can.
122
+ */
123
+ const EN_US = {
124
+ 'signIn.title': 'Sign in',
125
+ 'signIn.subtitle': 'to continue to {application}',
126
+ 'signIn.identifierLabel': 'Email address',
127
+ 'signIn.passwordLabel': 'Password',
128
+ 'signIn.submit': 'Continue',
129
+ 'signIn.forgotPassword': 'Forgot password?',
130
+ 'signIn.noAccount': 'No account?',
131
+ 'signIn.signUpLink': 'Sign up',
132
+ 'signIn.orDivider': 'or',
133
+ 'signIn.socialButton': 'Continue with {provider}',
134
+ 'signIn.emailCodeLabel': 'Verification code',
135
+ 'signIn.magicLinkSent': 'Check your email for a sign-in link.',
136
+ 'signIn.checkOtherTab': 'You can close this tab — you are signed in where you started.',
137
+ 'signUp.title': 'Create your account',
138
+ 'signUp.submit': 'Continue',
139
+ 'signUp.haveAccount': 'Already have an account?',
140
+ 'signUp.signInLink': 'Sign in',
141
+ 'mfa.title': 'Two-step verification',
142
+ 'mfa.subtitle': 'Enter the code from your authenticator app.',
143
+ 'mfa.codeLabel': 'Code',
144
+ 'mfa.enrollFirstCodeLabel': 'Code from your authenticator app',
145
+ 'mfa.enrollSecondCodeLabel': 'The next code it shows',
146
+ 'mfa.usePasskey': 'Use a passkey instead',
147
+ 'mfa.useBackupCode': 'Use a recovery code',
148
+ 'mfa.submit': 'Verify',
149
+ 'reset.title': 'Reset your password',
150
+ 'reset.submit': 'Send reset code',
151
+ 'reset.newPasswordLabel': 'New password',
152
+ 'userButton.manageAccount': 'Manage account',
153
+ 'userButton.signOut': 'Sign out',
154
+ 'userButton.signOutAll': 'Sign out of all devices',
155
+ 'userProfile.title': 'Account',
156
+ 'userProfile.profileSection': 'Profile',
157
+ 'userProfile.emailsSection': 'Email addresses',
158
+ 'userProfile.securitySection': 'Security',
159
+ 'userProfile.devicesSection': 'Active devices',
160
+ 'userProfile.dangerSection': 'Danger zone',
161
+ 'userProfile.addEmail': 'Add an email address',
162
+ 'userProfile.makePrimary': 'Make primary',
163
+ 'userProfile.unverified': 'Unverified',
164
+ 'userProfile.revokeDevice': 'Sign out',
165
+ 'userProfile.deleteAccount': 'Delete account',
166
+ 'organization.switcher': 'Switch organization',
167
+ 'organization.personal': 'Personal account',
168
+ 'organization.members': 'Members',
169
+ 'organization.invite': 'Invite member',
170
+ 'organization.roleLabel': 'Role',
171
+ 'error.generic': 'Something went wrong. Please try again.',
172
+ 'error.network': 'We could not reach the server. Check your connection.',
173
+ };
174
+ /**
175
+ * Look up a key and interpolate `{placeholders}`.
176
+ *
177
+ * An unknown placeholder is left visible for the same reason a missing key is:
178
+ * `Continue with {provider}` in the UI is a bug report waiting to be filed,
179
+ * whereas `Continue with ` is one nobody notices until a customer does.
180
+ */
181
+ function translate(catalog, key, variables = {}) {
182
+ const template = catalog[key] ?? EN_US[key] ?? key;
183
+ return template.replace(/\{(\w+)\}/g, (match, name) => variables[name] ?? match);
184
+ }
185
+ /** Merge a community locale over en-US so a partial translation still renders. */
186
+ function withFallback(locale) {
187
+ return { ...EN_US, ...locale };
188
+ }
189
+
190
+ /**
191
+ * `AtlasService` — the Angular peer of React's `<AtlasProvider>` + hooks.
192
+ *
193
+ * It holds exactly one thing that matters in memory: the short-lived JWT, in a
194
+ * {@link TokenCache}. Not localStorage, not a cookie this code writes — the
195
+ * refresh token is HttpOnly so browser script never touches it, and the JWT
196
+ * lives only as long as the tab. Anything persisted would survive the tab and
197
+ * become the first thing an XSS reads.
198
+ *
199
+ * Reactive auth state is exposed two ways so callers can pick their idiom:
200
+ * - RxJS observables (`user$`, `session$`, `isSignedIn$`, …) — the primary API,
201
+ * the analogue of the React hooks' return values.
202
+ * - Angular signals (`user`, `session`, `isSignedIn`, …) — the same values as
203
+ * `Signal<T>`, for templates and `computed()`.
204
+ *
205
+ * The imperative methods (`signOut`, `getToken`, `getProviderToken`,
206
+ * `setActiveOrganization`, `reload`) mirror the actions the hooks return.
207
+ */
208
+ class AtlasService {
209
+ options = inject(ATLAS_OPTIONS);
210
+ platformId = inject(PLATFORM_ID);
211
+ isBrowser = isPlatformBrowser(this.platformId);
212
+ baseUrl;
213
+ publishableKey;
214
+ doFetch;
215
+ cache = new TokenCache();
216
+ fapiClient;
217
+ refreshTimer = null;
218
+ catalog;
219
+ /**
220
+ * A sign-in attempt handed back by an OAuth redirect that still needs a second
221
+ * factor (no session was issued). Null in the ordinary case; the `<atlas-sign-in>`
222
+ * flow seeds itself from this so the user resumes at the 2FA step. Captured
223
+ * once, synchronously, at construction — before any flow component reads it.
224
+ */
225
+ pendingAttempt;
226
+ // --- Reactive state (source of truth) ---------------------------------------
227
+ _status = new BehaviorSubject('loading');
228
+ _user = new BehaviorSubject(null);
229
+ _session = new BehaviorSubject(null);
230
+ _claims = new BehaviorSubject(null);
231
+ _memberships = new BehaviorSubject([]);
232
+ /** The three-state auth lifecycle: `loading` → `signed_in` | `signed_out`. */
233
+ status$ = this._status.asObservable();
234
+ user$ = this._user.asObservable();
235
+ session$ = this._session.asObservable();
236
+ claims$ = this._claims.asObservable();
237
+ memberships$ = this._memberships.asObservable();
238
+ /**
239
+ * True once the SDK has finished its first boot. Distinguishing "still loading"
240
+ * from "signed out" is what lets a template avoid the flash of a sign-in form
241
+ * on every page load.
242
+ */
243
+ isLoaded$ = this.status$.pipe(map((s) => s !== 'loading'), distinctUntilChanged());
244
+ isSignedIn$ = this.status$.pipe(map((s) => s === 'signed_in'), distinctUntilChanged());
245
+ /** The active organization membership, derived from the token's `org_id` claim. */
246
+ organization$ = combineLatest([
247
+ this._memberships,
248
+ this._claims,
249
+ ]).pipe(map(([memberships, claims]) => memberships.find((m) => m.organization.id === claims?.org_id) ?? null));
250
+ // --- Signal mirror ----------------------------------------------------------
251
+ status = toSignal(this.status$, { initialValue: 'loading' });
252
+ user = toSignal(this.user$, { initialValue: null });
253
+ session = toSignal(this.session$, { initialValue: null });
254
+ claims = toSignal(this.claims$, { initialValue: null });
255
+ memberships = toSignal(this.memberships$, {
256
+ initialValue: [],
257
+ });
258
+ isLoaded = computed(() => this.status() !== 'loading');
259
+ isSignedIn = computed(() => this.status() === 'signed_in');
260
+ organization = computed(() => {
261
+ const orgId = this.claims()?.org_id;
262
+ return this.memberships().find((m) => m.organization.id === orgId) ?? null;
263
+ });
264
+ constructor() {
265
+ const options = this.options;
266
+ this.publishableKey = options.publishableKey;
267
+ this.baseUrl = options.frontendApi ?? '';
268
+ this.doFetch = options.fetchImpl ?? ((...args) => fetch(...args));
269
+ this.catalog = options.localization ? withFallback(options.localization) : EN_US;
270
+ this.fapiClient = new FapiClient({
271
+ publishableKey: this.publishableKey,
272
+ baseUrl: this.baseUrl,
273
+ fetchImpl: this.doFetch,
274
+ });
275
+ // Captured synchronously (not in an async init) so it is available before any
276
+ // flow component reads it: a needs_second_factor redirect carries a status but
277
+ // no ticket, and that is what the 2FA resume seeds from.
278
+ this.pendingAttempt = this.isBrowser ? this.readPendingAttempt() : null;
279
+ // Boot once, browser only. On the server there is no cookie jar to read a
280
+ // session from, so we leave the state at `loading` and let hydration boot it.
281
+ if (this.isBrowser) {
282
+ void this.boot();
283
+ }
284
+ }
285
+ ngOnDestroy() {
286
+ this.clearRefreshTimer();
287
+ this._status.complete();
288
+ this._user.complete();
289
+ this._session.complete();
290
+ this._claims.complete();
291
+ this._memberships.complete();
292
+ }
293
+ // --- Configuration accessors ------------------------------------------------
294
+ get appearance() {
295
+ return this.options.appearance;
296
+ }
297
+ get localization() {
298
+ return this.catalog;
299
+ }
300
+ get frontendApi() {
301
+ return this.baseUrl;
302
+ }
303
+ /** Tokens as CSS custom properties, for a host element to inherit. */
304
+ appearanceVariables() {
305
+ return cssVariables(resolveTokens(this.options.appearance));
306
+ }
307
+ /** A fresh {@link FapiClient} for driving a sign-in/sign-up flow. */
308
+ createFapiClient() {
309
+ return new FapiClient({ publishableKey: this.publishableKey, baseUrl: this.baseUrl });
310
+ }
311
+ // --- Imperative API (mirrors the React hooks' actions) ----------------------
312
+ /**
313
+ * The current session's JWT, always fresh: if the cached token is within 5s of
314
+ * expiry it is refreshed first rather than handed over. Null when signed out.
315
+ */
316
+ async getToken() {
317
+ const usable = this.cache.get(5_000);
318
+ if (usable)
319
+ return usable.jwt;
320
+ await this.refresh();
321
+ return this.cache.get(5_000)?.jwt ?? null;
322
+ }
323
+ /**
324
+ * The signed-in user's OWN live access token at a connected provider (Google,
325
+ * GitHub, …), so the app can call that provider's API from the browser. Null
326
+ * when there is no usable token — no linked account, or the provider granted /
327
+ * kept none. The server refreshes a stale token on read; the refresh token is
328
+ * never exposed.
329
+ */
330
+ getProviderToken(provider) {
331
+ return getProviderToken(this.fapiClient, provider);
332
+ }
333
+ /** Imperative `has()` — the same evaluation `*atlasProtect` renders on. */
334
+ has(condition) {
335
+ return evaluate(this._claims.value, condition).allowed;
336
+ }
337
+ /** Revoke the current session server-side and drop all local auth state. */
338
+ async signOut() {
339
+ const current = this.cache.peek();
340
+ if (current) {
341
+ await this.call(`/v1/client/sessions/${current.sessionId}/revoke`, {
342
+ method: 'POST',
343
+ }).catch(() => undefined);
344
+ }
345
+ this.clearRefreshTimer();
346
+ this.cache.clear();
347
+ this._status.next('signed_out');
348
+ this._user.next(null);
349
+ this._session.next(null);
350
+ this._claims.next(null);
351
+ this._memberships.next([]);
352
+ }
353
+ /**
354
+ * Switch the active organization (or `null` for the personal account). The
355
+ * new token carries the new org claims, so the app's permission checks update
356
+ * without a re-login.
357
+ */
358
+ async setActiveOrganization(organizationId) {
359
+ const current = this.cache.peek();
360
+ if (!current)
361
+ return;
362
+ const response = await this.call(`/v1/client/sessions/${current.sessionId}/touch`, {
363
+ method: 'POST',
364
+ body: JSON.stringify({ active_organization_id: organizationId }),
365
+ });
366
+ if (!response.ok)
367
+ return;
368
+ const body = (await response.json());
369
+ this.applyToken(body.jwt, current.sessionId);
370
+ await this.reload();
371
+ }
372
+ /** Re-read `/v1/client`: refresh user, session, memberships and status. */
373
+ async reload() {
374
+ try {
375
+ const response = await this.call('/v1/client');
376
+ if (!response.ok)
377
+ throw new Error('boot failed');
378
+ const body = (await response.json());
379
+ if (!body.session || !body.user) {
380
+ this.clearRefreshTimer();
381
+ this.cache.clear();
382
+ this._status.next('signed_out');
383
+ this._user.next(null);
384
+ this._session.next(null);
385
+ this._claims.next(null);
386
+ this._memberships.next([]);
387
+ return;
388
+ }
389
+ this._user.next(body.user);
390
+ this._session.next(body.session);
391
+ this._memberships.next(body.organization_memberships ?? []);
392
+ if (body.jwt)
393
+ this.applyToken(body.jwt, body.session.id);
394
+ this._status.next('signed_in');
395
+ this.scheduleRefresh();
396
+ }
397
+ catch {
398
+ // A failed boot is NOT signed-out. Rendering a sign-in form because the
399
+ // network hiccuped throws away a perfectly good session and forces a
400
+ // needless re-authentication. Only promote loading → signed_out.
401
+ if (this._status.value === 'loading')
402
+ this._status.next('signed_out');
403
+ }
404
+ }
405
+ // --- Internals --------------------------------------------------------------
406
+ /**
407
+ * §6.3 step 3: complete a redirect-based sign-in, then boot. An OAuth or
408
+ * hosted-page flow lands back with a one-time ticket in the URL; exchange it
409
+ * for cookies, scrub the URL so a reload cannot replay it, THEN read the
410
+ * session. A redirect carrying only a status (a second factor is still owed)
411
+ * has no ticket, so nothing is exchanged and the app boots signed-out.
412
+ */
413
+ async boot() {
414
+ const result = readRedirectResult(window.location.search);
415
+ if (result?.ticket) {
416
+ await this.call('/v1/client/tickets/exchange', {
417
+ method: 'POST',
418
+ body: JSON.stringify({ attempt_id: result.attemptId, ticket: result.ticket }),
419
+ }).catch(() => undefined);
420
+ }
421
+ if (result) {
422
+ window.history.replaceState({}, document.title, stripRedirectParams(window.location.href));
423
+ }
424
+ await this.reload();
425
+ }
426
+ readPendingAttempt() {
427
+ const result = readRedirectResult(window.location.search);
428
+ return result?.status && !result.ticket
429
+ ? { id: result.attemptId, status: result.status }
430
+ : null;
431
+ }
432
+ call(path, init) {
433
+ return this.doFetch(`${this.baseUrl}${path}`, {
434
+ ...init,
435
+ // Cookies carry the session; a request without this is indistinguishable
436
+ // from being signed out.
437
+ credentials: 'include',
438
+ headers: {
439
+ 'x-publishable-key': this.publishableKey,
440
+ ...(init?.body ? { 'content-type': 'application/json' } : {}),
441
+ ...init?.headers,
442
+ },
443
+ });
444
+ }
445
+ applyToken(jwt, sessionId) {
446
+ const expiresAt = readExpiry(jwt);
447
+ if (!expiresAt)
448
+ return;
449
+ this.cache.set({ jwt, expiresAt, sessionId });
450
+ this._claims.next(decodeClaims(jwt));
451
+ }
452
+ async refresh() {
453
+ const current = this.cache.peek();
454
+ if (!current)
455
+ return;
456
+ try {
457
+ const response = await this.call(`/v1/client/sessions/${current.sessionId}/tokens`, {
458
+ method: 'POST',
459
+ });
460
+ if (!response.ok)
461
+ throw new Error('refresh failed');
462
+ const body = (await response.json());
463
+ this.applyToken(body.jwt, body.session_id);
464
+ this.scheduleRefresh();
465
+ }
466
+ catch {
467
+ // The session may genuinely be gone. Re-boot rather than guess.
468
+ await this.reload();
469
+ }
470
+ }
471
+ /** §7.4: proactive, jittered refresh scheduled from the token's own expiry. */
472
+ scheduleRefresh() {
473
+ this.clearRefreshTimer();
474
+ if (this._status.value !== 'signed_in')
475
+ return;
476
+ const token = this.cache.peek();
477
+ if (!token)
478
+ return;
479
+ const delay = msUntilRefresh({ token, now: Date.now() });
480
+ this.refreshTimer = setTimeout(() => void this.refresh(), delay);
481
+ }
482
+ clearRefreshTimer() {
483
+ if (this.refreshTimer) {
484
+ clearTimeout(this.refreshTimer);
485
+ this.refreshTimer = null;
486
+ }
487
+ }
488
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
489
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasService, providedIn: 'root' });
490
+ }
491
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasService, decorators: [{
492
+ type: Injectable,
493
+ args: [{ providedIn: 'root' }]
494
+ }], ctorParameters: () => [] });
495
+ /** Decode a JWT payload without verifying it — a scheduling/UI hint only. */
496
+ function decodeClaims(jwt) {
497
+ const parts = jwt.split('.');
498
+ if (parts.length !== 3)
499
+ return null;
500
+ try {
501
+ return JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
502
+ }
503
+ catch {
504
+ return null;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Shared machinery for the boolean structural directives (`*atlasSignedIn`,
510
+ * `*atlasSignedOut`, `*atlasLoading`, `*atlasLoaded`). A subclass names the
511
+ * stream to gate on; this base mounts the embedded view exactly when that stream
512
+ * is true and tears it down otherwise, mirroring how the React `<SignedIn>` /
513
+ * `<SignedOut>` components render `children` or `null`.
514
+ */
515
+ class AtlasToggleDirective {
516
+ atlas = inject(AtlasService);
517
+ templateRef = inject(TemplateRef);
518
+ viewContainer = inject(ViewContainerRef);
519
+ sub;
520
+ shown = false;
521
+ ngOnInit() {
522
+ this.sub = this.source()
523
+ .pipe(distinctUntilChanged())
524
+ .subscribe((show) => this.render(show));
525
+ }
526
+ ngOnDestroy() {
527
+ this.sub?.unsubscribe();
528
+ }
529
+ render(show) {
530
+ if (show && !this.shown) {
531
+ this.viewContainer.createEmbeddedView(this.templateRef);
532
+ this.shown = true;
533
+ }
534
+ else if (!show && this.shown) {
535
+ this.viewContainer.clear();
536
+ this.shown = false;
537
+ }
538
+ }
539
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasToggleDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
540
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasToggleDirective, ngImport: i0 });
541
+ }
542
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasToggleDirective, decorators: [{
543
+ type: Directive
544
+ }] });
545
+
546
+ /**
547
+ * `*atlasSignedIn` — render the template only when the SDK has loaded AND the
548
+ * user is signed in. The Angular peer of React's `<SignedIn>`. Renders nothing
549
+ * while loading, so no signed-in UI flashes before boot completes.
550
+ *
551
+ * ```html
552
+ * <div *atlasSignedIn>Welcome back</div>
553
+ * ```
554
+ */
555
+ class AtlasSignedInDirective extends AtlasToggleDirective {
556
+ source() {
557
+ return combineLatest([this.atlas.isLoaded$, this.atlas.isSignedIn$]).pipe(map(([loaded, signedIn]) => loaded && signedIn));
558
+ }
559
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasSignedInDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive });
560
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasSignedInDirective, isStandalone: true, selector: "[atlasSignedIn]", usesInheritance: true, ngImport: i0 });
561
+ }
562
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasSignedInDirective, decorators: [{
563
+ type: Directive,
564
+ args: [{
565
+ selector: '[atlasSignedIn]',
566
+ standalone: true,
567
+ }]
568
+ }] });
569
+
570
+ /**
571
+ * `*atlasSignedOut` — render the template only when the SDK has loaded AND the
572
+ * user is signed out. The Angular peer of React's `<SignedOut>`. Renders nothing
573
+ * while loading, the symmetric partner of `*atlasSignedIn`.
574
+ *
575
+ * ```html
576
+ * <atlas-sign-in *atlasSignedOut></atlas-sign-in>
577
+ * ```
578
+ */
579
+ class AtlasSignedOutDirective extends AtlasToggleDirective {
580
+ source() {
581
+ return combineLatest([this.atlas.isLoaded$, this.atlas.isSignedIn$]).pipe(map(([loaded, signedIn]) => loaded && !signedIn));
582
+ }
583
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasSignedOutDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive });
584
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasSignedOutDirective, isStandalone: true, selector: "[atlasSignedOut]", usesInheritance: true, ngImport: i0 });
585
+ }
586
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasSignedOutDirective, decorators: [{
587
+ type: Directive,
588
+ args: [{
589
+ selector: '[atlasSignedOut]',
590
+ standalone: true,
591
+ }]
592
+ }] });
593
+
594
+ /**
595
+ * `*atlasLoading` — render the template only while the SDK is still booting. The
596
+ * Angular peer of React's `<AtlasLoading>`; show a spinner here.
597
+ */
598
+ class AtlasLoadingDirective extends AtlasToggleDirective {
599
+ source() {
600
+ return this.atlas.isLoaded$.pipe(map((loaded) => !loaded));
601
+ }
602
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasLoadingDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive });
603
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasLoadingDirective, isStandalone: true, selector: "[atlasLoading]", usesInheritance: true, ngImport: i0 });
604
+ }
605
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasLoadingDirective, decorators: [{
606
+ type: Directive,
607
+ args: [{
608
+ selector: '[atlasLoading]',
609
+ standalone: true,
610
+ }]
611
+ }] });
612
+
613
+ /**
614
+ * `*atlasLoaded` — render the template only once the SDK has finished booting.
615
+ * The Angular peer of React's `<AtlasLoaded>`, the symmetric partner of
616
+ * `*atlasLoading`.
617
+ */
618
+ class AtlasLoadedDirective extends AtlasToggleDirective {
619
+ source() {
620
+ return this.atlas.isLoaded$;
621
+ }
622
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasLoadedDirective, deps: null, target: i0.ɵɵFactoryTarget.Directive });
623
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasLoadedDirective, isStandalone: true, selector: "[atlasLoaded]", usesInheritance: true, ngImport: i0 });
624
+ }
625
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasLoadedDirective, decorators: [{
626
+ type: Directive,
627
+ args: [{
628
+ selector: '[atlasLoaded]',
629
+ standalone: true,
630
+ }]
631
+ }] });
632
+
633
+ /**
634
+ * `*atlasProtect` — render the template only when the active organization role
635
+ * satisfies a {@link ProtectCondition}. The Angular peer of React's
636
+ * `<Protect permission="…">`.
637
+ *
638
+ * A RENDERING helper, never an authorization boundary: anyone can flip the
639
+ * condition in devtools. The server checking the same permission is what
640
+ * actually stops them — this directive's only job is to avoid showing a control
641
+ * that would fail.
642
+ *
643
+ * ```html
644
+ * <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">Invite</button>
645
+ *
646
+ * <!-- with a fallback (identical microsyntax to *ngIf … else) -->
647
+ * <div *atlasProtect="{ role: 'admin' }; else denied">Admin tools</div>
648
+ * <ng-template #denied>You lack access.</ng-template>
649
+ * ```
650
+ *
651
+ * An empty condition (`*atlasProtect`) means simply "signed in".
652
+ */
653
+ class AtlasProtectDirective {
654
+ atlas = inject(AtlasService);
655
+ templateRef = inject(TemplateRef);
656
+ viewContainer = inject(ViewContainerRef);
657
+ condition$ = new BehaviorSubject({});
658
+ elseTemplate = null;
659
+ sub;
660
+ rendered = 'none';
661
+ /** The condition to evaluate. An empty/omitted value means "signed in". */
662
+ set atlasProtect(condition) {
663
+ this.condition$.next(condition || {});
664
+ }
665
+ /** Template rendered when the condition is not met (the `else` branch). */
666
+ set atlasProtectElse(template) {
667
+ this.elseTemplate = template;
668
+ // Re-render against the current state so a late-bound else takes effect.
669
+ this.condition$.next(this.condition$.value);
670
+ }
671
+ ngOnInit() {
672
+ this.sub = combineLatest([this.atlas.status$, this.atlas.claims$, this.condition$]).subscribe(([status, claims, condition]) => {
673
+ if (status === 'loading') {
674
+ this.show('none');
675
+ return;
676
+ }
677
+ this.show(evaluate(claims, condition).allowed ? 'main' : 'else');
678
+ });
679
+ }
680
+ ngOnDestroy() {
681
+ this.sub?.unsubscribe();
682
+ }
683
+ show(which) {
684
+ if (which === this.rendered)
685
+ return;
686
+ this.viewContainer.clear();
687
+ if (which === 'main') {
688
+ this.viewContainer.createEmbeddedView(this.templateRef);
689
+ }
690
+ else if (which === 'else' && this.elseTemplate) {
691
+ this.viewContainer.createEmbeddedView(this.elseTemplate);
692
+ }
693
+ this.rendered = which;
694
+ }
695
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasProtectDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
696
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasProtectDirective, isStandalone: true, selector: "[atlasProtect]", inputs: { atlasProtect: "atlasProtect", atlasProtectElse: "atlasProtectElse" }, ngImport: i0 });
697
+ }
698
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasProtectDirective, decorators: [{
699
+ type: Directive,
700
+ args: [{
701
+ selector: '[atlasProtect]',
702
+ standalone: true,
703
+ }]
704
+ }], propDecorators: { atlasProtect: [{
705
+ type: Input
706
+ }], atlasProtectElse: [{
707
+ type: Input
708
+ }] } });
709
+
710
+ /**
711
+ * `atlasRoot` — apply the resolved appearance design tokens as `--atlas-*` CSS
712
+ * custom properties (and the `atlas-root` class) onto its host element, so the
713
+ * Atlas components rendered inside inherit them.
714
+ *
715
+ * React does this with the wrapping `<div className="atlas-root" style={…}>`
716
+ * inside `<AtlasProvider>`. Angular has no single wrapping element, so a
717
+ * consumer opts in by putting `atlasRoot` on whatever element encloses the
718
+ * Atlas UI (commonly the app root):
719
+ *
720
+ * ```html
721
+ * <div atlasRoot>
722
+ * <router-outlet></router-outlet>
723
+ * </div>
724
+ * ```
725
+ */
726
+ class AtlasRootDirective {
727
+ atlas = inject(AtlasService);
728
+ el = inject(ElementRef);
729
+ renderer = inject(Renderer2);
730
+ ngOnInit() {
731
+ this.renderer.addClass(this.el.nativeElement, 'atlas-root');
732
+ const vars = this.atlas.appearanceVariables();
733
+ for (const [name, value] of Object.entries(vars)) {
734
+ // DashCase so a `--atlas-*` custom property is written verbatim, not camelCased.
735
+ this.renderer.setStyle(this.el.nativeElement, name, value, RendererStyleFlags2.DashCase);
736
+ }
737
+ }
738
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasRootDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
739
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: AtlasRootDirective, isStandalone: true, selector: "[atlasRoot]", ngImport: i0 });
740
+ }
741
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasRootDirective, decorators: [{
742
+ type: Directive,
743
+ args: [{
744
+ selector: '[atlasRoot]',
745
+ standalone: true,
746
+ }]
747
+ }] });
748
+
749
+ /**
750
+ * The server-driven flow shell, shared by `<atlas-sign-in>` and
751
+ * `<atlas-sign-up>`. The Angular peer of React's internal `AttemptFlow`.
752
+ *
753
+ * It contains NO decision about what comes next: it renders whatever `status`
754
+ * the server returned, mapped through `nextStep` from `@atlasauth/js` (the same
755
+ * exhaustive mapping the React SDK uses), so adding a step is a server change
756
+ * and an out-of-date client degrades to a visible "please update" message rather
757
+ * than a blank screen.
758
+ */
759
+ class AttemptFlowComponent {
760
+ atlas = inject(AtlasService);
761
+ attempt = null;
762
+ busy = false;
763
+ errors = [];
764
+ titleKey = 'signIn.title';
765
+ /** Emits the collected field values when the form is submitted. */
766
+ submitFlow = new EventEmitter();
767
+ values = {};
768
+ t(key, vars) {
769
+ return translate(this.atlas.localization, key, vars);
770
+ }
771
+ cls(key, base) {
772
+ return classFor(key, this.atlas.appearance, base);
773
+ }
774
+ setValue(name, value) {
775
+ this.values = { ...this.values, [name]: value };
776
+ }
777
+ onSubmit(event) {
778
+ event.preventDefault();
779
+ this.submitFlow.emit(this.values);
780
+ }
781
+ formErrors() {
782
+ return this.errors.filter((error) => !error.param);
783
+ }
784
+ fieldErrorsFor(name) {
785
+ return this.errors.filter((error) => error.param === name);
786
+ }
787
+ step() {
788
+ return this.attempt ? nextStep(this.attempt) : { kind: 'collect_identifier' };
789
+ }
790
+ showSubmit() {
791
+ const kind = this.step().kind;
792
+ return kind !== 'done' && kind !== 'await_oauth';
793
+ }
794
+ fields() {
795
+ const step = this.step();
796
+ switch (step.kind) {
797
+ case 'collect_identifier':
798
+ return [{ kind: 'input', name: 'identifier', labelKey: 'signIn.identifierLabel', type: 'email' }];
799
+ case 'collect_first_factor':
800
+ return [{ kind: 'input', name: 'password', labelKey: 'signIn.passwordLabel', type: 'password' }];
801
+ case 'collect_second_factor':
802
+ return [{ kind: 'input', name: 'code', labelKey: 'mfa.codeLabel', type: 'text' }];
803
+ case 'enroll_second_factor':
804
+ // Two consecutive codes (§5.6): a phone with a fast clock fails here
805
+ // rather than on every sign-in afterwards.
806
+ return [
807
+ { kind: 'input', name: 'code', labelKey: 'mfa.enrollFirstCodeLabel', type: 'text' },
808
+ { kind: 'input', name: 'secondCode', labelKey: 'mfa.enrollSecondCodeLabel', type: 'text' },
809
+ ];
810
+ case 'collect_email_code':
811
+ return [{ kind: 'input', name: 'code', labelKey: 'signIn.emailCodeLabel', type: 'text' }];
812
+ case 'collect_new_password':
813
+ return [{ kind: 'input', name: 'password', labelKey: 'reset.newPasswordLabel', type: 'password' }];
814
+ case 'await_oauth':
815
+ return [msg('signIn.magicLinkSent')];
816
+ case 'done':
817
+ return [msg('signIn.checkOtherTab')];
818
+ case 'restart':
819
+ return [msg('error.generic')];
820
+ default:
821
+ // A status this SDK version does not know. An honest message beats a
822
+ // blank login box the user cannot act on.
823
+ return [msg('error.generic')];
824
+ }
825
+ }
826
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
827
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: AttemptFlowComponent, isStandalone: true, selector: "atlas-attempt-flow", inputs: { attempt: "attempt", busy: "busy", errors: "errors", titleKey: "titleKey" }, outputs: { submitFlow: "submitFlow" }, ngImport: i0, template: `
828
+ <div [class]="cls('card', 'atlas-card')">
829
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
830
+
831
+ @for (error of formErrors(); track error.code) {
832
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
833
+ }
834
+
835
+ <form (submit)="onSubmit($event)">
836
+ @for (f of fields(); track $index) {
837
+ @if (f.kind === 'input') {
838
+ <label class="atlas-field">
839
+ <span>{{ t(f.labelKey) }}</span>
840
+ <input
841
+ [class]="cls('formFieldInput', 'atlas-input')"
842
+ [type]="f.type"
843
+ [value]="values[f.name] ?? ''"
844
+ (input)="setValue(f.name, $any($event.target).value)"
845
+ />
846
+ @for (error of fieldErrorsFor(f.name); track error.code) {
847
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
848
+ }
849
+ </label>
850
+ } @else {
851
+ <p>{{ t(f.labelKey) }}</p>
852
+ }
853
+ }
854
+
855
+ @if (showSubmit()) {
856
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
857
+ {{ t('signIn.submit') }}
858
+ </button>
859
+ }
860
+ </form>
861
+ </div>
862
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
863
+ }
864
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AttemptFlowComponent, decorators: [{
865
+ type: Component,
866
+ args: [{
867
+ selector: 'atlas-attempt-flow',
868
+ standalone: true,
869
+ changeDetection: ChangeDetectionStrategy.OnPush,
870
+ template: `
871
+ <div [class]="cls('card', 'atlas-card')">
872
+ <h1 [class]="cls('headerTitle', 'atlas-title')">{{ t(titleKey) }}</h1>
873
+
874
+ @for (error of formErrors(); track error.code) {
875
+ <p [class]="cls('formFieldError', 'atlas-error')" role="alert">{{ error.message }}</p>
876
+ }
877
+
878
+ <form (submit)="onSubmit($event)">
879
+ @for (f of fields(); track $index) {
880
+ @if (f.kind === 'input') {
881
+ <label class="atlas-field">
882
+ <span>{{ t(f.labelKey) }}</span>
883
+ <input
884
+ [class]="cls('formFieldInput', 'atlas-input')"
885
+ [type]="f.type"
886
+ [value]="values[f.name] ?? ''"
887
+ (input)="setValue(f.name, $any($event.target).value)"
888
+ />
889
+ @for (error of fieldErrorsFor(f.name); track error.code) {
890
+ <span [class]="cls('formFieldError', 'atlas-error')">{{ error.message }}</span>
891
+ }
892
+ </label>
893
+ } @else {
894
+ <p>{{ t(f.labelKey) }}</p>
895
+ }
896
+ }
897
+
898
+ @if (showSubmit()) {
899
+ <button [class]="cls('formButtonPrimary', 'atlas-button')" [disabled]="busy" type="submit">
900
+ {{ t('signIn.submit') }}
901
+ </button>
902
+ }
903
+ </form>
904
+ </div>
905
+ `,
906
+ }]
907
+ }], propDecorators: { attempt: [{
908
+ type: Input
909
+ }], busy: [{
910
+ type: Input
911
+ }], errors: [{
912
+ type: Input
913
+ }], titleKey: [{
914
+ type: Input
915
+ }], submitFlow: [{
916
+ type: Output
917
+ }] } });
918
+ /** A read-only message row: `labelKey` holds the i18n key of the text to show. */
919
+ function msg(labelKey) {
920
+ return { kind: 'message', name: '', labelKey, type: 'text' };
921
+ }
922
+
923
+ /**
924
+ * The server-driven sign-in / sign-up engine, shared by `<atlas-sign-in>` and
925
+ * `<atlas-sign-up>` — the Angular counterpart of React's `useFlow` hook.
926
+ *
927
+ * It owns no decision about what comes next: every status comes back from the
928
+ * server via `advance`, and this controller only posts whatever the current step
929
+ * needs and re-renders the returned status. State lives in a signal so templates
930
+ * update whether the app runs zoned or zoneless.
931
+ */
932
+ class AtlasFlowController {
933
+ client;
934
+ reload;
935
+ state;
936
+ pollTimer = null;
937
+ constructor(client, reload, pending) {
938
+ this.client = client;
939
+ this.reload = reload;
940
+ // Seeded from a redirect that came back mid-flow (e.g. OAuth needing a second
941
+ // factor) so the user resumes at the demanded step instead of starting over.
942
+ this.state = signal(pending ? flowFromPending(pending) : initialFlow);
943
+ }
944
+ /**
945
+ * Advance the flow with the values the user typed. On completion, exchange the
946
+ * one-time ticket for cookies over a same-origin request (FAPI is cross-origin
947
+ * and cannot set the cookie on its own response), then re-boot so the rest of
948
+ * the app sees a signed-in user.
949
+ */
950
+ async submit(values) {
951
+ this.state.update((current) => ({ ...current, busy: true, errors: [] }));
952
+ const next = await advance(this.client, this.state(), values);
953
+ this.state.set(next);
954
+ if (next.attempt?.status === 'complete') {
955
+ if (next.ticket) {
956
+ await this.client.post('/v1/client/tickets/exchange', {
957
+ attempt_id: next.attempt.id,
958
+ ticket: next.ticket,
959
+ });
960
+ }
961
+ await this.reload();
962
+ }
963
+ else {
964
+ // A magic-link step may now be pollable (a link opened on another device).
965
+ this.syncPolling();
966
+ }
967
+ }
968
+ /** Start/stop the §5.3 magic-link poll to match the current state. */
969
+ syncPolling() {
970
+ if (shouldKeepPolling(this.state())) {
971
+ this.startPolling();
972
+ }
973
+ else {
974
+ this.stopPolling();
975
+ }
976
+ }
977
+ startPolling() {
978
+ if (this.pollTimer)
979
+ return;
980
+ this.pollTimer = setInterval(() => {
981
+ void (async () => {
982
+ const { state: next, ticket } = await pollAttempt(this.client, this.state());
983
+ this.state.set(next);
984
+ if (!ticket || !next.attempt)
985
+ return;
986
+ // The ticket is exchanged by a normal request, so no session token ever
987
+ // appears in a URL.
988
+ await this.client.post('/v1/client/tickets/exchange', {
989
+ attempt_id: next.attempt.id,
990
+ ticket,
991
+ });
992
+ await this.reload();
993
+ this.stopPolling();
994
+ })();
995
+ }, 2_000);
996
+ }
997
+ stopPolling() {
998
+ if (this.pollTimer) {
999
+ clearInterval(this.pollTimer);
1000
+ this.pollTimer = null;
1001
+ }
1002
+ }
1003
+ /** Release the poll timer — call from the component's `ngOnDestroy`. */
1004
+ destroy() {
1005
+ this.stopPolling();
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * `<atlas-sign-in>` — the full, multi-step sign-in flow driven entirely by the
1011
+ * server-side attempt status. The Angular peer of React's `<SignIn/>`.
1012
+ *
1013
+ * ```html
1014
+ * <atlas-sign-in *atlasSignedOut afterUrl="/dashboard"></atlas-sign-in>
1015
+ * ```
1016
+ */
1017
+ class SignInComponent {
1018
+ atlas = inject(AtlasService);
1019
+ /** Where to send the user once the flow completes. Reserved for host routing. */
1020
+ afterUrl;
1021
+ flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(),
1022
+ // Resume mid-flow if an OAuth redirect came back owing a second factor.
1023
+ this.atlas.pendingAttempt);
1024
+ ngOnInit() {
1025
+ // If seeded from a pending attempt that is already pollable, start polling.
1026
+ this.flow.syncPolling();
1027
+ }
1028
+ ngOnDestroy() {
1029
+ this.flow.destroy();
1030
+ }
1031
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1032
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SignInComponent, isStandalone: true, selector: "atlas-sign-in", inputs: { afterUrl: "afterUrl" }, ngImport: i0, template: `
1033
+ <atlas-attempt-flow
1034
+ [attempt]="flow.state().attempt"
1035
+ [busy]="flow.state().busy"
1036
+ [errors]="flow.state().errors"
1037
+ titleKey="signIn.title"
1038
+ (submitFlow)="flow.submit($event)"
1039
+ ></atlas-attempt-flow>
1040
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey"], outputs: ["submitFlow"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1041
+ }
1042
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignInComponent, decorators: [{
1043
+ type: Component,
1044
+ args: [{
1045
+ selector: 'atlas-sign-in',
1046
+ standalone: true,
1047
+ changeDetection: ChangeDetectionStrategy.OnPush,
1048
+ imports: [AttemptFlowComponent],
1049
+ template: `
1050
+ <atlas-attempt-flow
1051
+ [attempt]="flow.state().attempt"
1052
+ [busy]="flow.state().busy"
1053
+ [errors]="flow.state().errors"
1054
+ titleKey="signIn.title"
1055
+ (submitFlow)="flow.submit($event)"
1056
+ ></atlas-attempt-flow>
1057
+ `,
1058
+ }]
1059
+ }], propDecorators: { afterUrl: [{
1060
+ type: Input
1061
+ }] } });
1062
+
1063
+ /**
1064
+ * `<atlas-sign-up>` — the full, server-driven sign-up flow. The Angular peer of
1065
+ * React's `<SignUp/>`. It shares the exact flow engine with `<atlas-sign-in>`;
1066
+ * only the title differs, because the sequence of steps is decided by the server.
1067
+ *
1068
+ * ```html
1069
+ * <atlas-sign-up *atlasSignedOut></atlas-sign-up>
1070
+ * ```
1071
+ */
1072
+ class SignUpComponent {
1073
+ atlas = inject(AtlasService);
1074
+ /** Where to send the user once the flow completes. Reserved for host routing. */
1075
+ afterUrl;
1076
+ flow = new AtlasFlowController(this.atlas.createFapiClient(), () => this.atlas.reload(), this.atlas.pendingAttempt);
1077
+ ngOnDestroy() {
1078
+ this.flow.destroy();
1079
+ }
1080
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1081
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: SignUpComponent, isStandalone: true, selector: "atlas-sign-up", inputs: { afterUrl: "afterUrl" }, ngImport: i0, template: `
1082
+ <atlas-attempt-flow
1083
+ [attempt]="flow.state().attempt"
1084
+ [busy]="flow.state().busy"
1085
+ [errors]="flow.state().errors"
1086
+ titleKey="signUp.title"
1087
+ (submitFlow)="flow.submit($event)"
1088
+ ></atlas-attempt-flow>
1089
+ `, isInline: true, dependencies: [{ kind: "component", type: AttemptFlowComponent, selector: "atlas-attempt-flow", inputs: ["attempt", "busy", "errors", "titleKey"], outputs: ["submitFlow"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1090
+ }
1091
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: SignUpComponent, decorators: [{
1092
+ type: Component,
1093
+ args: [{
1094
+ selector: 'atlas-sign-up',
1095
+ standalone: true,
1096
+ changeDetection: ChangeDetectionStrategy.OnPush,
1097
+ imports: [AttemptFlowComponent],
1098
+ template: `
1099
+ <atlas-attempt-flow
1100
+ [attempt]="flow.state().attempt"
1101
+ [busy]="flow.state().busy"
1102
+ [errors]="flow.state().errors"
1103
+ titleKey="signUp.title"
1104
+ (submitFlow)="flow.submit($event)"
1105
+ ></atlas-attempt-flow>
1106
+ `,
1107
+ }]
1108
+ }], propDecorators: { afterUrl: [{
1109
+ type: Input
1110
+ }] } });
1111
+
1112
+ /**
1113
+ * `<atlas-user-button>` — the signed-in user's avatar with a drop-down that can
1114
+ * sign them out. The Angular peer of React's `<UserButton/>`. Renders nothing
1115
+ * when there is no user.
1116
+ */
1117
+ class UserButtonComponent {
1118
+ atlas = inject(AtlasService);
1119
+ open = signal(false);
1120
+ label = computed(() => {
1121
+ const user = this.atlas.user();
1122
+ if (!user)
1123
+ return '';
1124
+ return [user.first_name, user.last_name].filter(Boolean).join(' ') || user.username || '';
1125
+ });
1126
+ t(key) {
1127
+ return translate(this.atlas.localization, key);
1128
+ }
1129
+ cls(key, base) {
1130
+ return classFor(key, this.atlas.appearance, base);
1131
+ }
1132
+ toggle() {
1133
+ this.open.update((o) => !o);
1134
+ }
1135
+ signOut() {
1136
+ void this.atlas.signOut();
1137
+ }
1138
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UserButtonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1139
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: UserButtonComponent, isStandalone: true, selector: "atlas-user-button", ngImport: i0, template: `
1140
+ @if (atlas.user(); as user) {
1141
+ <div class="atlas-user-button">
1142
+ <button
1143
+ [class]="cls('avatar', 'atlas-avatar')"
1144
+ [attr.aria-expanded]="open()"
1145
+ (click)="toggle()"
1146
+ >
1147
+ @if (user.image_url) {
1148
+ <img [src]="user.image_url" alt="" />
1149
+ } @else {
1150
+ <span>{{ label().slice(0, 1) }}</span>
1151
+ }
1152
+ </button>
1153
+
1154
+ @if (open()) {
1155
+ <div [class]="cls('menu', 'atlas-menu')" role="menu">
1156
+ <span [class]="cls('menuItem', 'atlas-menu-item')">{{ label() }}</span>
1157
+ <button [class]="cls('menuItem', 'atlas-menu-item')" (click)="signOut()">
1158
+ {{ t('userButton.signOut') }}
1159
+ </button>
1160
+ </div>
1161
+ }
1162
+ </div>
1163
+ }
1164
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1165
+ }
1166
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UserButtonComponent, decorators: [{
1167
+ type: Component,
1168
+ args: [{
1169
+ selector: 'atlas-user-button',
1170
+ standalone: true,
1171
+ changeDetection: ChangeDetectionStrategy.OnPush,
1172
+ template: `
1173
+ @if (atlas.user(); as user) {
1174
+ <div class="atlas-user-button">
1175
+ <button
1176
+ [class]="cls('avatar', 'atlas-avatar')"
1177
+ [attr.aria-expanded]="open()"
1178
+ (click)="toggle()"
1179
+ >
1180
+ @if (user.image_url) {
1181
+ <img [src]="user.image_url" alt="" />
1182
+ } @else {
1183
+ <span>{{ label().slice(0, 1) }}</span>
1184
+ }
1185
+ </button>
1186
+
1187
+ @if (open()) {
1188
+ <div [class]="cls('menu', 'atlas-menu')" role="menu">
1189
+ <span [class]="cls('menuItem', 'atlas-menu-item')">{{ label() }}</span>
1190
+ <button [class]="cls('menuItem', 'atlas-menu-item')" (click)="signOut()">
1191
+ {{ t('userButton.signOut') }}
1192
+ </button>
1193
+ </div>
1194
+ }
1195
+ </div>
1196
+ }
1197
+ `,
1198
+ }]
1199
+ }] });
1200
+
1201
+ /**
1202
+ * `<atlas-organization-switcher>` — switch the active organization (or the
1203
+ * personal account). The Angular peer of React's `<OrganizationSwitcher/>`.
1204
+ */
1205
+ class OrganizationSwitcherComponent {
1206
+ atlas = inject(AtlasService);
1207
+ t(key) {
1208
+ return translate(this.atlas.localization, key);
1209
+ }
1210
+ cls(key, base) {
1211
+ return classFor(key, this.atlas.appearance, base);
1212
+ }
1213
+ setActive(organizationId) {
1214
+ void this.atlas.setActiveOrganization(organizationId);
1215
+ }
1216
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationSwitcherComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1217
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: OrganizationSwitcherComponent, isStandalone: true, selector: "atlas-organization-switcher", ngImport: i0, template: `
1218
+ <div [class]="cls('menu', 'atlas-menu')">
1219
+ <button [class]="cls('menuItem', 'atlas-menu-item')" (click)="setActive(null)">
1220
+ {{ t('organization.personal') }}
1221
+ </button>
1222
+ @for (membership of atlas.memberships(); track membership.organization.id) {
1223
+ <button
1224
+ [class]="cls('menuItem', 'atlas-menu-item')"
1225
+ [disabled]="membership.organization.id === atlas.organization()?.organization?.id"
1226
+ (click)="setActive(membership.organization.id)"
1227
+ >
1228
+ {{ membership.organization.name }}
1229
+ </button>
1230
+ }
1231
+ </div>
1232
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1233
+ }
1234
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationSwitcherComponent, decorators: [{
1235
+ type: Component,
1236
+ args: [{
1237
+ selector: 'atlas-organization-switcher',
1238
+ standalone: true,
1239
+ changeDetection: ChangeDetectionStrategy.OnPush,
1240
+ template: `
1241
+ <div [class]="cls('menu', 'atlas-menu')">
1242
+ <button [class]="cls('menuItem', 'atlas-menu-item')" (click)="setActive(null)">
1243
+ {{ t('organization.personal') }}
1244
+ </button>
1245
+ @for (membership of atlas.memberships(); track membership.organization.id) {
1246
+ <button
1247
+ [class]="cls('menuItem', 'atlas-menu-item')"
1248
+ [disabled]="membership.organization.id === atlas.organization()?.organization?.id"
1249
+ (click)="setActive(membership.organization.id)"
1250
+ >
1251
+ {{ membership.organization.name }}
1252
+ </button>
1253
+ }
1254
+ </div>
1255
+ `,
1256
+ }]
1257
+ }] });
1258
+
1259
+ /**
1260
+ * `<atlas-user-profile>` — the signed-in user's account surface (emails,
1261
+ * security). The Angular peer of React's `<UserProfile/>`. Renders nothing when
1262
+ * there is no user.
1263
+ */
1264
+ class UserProfileComponent {
1265
+ atlas = inject(AtlasService);
1266
+ t(key) {
1267
+ return translate(this.atlas.localization, key);
1268
+ }
1269
+ cls(key, base) {
1270
+ return classFor(key, this.atlas.appearance, base);
1271
+ }
1272
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UserProfileComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1273
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: UserProfileComponent, isStandalone: true, selector: "atlas-user-profile", ngImport: i0, template: `
1274
+ @if (atlas.user(); as user) {
1275
+ <div [class]="cls('card', 'atlas-card')">
1276
+ <h1>{{ t('userProfile.title') }}</h1>
1277
+
1278
+ <section>
1279
+ <h2>{{ t('userProfile.emailsSection') }}</h2>
1280
+ <ul>
1281
+ @for (email of user.email_addresses ?? []; track email.id) {
1282
+ <li>
1283
+ {{ email.email_address }}
1284
+ @if (!email.verified) {
1285
+ <em> — {{ t('userProfile.unverified') }}</em>
1286
+ }
1287
+ </li>
1288
+ }
1289
+ </ul>
1290
+ </section>
1291
+
1292
+ <section>
1293
+ <h2>{{ t('userProfile.securitySection') }}</h2>
1294
+ <p>{{ user.mfa_enabled ? '2FA on' : '2FA off' }}</p>
1295
+ </section>
1296
+ </div>
1297
+ }
1298
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1299
+ }
1300
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UserProfileComponent, decorators: [{
1301
+ type: Component,
1302
+ args: [{
1303
+ selector: 'atlas-user-profile',
1304
+ standalone: true,
1305
+ changeDetection: ChangeDetectionStrategy.OnPush,
1306
+ template: `
1307
+ @if (atlas.user(); as user) {
1308
+ <div [class]="cls('card', 'atlas-card')">
1309
+ <h1>{{ t('userProfile.title') }}</h1>
1310
+
1311
+ <section>
1312
+ <h2>{{ t('userProfile.emailsSection') }}</h2>
1313
+ <ul>
1314
+ @for (email of user.email_addresses ?? []; track email.id) {
1315
+ <li>
1316
+ {{ email.email_address }}
1317
+ @if (!email.verified) {
1318
+ <em> — {{ t('userProfile.unverified') }}</em>
1319
+ }
1320
+ </li>
1321
+ }
1322
+ </ul>
1323
+ </section>
1324
+
1325
+ <section>
1326
+ <h2>{{ t('userProfile.securitySection') }}</h2>
1327
+ <p>{{ user.mfa_enabled ? '2FA on' : '2FA off' }}</p>
1328
+ </section>
1329
+ </div>
1330
+ }
1331
+ `,
1332
+ }]
1333
+ }] });
1334
+
1335
+ /**
1336
+ * `<atlas-organization-profile>` — the active organization's surface. The
1337
+ * Angular peer of React's `<OrganizationProfile/>`. Management controls are
1338
+ * gated on the ACTIVE org role via `*atlasProtect`, matching the server's rule
1339
+ * rather than duplicating a looser one. Renders nothing with no active org.
1340
+ */
1341
+ class OrganizationProfileComponent {
1342
+ atlas = inject(AtlasService);
1343
+ t(key) {
1344
+ return translate(this.atlas.localization, key);
1345
+ }
1346
+ cls(key, base) {
1347
+ return classFor(key, this.atlas.appearance, base);
1348
+ }
1349
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationProfileComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1350
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: OrganizationProfileComponent, isStandalone: true, selector: "atlas-organization-profile", ngImport: i0, template: `
1351
+ @if (atlas.organization(); as membership) {
1352
+ <div [class]="cls('card', 'atlas-card')">
1353
+ <h1>{{ membership.organization.name }}</h1>
1354
+ <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
1355
+
1356
+ <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">
1357
+ {{ t('organization.invite') }}
1358
+ </button>
1359
+ </div>
1360
+ }
1361
+ `, isInline: true, dependencies: [{ kind: "directive", type: AtlasProtectDirective, selector: "[atlasProtect]", inputs: ["atlasProtect", "atlasProtectElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1362
+ }
1363
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: OrganizationProfileComponent, decorators: [{
1364
+ type: Component,
1365
+ args: [{
1366
+ selector: 'atlas-organization-profile',
1367
+ standalone: true,
1368
+ changeDetection: ChangeDetectionStrategy.OnPush,
1369
+ imports: [AtlasProtectDirective],
1370
+ template: `
1371
+ @if (atlas.organization(); as membership) {
1372
+ <div [class]="cls('card', 'atlas-card')">
1373
+ <h1>{{ membership.organization.name }}</h1>
1374
+ <p>{{ t('organization.roleLabel') }}: {{ membership.role }}</p>
1375
+
1376
+ <button *atlasProtect="{ permission: 'org:sys_memberships:manage' }">
1377
+ {{ t('organization.invite') }}
1378
+ </button>
1379
+ </div>
1380
+ }
1381
+ `,
1382
+ }]
1383
+ }] });
1384
+
1385
+ /**
1386
+ * Every standalone directive and component the SDK ships. Import the array in a
1387
+ * standalone component's `imports`, or rely on `AtlasModule` re-exporting it for
1388
+ * NgModule-based apps:
1389
+ *
1390
+ * ```ts
1391
+ * @Component({ standalone: true, imports: [...ATLAS_UI], template: '…' })
1392
+ * ```
1393
+ */
1394
+ const ATLAS_UI = [
1395
+ AtlasSignedInDirective,
1396
+ AtlasSignedOutDirective,
1397
+ AtlasLoadingDirective,
1398
+ AtlasLoadedDirective,
1399
+ AtlasProtectDirective,
1400
+ AtlasRootDirective,
1401
+ SignInComponent,
1402
+ SignUpComponent,
1403
+ UserButtonComponent,
1404
+ OrganizationSwitcherComponent,
1405
+ UserProfileComponent,
1406
+ OrganizationProfileComponent,
1407
+ ];
1408
+
1409
+ /**
1410
+ * Standalone-provider entry point (Angular 17+). Register Atlas once at the
1411
+ * application root — the direct analogue of wrapping the tree in React's
1412
+ * `<AtlasProvider publishableKey>`:
1413
+ *
1414
+ * ```ts
1415
+ * bootstrapApplication(AppComponent, {
1416
+ * providers: [
1417
+ * provideAtlas({ publishableKey: 'pk_test_…' }),
1418
+ * provideRouter(routes),
1419
+ * ],
1420
+ * });
1421
+ * ```
1422
+ *
1423
+ * The returned providers make {@link AtlasService} injectable and eagerly
1424
+ * construct it, so the SDK begins its first boot (redirect-completion + session
1425
+ * read) as soon as the app starts — matching the React provider mounting on the
1426
+ * first render.
1427
+ */
1428
+ function provideAtlas(options) {
1429
+ return makeEnvironmentProviders([
1430
+ { provide: ATLAS_OPTIONS, useValue: options },
1431
+ AtlasService,
1432
+ {
1433
+ // Eagerly instantiate AtlasService so its boot runs at startup rather than
1434
+ // lazily on first injection — the equivalent of the provider mounting.
1435
+ provide: ENVIRONMENT_INITIALIZER,
1436
+ multi: true,
1437
+ useValue: () => {
1438
+ inject(AtlasService);
1439
+ },
1440
+ },
1441
+ ]);
1442
+ }
1443
+ /**
1444
+ * NgModule entry point for apps not yet on the standalone bootstrap. Import
1445
+ * `AtlasModule.forRoot({ publishableKey })` once in the root module; feature
1446
+ * modules import `AtlasModule` (no args) to use the directives/components.
1447
+ */
1448
+ class AtlasModule {
1449
+ static forRoot(options) {
1450
+ const providers = [
1451
+ { provide: ATLAS_OPTIONS, useValue: options },
1452
+ AtlasService,
1453
+ {
1454
+ provide: ENVIRONMENT_INITIALIZER,
1455
+ multi: true,
1456
+ useValue: () => {
1457
+ inject(AtlasService);
1458
+ },
1459
+ },
1460
+ ];
1461
+ return { ngModule: AtlasModule, providers };
1462
+ }
1463
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
1464
+ static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, imports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent], exports: [AtlasSignedInDirective, AtlasSignedOutDirective, AtlasLoadingDirective, AtlasLoadedDirective, AtlasProtectDirective, AtlasRootDirective, SignInComponent, SignUpComponent, UserButtonComponent, OrganizationSwitcherComponent, UserProfileComponent, OrganizationProfileComponent] });
1465
+ static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule });
1466
+ }
1467
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AtlasModule, decorators: [{
1468
+ type: NgModule,
1469
+ args: [{
1470
+ // Standalone directives/components are re-exported so NgModule consumers get
1471
+ // them just by importing AtlasModule, the way they would with a classic module.
1472
+ imports: [...ATLAS_UI],
1473
+ exports: [...ATLAS_UI],
1474
+ }]
1475
+ }] });
1476
+
1477
+ /**
1478
+ * Route guard factory that gates a route on session — and optionally on a
1479
+ * permission/role. The Angular peer of using `<SignedIn>` / `<Protect>` to fence
1480
+ * a page, expressed as a `CanActivateFn`.
1481
+ *
1482
+ * ```ts
1483
+ * export const routes: Routes = [
1484
+ * { path: 'dashboard', canActivate: [atlasGuard({ redirectTo: '/sign-in' })], ... },
1485
+ * {
1486
+ * path: 'admin',
1487
+ * canActivate: [atlasGuard({ permission: 'org:admin', redirectTo: '/' })],
1488
+ * ...
1489
+ * },
1490
+ * ];
1491
+ * ```
1492
+ *
1493
+ * The guard first waits for the SDK to finish booting, so a hard refresh onto a
1494
+ * protected URL does not bounce to sign-in before the session has been read.
1495
+ * Like the components, this is UX, not security: the server enforces the same
1496
+ * checks on every request.
1497
+ */
1498
+ function atlasGuard(options = {}) {
1499
+ const { redirectTo, preserveReturnUrl = true, ...condition } = options;
1500
+ return async (_route, state) => {
1501
+ const atlas = inject(AtlasService);
1502
+ const router = inject(Router);
1503
+ // Wait for boot to settle so we judge against a real answer, not `loading`.
1504
+ await firstValueFrom(atlas.status$.pipe(filter((s) => s !== 'loading'), take(1)));
1505
+ const allowed = evaluate(atlas.claims(), condition).allowed;
1506
+ if (allowed)
1507
+ return true;
1508
+ if (redirectTo) {
1509
+ return router.createUrlTree([redirectTo], {
1510
+ queryParams: preserveReturnUrl ? { redirect_url: state.url } : undefined,
1511
+ });
1512
+ }
1513
+ return false;
1514
+ };
1515
+ }
1516
+ /**
1517
+ * The `CanActivateChild` form of {@link atlasGuard}, for gating a whole child
1518
+ * route subtree with one declaration.
1519
+ */
1520
+ function atlasChildGuard(options = {}) {
1521
+ const guard = atlasGuard(options);
1522
+ return (route, state) => guard(route, state);
1523
+ }
1524
+
1525
+ /**
1526
+ * Conditional-rendering authorization primitive — re-exported unchanged from
1527
+ * `@atlasauth/authz`, the ONE evaluator every Atlas SDK shares. `*atlasProtect` in
1528
+ * this package and `<Protect>` in `@atlasauth/react` therefore judge a condition
1529
+ * identically, and the server checking the same permission is what actually
1530
+ * stops a request; these helpers only decide what a user is shown.
1531
+ */
1532
+
1533
+ /**
1534
+ * Public data shapes, mirrored 1:1 from `@atlasauth/react`'s `types.ts` so an app
1535
+ * can move between the React and Angular SDKs without re-typing its models.
1536
+ */
1537
+
1538
+ /**
1539
+ * The FAPI client and the server-driven sign-in flow driver live in `@atlasauth/js`
1540
+ * — the pure, framework-agnostic browser flow. This thin re-export lets the
1541
+ * Angular components, guards and services resolve the driver from one place, so
1542
+ * the Angular `<atlas-sign-in>` and the React `<SignIn/>` drive the exact same
1543
+ * endpoints and cannot diverge in what the server sees.
1544
+ */
1545
+
1546
+ /**
1547
+ * `@atlasauth/angular` — the official Angular SDK for Atlas, the Angular peer of
1548
+ * `@atlasauth/react`. Public surface.
1549
+ */
1550
+ // Configuration + DI
1551
+
1552
+ /**
1553
+ * Generated bundle index. Do not edit.
1554
+ */
1555
+
1556
+ export { ATLAS_OPTIONS, ATLAS_UI, AtlasFlowController, AtlasLoadedDirective, AtlasLoadingDirective, AtlasModule, AtlasProtectDirective, AtlasRootDirective, AtlasService, AtlasSignedInDirective, AtlasSignedOutDirective, AttemptFlowComponent, DEFAULT_TOKENS, EN_US, OrganizationProfileComponent, OrganizationSwitcherComponent, SignInComponent, SignUpComponent, THEMES, UserButtonComponent, UserProfileComponent, atlasChildGuard, atlasGuard, classFor, cssVariables, provideAtlas, resolveTokens, translate, withFallback };
1557
+ //# sourceMappingURL=atlasauth-angular.mjs.map