@block-auth.io/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 (148) hide show
  1. package/.turbo/turbo-build.log +37 -0
  2. package/.turbo/turbo-lint.log +4 -0
  3. package/.turbo/turbo-test.log +82 -0
  4. package/.turbo/turbo-typecheck.log +4 -0
  5. package/CHANGELOG.md +10 -0
  6. package/ng-package.json +6 -0
  7. package/package.json +50 -0
  8. package/scripts/css-to-ts.mjs +14 -0
  9. package/src/lib/__tests__/integration.test.ts +45 -0
  10. package/src/lib/block-auth.service.test.ts +69 -0
  11. package/src/lib/block-auth.service.ts +81 -0
  12. package/src/lib/components/btns/btn-base-provider.component.test.ts +22 -0
  13. package/src/lib/components/btns/btn-base-provider.component.ts +55 -0
  14. package/src/lib/components/btns/btn-block-auth.component.test.ts +79 -0
  15. package/src/lib/components/btns/btn-block-auth.component.ts +104 -0
  16. package/src/lib/components/btns/btn-gmail.component.test.ts +16 -0
  17. package/src/lib/components/btns/btn-gmail.component.ts +41 -0
  18. package/src/lib/components/btns/btn-google-base.component.test.ts +16 -0
  19. package/src/lib/components/btns/btn-google-base.component.ts +73 -0
  20. package/src/lib/components/btns/btn-metamask.component.test.ts +20 -0
  21. package/src/lib/components/btns/btn-metamask.component.ts +98 -0
  22. package/src/lib/components/btns/btn-microsoft.component.test.ts +16 -0
  23. package/src/lib/components/btns/btn-microsoft.component.ts +77 -0
  24. package/src/lib/components/btns/btn-phantom.component.test.ts +20 -0
  25. package/src/lib/components/btns/btn-phantom.component.ts +93 -0
  26. package/src/lib/components/btns/btn-signin.component.test.ts +55 -0
  27. package/src/lib/components/btns/btn-signin.component.ts +108 -0
  28. package/src/lib/components/btns/btn-wallet-connect.component.test.ts +20 -0
  29. package/src/lib/components/btns/btn-wallet-connect.component.ts +109 -0
  30. package/src/lib/components/btns/index.ts +11 -0
  31. package/src/lib/components/contents/content-signin.component.test.ts +33 -0
  32. package/src/lib/components/contents/content-signin.component.ts +68 -0
  33. package/src/lib/components/contents/content-signup.component.test.ts +78 -0
  34. package/src/lib/components/contents/content-signup.component.ts +225 -0
  35. package/src/lib/components/dds/change-account.component.test.ts +66 -0
  36. package/src/lib/components/dds/change-account.component.ts +112 -0
  37. package/src/lib/components/dds/dropdown-profile-btn.component.test.ts +66 -0
  38. package/src/lib/components/dds/dropdown-profile-btn.component.ts +185 -0
  39. package/src/lib/components/dds/dropdown-profile-item.component.test.ts +75 -0
  40. package/src/lib/components/dds/dropdown-profile-item.component.ts +74 -0
  41. package/src/lib/components/dds/dropdown-profile.component.test.ts +98 -0
  42. package/src/lib/components/dds/dropdown-profile.component.ts +258 -0
  43. package/src/lib/components/flow-block-auth.component.test.ts +93 -0
  44. package/src/lib/components/flow-block-auth.component.ts +226 -0
  45. package/src/lib/components/modals/modal-overlay.component.test.ts +81 -0
  46. package/src/lib/components/modals/modal-overlay.component.ts +43 -0
  47. package/src/lib/components/modals/modal-restoration-expired.component.test.ts +71 -0
  48. package/src/lib/components/modals/modal-restoration-expired.component.ts +83 -0
  49. package/src/lib/components/modals/modal-restore.component.test.ts +74 -0
  50. package/src/lib/components/modals/modal-restore.component.ts +104 -0
  51. package/src/lib/components/modals/modal-signin.component.test.ts +66 -0
  52. package/src/lib/components/modals/modal-signin.component.ts +44 -0
  53. package/src/lib/components/modals/modal-signup.component.test.ts +56 -0
  54. package/src/lib/components/modals/modal-signup.component.ts +40 -0
  55. package/src/lib/components/sections/footer-section.component.test.ts +19 -0
  56. package/src/lib/components/sections/footer-section.component.ts +49 -0
  57. package/src/lib/components/sections/internals/providers-buttons.component.test.ts +27 -0
  58. package/src/lib/components/sections/internals/providers-buttons.component.ts +290 -0
  59. package/src/lib/components/sections/internals/providers-filters.component.test.ts +25 -0
  60. package/src/lib/components/sections/internals/providers-filters.component.ts +139 -0
  61. package/src/lib/components/sections/internals/selected-button.component.test.ts +36 -0
  62. package/src/lib/components/sections/internals/selected-button.component.ts +92 -0
  63. package/src/lib/components/sections/login-section.component.test.ts +49 -0
  64. package/src/lib/components/sections/login-section.component.ts +120 -0
  65. package/src/lib/components/sections/providers-section.component.test.ts +22 -0
  66. package/src/lib/components/sections/providers-section.component.ts +187 -0
  67. package/src/lib/components/txns/internals/permissions-required-modal.component.test.ts +14 -0
  68. package/src/lib/components/txns/internals/permissions-required-modal.component.ts +35 -0
  69. package/src/lib/components/txns/internals/solicited-data-modal.component.test.ts +72 -0
  70. package/src/lib/components/txns/internals/solicited-data-modal.component.ts +187 -0
  71. package/src/lib/components/txns/internals/txn-error.component.test.ts +100 -0
  72. package/src/lib/components/txns/internals/txn-error.component.ts +121 -0
  73. package/src/lib/components/txns/internals/txn-type.component.test.ts +52 -0
  74. package/src/lib/components/txns/internals/txn-type.component.ts +51 -0
  75. package/src/lib/components/txns/internals/txn.component.test.ts +188 -0
  76. package/src/lib/components/txns/internals/txn.component.ts +298 -0
  77. package/src/lib/components/txns/txns-overview.component.test.ts +107 -0
  78. package/src/lib/components/txns/txns-overview.component.ts +229 -0
  79. package/src/lib/icons/ai-outline-disconnect.component.ts +21 -0
  80. package/src/lib/icons/bi-logo-gmail.component.ts +21 -0
  81. package/src/lib/icons/cg-profile.component.ts +31 -0
  82. package/src/lib/icons/check-icon.component.ts +27 -0
  83. package/src/lib/icons/ci-clock2.component.ts +31 -0
  84. package/src/lib/icons/fa-arrow-right.component.ts +21 -0
  85. package/src/lib/icons/fa-times.component.ts +21 -0
  86. package/src/lib/icons/fa-undo.component.ts +21 -0
  87. package/src/lib/icons/icons.test.ts +25 -0
  88. package/src/lib/icons/index.ts +22 -0
  89. package/src/lib/icons/io-ios-arrow-back.component.ts +21 -0
  90. package/src/lib/icons/io-ios-arrow-forward.component.ts +21 -0
  91. package/src/lib/icons/io-md-arrow-back.component.ts +19 -0
  92. package/src/lib/icons/io-md-arrow-forward.component.ts +19 -0
  93. package/src/lib/icons/pi-warning-fill.component.ts +21 -0
  94. package/src/lib/icons/tb-arrows-exchange2.component.ts +30 -0
  95. package/src/lib/icons/tb-copy-check-filled.component.ts +21 -0
  96. package/src/lib/icons/tb-copy.component.ts +34 -0
  97. package/src/lib/icons/ti-vendor-microsoft.component.ts +21 -0
  98. package/src/lib/inject-store.test.ts +26 -0
  99. package/src/lib/inject-store.ts +16 -0
  100. package/src/lib/primitives/arrow-icon.component.test.ts +12 -0
  101. package/src/lib/primitives/arrow-icon.component.ts +38 -0
  102. package/src/lib/primitives/btn-arrow-dir.component.test.ts +22 -0
  103. package/src/lib/primitives/btn-arrow-dir.component.ts +65 -0
  104. package/src/lib/primitives/btn-base.component.test.ts +38 -0
  105. package/src/lib/primitives/btn-base.component.ts +140 -0
  106. package/src/lib/primitives/divider-base.component.test.ts +12 -0
  107. package/src/lib/primitives/divider-base.component.ts +74 -0
  108. package/src/lib/primitives/dropdown-arrow.component.test.ts +12 -0
  109. package/src/lib/primitives/dropdown-arrow.component.ts +46 -0
  110. package/src/lib/primitives/dropdown-base.component.test.ts +49 -0
  111. package/src/lib/primitives/dropdown-base.component.ts +329 -0
  112. package/src/lib/primitives/helpers.ts +151 -0
  113. package/src/lib/primitives/horizontal-loader.component.test.ts +19 -0
  114. package/src/lib/primitives/horizontal-loader.component.ts +70 -0
  115. package/src/lib/primitives/icon-base.component.test.ts +19 -0
  116. package/src/lib/primitives/icon-base.component.ts +58 -0
  117. package/src/lib/primitives/img-loader.component.test.ts +12 -0
  118. package/src/lib/primitives/img-loader.component.ts +118 -0
  119. package/src/lib/primitives/index.ts +29 -0
  120. package/src/lib/primitives/input-base.component.test.ts +20 -0
  121. package/src/lib/primitives/input-base.component.ts +210 -0
  122. package/src/lib/primitives/lifetime-slider-base.component.test.ts +88 -0
  123. package/src/lib/primitives/lifetime-slider-base.component.ts +75 -0
  124. package/src/lib/primitives/oval-spinner.component.test.ts +9 -0
  125. package/src/lib/primitives/oval-spinner.component.ts +158 -0
  126. package/src/lib/primitives/pickers/dropdown-picker-animated-base.component.ts +220 -0
  127. package/src/lib/primitives/pickers/picker-button.component.ts +74 -0
  128. package/src/lib/primitives/pickers/time-picker-dropdown-selector.component.ts +188 -0
  129. package/src/lib/primitives/pickers/time-picker.component.ts +124 -0
  130. package/src/lib/primitives/pickers/time-unit-selector.component.ts +82 -0
  131. package/src/lib/primitives/progressbar.component.test.ts +18 -0
  132. package/src/lib/primitives/progressbar.component.ts +93 -0
  133. package/src/lib/primitives/providersStyles.ts +268 -0
  134. package/src/lib/primitives/slider-base.component.ts +56 -0
  135. package/src/lib/provide-block-auth.test.ts +43 -0
  136. package/src/lib/provide-block-auth.ts +43 -0
  137. package/src/lib/runtime/block-auth-runtime.directive.test.ts +89 -0
  138. package/src/lib/runtime/block-auth-runtime.directive.ts +68 -0
  139. package/src/lib/teleport-body.directive.test.ts +29 -0
  140. package/src/lib/teleport-body.directive.ts +21 -0
  141. package/src/lib/testing.ts +15 -0
  142. package/src/lib/transitions.css +23 -0
  143. package/src/public-api.ts +85 -0
  144. package/src/vitest.setup.ts +8 -0
  145. package/tailwind.config.cjs +6 -0
  146. package/tsconfig.json +15 -0
  147. package/tsconfig.lib.json +9 -0
  148. package/vite.config.mts +44 -0
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Component } from '@angular/core';
3
+ import { render, screen, fireEvent, waitFor } from '@testing-library/angular';
4
+ import { map } from 'nanostores';
5
+ import { DropdownProfileComponent } from './dropdown-profile.component';
6
+ import { provideBlockAuthClientForTest } from '../../testing';
7
+
8
+ vi.mock('@wagmi/core', () => ({
9
+ createConfig: vi.fn(() => ({ connectors: [] })),
10
+ connect: vi.fn(), disconnect: vi.fn(), getAccount: vi.fn(() => ({})),
11
+ signMessage: vi.fn(), watchAccount: vi.fn(() => () => undefined), http: vi.fn(),
12
+ }));
13
+ vi.mock('@wagmi/connectors', () => ({
14
+ injected: vi.fn(() => ({ id: 'injected' })), walletConnect: vi.fn(() => ({ id: 'walletConnect' })),
15
+ }));
16
+
17
+ // Port de packages/react/src/components/dds/DropdownProfile.test.tsx (mapa:
18
+ // packages/vue/.../DropdownProfile.test.ts). Client falso con `logout` spy y
19
+ // stores nanostores; serviceName 'blockauth' para textos en inglés.
20
+ describe('DropdownProfile', () => {
21
+ function makeClient(logout = vi.fn(async () => {})) {
22
+ return {
23
+ logout,
24
+ web3: { signup: vi.fn() },
25
+ oauth: { signupGoogle: vi.fn(), signupMicrosoft: vi.fn() },
26
+ connectBlockAuth: vi.fn(),
27
+ state: { $ui: map({ serviceName: 'blockauth' }), $ws: map({}) },
28
+ } as any;
29
+ }
30
+
31
+ it('"Disconnect" llama a client.logout() ANTES del prop onLogout (orden critico)', async () => {
32
+ const callOrder: string[] = [];
33
+ const client = makeClient(vi.fn(async () => {
34
+ callOrder.push('logout');
35
+ }));
36
+ const onLogout = vi.fn(async () => {
37
+ callOrder.push('onLogout');
38
+ });
39
+ @Component({
40
+ standalone: true,
41
+ imports: [DropdownProfileComponent],
42
+ template: `<ba-dropdown-profile [onLogout]="ol" />`,
43
+ })
44
+ class Host {
45
+ ol = onLogout;
46
+ }
47
+ await render(Host, { providers: [provideBlockAuthClientForTest(client)] });
48
+
49
+ await fireEvent.click(screen.getByTestId('dropdown-profile-btn'));
50
+ await fireEvent.click(screen.getByText('Disconnect'));
51
+
52
+ await waitFor(() => expect(onLogout).toHaveBeenCalled());
53
+ expect(client.logout).toHaveBeenCalled();
54
+ expect(callOrder).toEqual(['logout', 'onLogout']);
55
+ });
56
+
57
+ it('abre y cierra el panel al pulsar el boton de perfil', async () => {
58
+ await render(DropdownProfileComponent, {
59
+ providers: [provideBlockAuthClientForTest(makeClient())],
60
+ });
61
+ // Panel cerrado inicialmente: "Disconnect" no está en el DOM.
62
+ expect(screen.queryByText('Disconnect')).toBeNull();
63
+ await fireEvent.click(screen.getByTestId('dropdown-profile-btn'));
64
+ expect(screen.getByText('Disconnect')).toBeTruthy();
65
+ });
66
+
67
+ it('click-outside cierra el panel; click en el propio boton de perfil no cuenta como fuera', async () => {
68
+ await render(DropdownProfileComponent, {
69
+ providers: [provideBlockAuthClientForTest(makeClient())],
70
+ });
71
+ await fireEvent.click(screen.getByTestId('dropdown-profile-btn'));
72
+ expect(screen.getByText('Disconnect')).toBeTruthy();
73
+
74
+ const outside = document.createElement('div');
75
+ document.body.appendChild(outside);
76
+ await fireEvent.click(outside);
77
+ await waitFor(() => expect(screen.queryByText('Disconnect')).toBeNull());
78
+ document.body.removeChild(outside);
79
+ });
80
+
81
+ it('renderiza el item por defecto "My profile" cuando no se pasan additionalDropdownItems', async () => {
82
+ await render(DropdownProfileComponent, {
83
+ providers: [provideBlockAuthClientForTest(makeClient())],
84
+ });
85
+ await fireEvent.click(screen.getByTestId('dropdown-profile-btn'));
86
+ expect(screen.getByText('My profile')).toBeTruthy();
87
+ });
88
+
89
+ // Regresión: el contenedor raíz debe encogerse a su contenido (bas-w-fit) para
90
+ // que el panel (bas-w-full) tome el ancho del botón y no se estire al ancho del
91
+ // contenedor de la app. Igual que React/Vue.
92
+ it('el contenedor raíz usa bas-w-fit (panel compacto, no a ancho completo)', async () => {
93
+ await render(DropdownProfileComponent, {
94
+ providers: [provideBlockAuthClientForTest(makeClient())],
95
+ });
96
+ expect(screen.getByTestId('dropdown-profile').className).toContain('bas-w-fit');
97
+ });
98
+ });
@@ -0,0 +1,258 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ DestroyRef,
5
+ ElementRef,
6
+ HostListener,
7
+ Type,
8
+ computed,
9
+ inject,
10
+ input,
11
+ signal,
12
+ viewChild,
13
+ } from '@angular/core';
14
+ import { $locale } from '@block-auth.io/core';
15
+ import { injectBlockAuthClient } from '../../provide-block-auth';
16
+ import { injectStore } from '../../inject-store';
17
+ import { CgProfileComponent, AiOutlineDisconnectComponent } from '../../icons';
18
+ import { ChangeAccountComponent } from './change-account.component';
19
+ import { DropdownProfileItemComponent } from './dropdown-profile-item.component';
20
+ import { DropdownProfileBtnComponent } from './dropdown-profile-btn.component';
21
+
22
+ // Decisiones de diseño:
23
+ // - `onLogout`/`onNavigate` son INPUTS DE FUNCIÓN (no outputs): `onLogout` se
24
+ // hace `await` DESPUÉS de `client.logout()` (orden crítico, un output no se
25
+ // puede "esperar"); `onNavigate` es un hook de host con rama alternativa
26
+ // (`window.location.href`) cuando es null. El resto de callbacks son outputs
27
+ // (`clicked`/`changeAccountClicked`).
28
+ // - Refs `profileBtnEl`/`dropdownPanelEl` (viewChild) sobre `<div>`s propios de
29
+ // este componente para el click-outside.
30
+ // - La animación de apertura es `@if` + clase `ba-pop`. Al abrir, un
31
+ // `setTimeout(200)` marca `isAnimationCompleted` (habilita `overflow-y-auto`).
32
+ // - `additionalDropdownItems[].firstIcon` es un tipo de componente
33
+ // (`Type<unknown>`) renderizado por `dropdown-profile-item` vía NgComponentOutlet.
34
+ // - "Disconnect": `client.logout()` PRIMERO y `onLogout()` DESPUÉS (verificado
35
+ // en el test).
36
+ export interface DropdownProfileAdditionalItem {
37
+ onClick: () => void;
38
+ /** String plano o mapa de locale (`{ es, en }`). */
39
+ text: string | Record<string, string>;
40
+ firstIcon?: Type<unknown> | null;
41
+ disabled?: boolean;
42
+ }
43
+
44
+ @Component({
45
+ selector: 'ba-dropdown-profile',
46
+ standalone: true,
47
+ changeDetection: ChangeDetectionStrategy.OnPush,
48
+ imports: [DropdownProfileBtnComponent, DropdownProfileItemComponent, ChangeAccountComponent],
49
+ template: `
50
+ <div
51
+ data-testid="dropdown-profile"
52
+ class="bas-flex bas-w-fit bas-flex-col bas-items-center bas-justify-start"
53
+ [style.height.px]="getHeight(dropdownBtnSize())"
54
+ >
55
+ <div #profileBtnEl>
56
+ <ba-dropdown-profile-btn
57
+ [serviceName]="serviceName()"
58
+ [dropdownBtnBgClassNames]="dropdownBtnBgClassNames()"
59
+ [dropdownBtnSize]="dropdownBtnSize()"
60
+ [dropdownBtnBorderClassNames]="dropdownBtnBorderClassNames()"
61
+ [dropdownBtnTextColorClassNames]="dropdownBtnTextColorClassNames()"
62
+ [height]="getHeight(dropdownBtnSize())"
63
+ [isProfileOpen]="isProfileOpen()"
64
+ (clicked)="onProfileClicked()"
65
+ />
66
+ </div>
67
+ @if (isProfileOpen()) {
68
+ <div
69
+ #dropdownPanelEl
70
+ class="ba-pop bal-ba-transition-2 bal-ba-shadow-base bal-ba-hover-shadow-md bas-z-50 bas-mt-2 bas-box-border bas-w-full bas-overflow-hidden bas-rounded-md bas-border bas-border-solid bas-border-[var(--bal-primary-100)] bas-bg-white hover:bas-border-[var(--bal-primary-300)]"
71
+ [style.minHeight.px]="dropdownHeight() ?? totalDropdownItems() * 36 + 2"
72
+ >
73
+ <ul
74
+ [class]="
75
+ 'bas-flex bas-h-full bas-w-full bas-box-border bas-flex-col bas-items-center bas-justify-start bas-text-base bas-text-[var(--bal-primary-700)] bal-ba-transition-2 hover:bas-text-[var(--bal-primary-900)] bal-ba-scroll-base bas-p-0 bas-m-0 ' +
76
+ (isAnimationCompleted() && isProfileOpen() ? 'bas-overflow-y-auto' : '')
77
+ "
78
+ [style.maxHeight.px]="dropdownHeight() ?? totalDropdownItems() * 36"
79
+ >
80
+ <!-- ADDITIONAL ITEMS -->
81
+ @for (item of itemsToShow(); track $index; let index = $index) {
82
+ <ba-dropdown-profile-item
83
+ [id]="'ba-ddi-additional-item-' + index"
84
+ [text]="localized(item.text, '')"
85
+ [firstIcon]="item.firstIcon ?? null"
86
+ [isTopBorder]="index !== 0"
87
+ [disabled]="item.disabled ?? false"
88
+ (clicked)="item.onClick()"
89
+ />
90
+ }
91
+ <!-- CHANGE ACCOUNT -->
92
+ <ba-change-account
93
+ [serviceName]="serviceName()"
94
+ [text]="localized(btnsTexts().changeAccount, 'Change account')"
95
+ [disabled]="disabled()"
96
+ [isTopBorder]="isWeb3Service() || itemsToShow().length > 0"
97
+ (changeAccountClicked)="onChangeAccountClicked()"
98
+ />
99
+ <!-- DISCONNECT -->
100
+ <ba-dropdown-profile-item
101
+ id="ba-dd8-disconnect-0"
102
+ [text]="localized(btnsTexts().disconnect, 'Disconnect')"
103
+ [firstIcon]="disconnectIcon"
104
+ [isTopBorder]="true"
105
+ (clicked)="onDisconnectClicked()"
106
+ />
107
+ </ul>
108
+ </div>
109
+ }
110
+ </div>
111
+ `,
112
+ styles: [
113
+ `
114
+ .ba-pop {
115
+ animation: ba-pop-in 0.15s ease;
116
+ transform-origin: top right;
117
+ }
118
+ @keyframes ba-pop-in {
119
+ from { opacity: 0; transform: scale(0.95); }
120
+ to { opacity: 1; transform: scale(1); }
121
+ }
122
+ `,
123
+ ],
124
+ })
125
+ export class DropdownProfileComponent {
126
+ onLogout = input<() => void | Promise<void>>(async () => {});
127
+ /** Host-app navigation for "My profile" when already on the identity origin. */
128
+ onNavigate = input<((path: string) => void) | null>(null);
129
+ isInsideModal = input(false);
130
+ /** Modal container that scopes the click-outside listener when `isInsideModal`. */
131
+ container = input<HTMLElement | null>(null);
132
+ /** Disables the "Change account" row (default: disabled — not functional yet). */
133
+ disabled = input(true);
134
+ dropdownBtnSize = input<'xs' | 'small' | 'base' | 'large'>('base');
135
+ dropdownBtnBgClassNames = input('bas-bg-transparent');
136
+ dropdownBtnTextColorClassNames = input(
137
+ 'bas-text-[var(--bal-primary-700)] group-hover:bas-text-[var(--bal-primary-900)]',
138
+ );
139
+ dropdownBtnBorderClassNames = input<string | undefined>(undefined);
140
+ dropdownHeight = input<number | null>(null);
141
+ additionalDropdownItems = input<DropdownProfileAdditionalItem[] | null>(null);
142
+
143
+ private readonly profileBtnEl = viewChild<ElementRef<HTMLDivElement>>('profileBtnEl');
144
+ private readonly dropdownPanelEl = viewChild<ElementRef<HTMLDivElement>>('dropdownPanelEl');
145
+
146
+ private readonly client = injectBlockAuthClient();
147
+ private readonly ui = injectStore(this.client.state.$ui);
148
+ private readonly locale = injectStore($locale);
149
+
150
+ protected readonly disconnectIcon = AiOutlineDisconnectComponent;
151
+ protected readonly isProfileOpen = signal(false);
152
+ protected readonly isAnimationCompleted = signal(false);
153
+ private animTimeout: ReturnType<typeof setTimeout> | undefined;
154
+
155
+ protected readonly serviceName = computed(() => this.ui().serviceName ?? 'blockauth');
156
+
157
+ private readonly uiConfigContent = computed(
158
+ () =>
159
+ this.ui().uiConfig as
160
+ | { config?: { content?: { btns?: Record<string, Record<string, string>> } } }
161
+ | null,
162
+ );
163
+ private readonly isUiConfig = computed(
164
+ () => this.uiConfigContent() != null && Object.keys(this.uiConfigContent()!).length > 0,
165
+ );
166
+ protected readonly btnsTexts = computed(
167
+ () =>
168
+ (this.isUiConfig() && this.uiConfigContent()?.config?.content?.btns) || {
169
+ disconnect: { es: 'Desconectar', en: 'Disconnect' },
170
+ changeAccount: { es: 'Cambiar cuenta', en: 'Change account' },
171
+ },
172
+ );
173
+
174
+ private readonly defaultAdditionalDropdownItems = computed<DropdownProfileAdditionalItem[]>(() => [
175
+ {
176
+ onClick: () => {
177
+ const url = 'https://identity.block-auth.io';
178
+ const identityOrigin = 'https://identity.block-auth.io';
179
+ if (typeof window === 'undefined') return;
180
+ if (window.location.origin === identityOrigin) {
181
+ const navigate = this.onNavigate();
182
+ if (navigate) navigate('/user/profile');
183
+ else window.location.href = url;
184
+ } else {
185
+ window.open(url, '_blank');
186
+ }
187
+ },
188
+ text: { es: 'Mi perfil', en: 'My profile' },
189
+ firstIcon: CgProfileComponent,
190
+ disabled: false,
191
+ },
192
+ ]);
193
+
194
+ protected readonly itemsToShow = computed(
195
+ () => this.additionalDropdownItems() ?? this.defaultAdditionalDropdownItems(),
196
+ );
197
+ private readonly web2Services = ['google', 'microsoft'];
198
+ protected readonly isWeb3Service = computed(() => !this.web2Services.includes(this.serviceName()));
199
+ protected readonly totalDropdownItems = computed(() => 2 + this.itemsToShow().length);
200
+
201
+ private readonly heightBySize: Record<string, number> = { xs: 46, small: 52, base: 58, large: 64 };
202
+
203
+ constructor() {
204
+ inject(DestroyRef).onDestroy(() => {
205
+ if (this.animTimeout) clearTimeout(this.animTimeout);
206
+ });
207
+ }
208
+
209
+ getHeight(size: string): number {
210
+ return this.heightBySize[size];
211
+ }
212
+
213
+ localized(text: string | Record<string, string> | undefined, fallback: string): string {
214
+ if (!text) return fallback;
215
+ if (typeof text === 'string') return text;
216
+ return text[this.locale()] ?? text['en'] ?? fallback;
217
+ }
218
+
219
+ onProfileClicked(): void {
220
+ this.isAnimationCompleted.set(false);
221
+ const next = !this.isProfileOpen();
222
+ this.isProfileOpen.set(next);
223
+ if (this.animTimeout) clearTimeout(this.animTimeout);
224
+ if (next) this.animTimeout = setTimeout(() => this.isAnimationCompleted.set(true), 200);
225
+ }
226
+
227
+ async onDisconnectClicked(): Promise<void> {
228
+ this.isAnimationCompleted.set(false);
229
+ this.isProfileOpen.set(false);
230
+ // Clear BlockAuth state first, then let the host app tear down — otherwise
231
+ // onLogout could read stale storage. Order is critical (verified in test).
232
+ await this.client.logout();
233
+ await this.onLogout()();
234
+ }
235
+
236
+ onChangeAccountClicked(): void {
237
+ if (this.disabled()) return;
238
+ this.isAnimationCompleted.set(false);
239
+ this.isProfileOpen.set(false);
240
+ }
241
+
242
+ // Click-outside. Con @HostListener el listener vive siempre en document; para
243
+ // respetar la
244
+ // semántica "escuchar sobre `container` cuando `isInsideModal && container`"
245
+ // se ignora todo click fuera de ese contenedor. Un click sobre el propio
246
+ // botón de perfil nunca cuenta como "fuera". "Fuera" se calcula vs el panel.
247
+ @HostListener('document:click', ['$event'])
248
+ onDocumentClick(e: MouseEvent): void {
249
+ if (!this.isProfileOpen()) return;
250
+ const target = e.target as Node;
251
+ const container = this.container();
252
+ if (this.isInsideModal() && container && !container.contains(target)) return;
253
+ const btnEl = this.profileBtnEl()?.nativeElement;
254
+ if (btnEl && btnEl.contains(target)) return;
255
+ const panel = this.dropdownPanelEl()?.nativeElement;
256
+ if (panel && !panel.contains(target)) this.isProfileOpen.set(false);
257
+ }
258
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Component } from '@angular/core';
3
+ import { render, screen, fireEvent, waitFor } from '@testing-library/angular';
4
+ import { map, atom } from 'nanostores';
5
+
6
+ vi.mock('@wagmi/core', () => ({
7
+ createConfig: vi.fn(() => ({ connectors: [] })),
8
+ connect: vi.fn(), disconnect: vi.fn(), getAccount: vi.fn(() => ({})),
9
+ signMessage: vi.fn(), watchAccount: vi.fn(() => () => undefined), http: vi.fn(),
10
+ }));
11
+ vi.mock('@wagmi/connectors', () => ({
12
+ injected: vi.fn(() => ({ id: 'injected' })), walletConnect: vi.fn(() => ({ id: 'walletConnect' })),
13
+ }));
14
+
15
+ import { FlowBlockAuthComponent } from './flow-block-auth.component';
16
+ import { provideBlockAuthClientForTest } from '../testing';
17
+
18
+ // Port de packages/react/src/components/FlowBlockAuth.test.tsx. El `initializeSdk`
19
+ // se pone en `false` donde no se quiere arrancar el runtime real (el arranque
20
+ // del runtime lo cubre block-auth-runtime.directive.test.ts). Client falso con
21
+ // stores nanostores y spies de acción.
22
+ function makeClient(ui: Record<string, unknown> = {}) {
23
+ return {
24
+ requestSignin: vi.fn(),
25
+ openModal: vi.fn(),
26
+ closeModal: vi.fn(),
27
+ cancelTxns: vi.fn(),
28
+ logout: vi.fn(async () => {}),
29
+ web3: { signup: vi.fn() },
30
+ oauth: { signupGoogle: vi.fn(), signupMicrosoft: vi.fn() },
31
+ state: {
32
+ $ws: map<Record<string, unknown>>({ ready: false }),
33
+ $ui: map<Record<string, unknown>>({ ...ui }),
34
+ $wallet: map({}),
35
+ $openModals: atom<string[]>([]),
36
+ },
37
+ } as any;
38
+ }
39
+
40
+ describe('FlowBlockAuth', () => {
41
+ it('renderiza el contenedor del orquestador sin romperse', async () => {
42
+ @Component({
43
+ standalone: true,
44
+ imports: [FlowBlockAuthComponent],
45
+ template: `<ba-flow-block-auth [initializeSdk]="false" />`,
46
+ })
47
+ class Host {}
48
+ await render(Host, { providers: [provideBlockAuthClientForTest(makeClient())] });
49
+ expect(screen.getByTestId('flow-blockauth')).toBeTruthy();
50
+ });
51
+
52
+ it('no se rompe cuando initializeSdk esta desactivado', async () => {
53
+ @Component({
54
+ standalone: true,
55
+ imports: [FlowBlockAuthComponent],
56
+ template: `<ba-flow-block-auth [initializeSdk]="false" />`,
57
+ })
58
+ class Host {}
59
+ await render(Host, { providers: [provideBlockAuthClientForTest(makeClient())] });
60
+ expect(screen.getByTestId('flow-blockauth')).toBeTruthy();
61
+ });
62
+
63
+ it('en estado log-in, click en el boton de estado llama a client.requestSignin', async () => {
64
+ const client = makeClient({
65
+ isSdkSignedIn: true,
66
+ isServiceConnected: true,
67
+ isSigned: false,
68
+ isSignedFirstCheck: true,
69
+ isOnDeletion: false,
70
+ serviceName: 'blockauth',
71
+ uiConfig: { config: { content: { btns: { logIn: { en: 'Log in', es: 'Iniciar sesion' } } } } },
72
+ });
73
+ client.state.$ws.setKey('ready', true);
74
+
75
+ @Component({
76
+ standalone: true,
77
+ imports: [FlowBlockAuthComponent],
78
+ template: `<ba-flow-block-auth [initializeSdk]="false" [showDropdownWhenConnected]="false" />`,
79
+ })
80
+ class Host {}
81
+ const { container } = await render(Host, {
82
+ providers: [provideBlockAuthClientForTest(client)],
83
+ });
84
+
85
+ const btn = container.querySelector('#btn-flow-blockauth') as HTMLButtonElement;
86
+ expect(btn).toBeTruthy();
87
+ expect(btn.disabled).toBe(false);
88
+ await fireEvent.click(btn);
89
+
90
+ await waitFor(() => expect(client.requestSignin).toHaveBeenCalled());
91
+ expect(client.openModal).toHaveBeenCalledWith('signin');
92
+ });
93
+ });
@@ -0,0 +1,226 @@
1
+ import { ChangeDetectionStrategy, Component, computed, inject, input, output } from '@angular/core';
2
+ import { setIsSigningLoader } from '@block-auth.io/core';
3
+ import { BlockAuthService } from '../block-auth.service';
4
+ import { BtnBaseComponent } from '../primitives/btn-base.component';
5
+ import { BlockAuthRuntimeDirective } from '../runtime/block-auth-runtime.directive';
6
+ import {
7
+ DropdownProfileComponent,
8
+ type DropdownProfileAdditionalItem,
9
+ } from './dds/dropdown-profile.component';
10
+ import { ModalSigninComponent } from './modals/modal-signin.component';
11
+ import { ModalSignupComponent } from './modals/modal-signup.component';
12
+ import { ModalRestoreComponent } from './modals/modal-restore.component';
13
+ import { ModalRestorationExpiredComponent } from './modals/modal-restoration-expired.component';
14
+
15
+ @Component({
16
+ selector: 'ba-flow-block-auth',
17
+ standalone: true,
18
+ changeDetection: ChangeDetectionStrategy.OnPush,
19
+ imports: [
20
+ BtnBaseComponent,
21
+ BlockAuthRuntimeDirective,
22
+ DropdownProfileComponent,
23
+ ModalSigninComponent,
24
+ ModalSignupComponent,
25
+ ModalRestoreComponent,
26
+ ModalRestorationExpiredComponent,
27
+ ],
28
+ template: `
29
+ <div
30
+ data-testid="flow-blockauth"
31
+ [class]="
32
+ 'bas-flex bas-w-full bas-items-center bas-justify-center ' +
33
+ (showDDP() ? 'bas-h-fit bas-flex-col bas-gap-2.5' : ' bas-h-[52px]')
34
+ "
35
+ >
36
+ @if (initializeSdk()) {
37
+ <ng-container
38
+ baRuntime
39
+ (success)="success.emit($event)"
40
+ (errored)="errored.emit()"
41
+ (loggedOut)="loggedOut.emit()"
42
+ />
43
+ }
44
+
45
+ @if (!ui().isSigned || !ui().isSignedFirstCheck) {
46
+ <ba-btn-base
47
+ btnId="btn-flow-blockauth"
48
+ type="button-outlined"
49
+ size="base"
50
+ [disabled]="isLoading()"
51
+ [isLoader]="isLoading()"
52
+ loaderColor="#ffffff"
53
+ [isIcon]="ws().ready"
54
+ [text]="buttonText() || label()"
55
+ [bgClassNames]="bgClassNames()"
56
+ [textClassNames]="btnTextClassNames()"
57
+ [classNames]="'bas-justify-center ' + btnClassNames()"
58
+ [isFocusOutlinedEnabled]="false"
59
+ (action)="onFlowBAClicked()"
60
+ >
61
+ @if (ws().ready && ui().isSdkSignedIn) {
62
+ <img
63
+ slot="icon"
64
+ src="https://dashboard.block-auth.io/img/blockauth_logo.png"
65
+ width="26"
66
+ height="26"
67
+ alt="BlockAuth"
68
+ />
69
+ } @else if (ws().ready) {
70
+ <svg
71
+ slot="icon"
72
+ class="bas-h-6 bas-w-6 bas-text-white"
73
+ viewBox="0 0 24 24"
74
+ fill="currentColor"
75
+ aria-hidden="true"
76
+ >
77
+ <path
78
+ d="M12 2 1 21h22L12 2Zm0 6c.55 0 1 .45 1 1v5a1 1 0 1 1-2 0V9c0-.55.45-1 1-1Zm0 9.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Z"
79
+ />
80
+ </svg>
81
+ }
82
+ </ba-btn-base>
83
+ }
84
+
85
+ @if (showDDP()) {
86
+ <ba-dropdown-profile
87
+ [onLogout]="onLogoutForwarder"
88
+ [isInsideModal]="isInsideModal()"
89
+ [container]="container()"
90
+ [dropdownBtnSize]="dropdownBtnSize()"
91
+ [dropdownBtnBgClassNames]="dropdownBtnBgClassNames()"
92
+ [dropdownBtnTextColorClassNames]="dropdownBtnTextColorClassNames()"
93
+ [dropdownBtnBorderClassNames]="dropdownBtnBorderClassNames()"
94
+ [dropdownHeight]="dropdownHeight()"
95
+ [additionalDropdownItems]="additionalDropdownItems()"
96
+ />
97
+ }
98
+
99
+ <!-- Orden fijo: signin (modal de txn) se renderiza por encima de signup;
100
+ la card de signup queda detrás. Cada modal se auto-oculta según el
101
+ stack openModals del core. -->
102
+ <ba-modal-signup />
103
+ <ba-modal-restore />
104
+ <ba-modal-restoration-expired />
105
+ <ba-modal-signin />
106
+ </div>
107
+ `,
108
+ })
109
+ export class FlowBlockAuthComponent {
110
+ showDropdownWhenConnected = input(true);
111
+ isInsideModal = input(false);
112
+ /** Modal container that scopes the dropdown's click-outside listener when `isInsideModal`. */
113
+ container = input<HTMLElement | null>(null);
114
+ btnClassNames = input('bas-w-full bas-justify-center');
115
+ btnTextClassNames = input('bas-font-semibold bas-leading-6 bas-text-base bas-text-white');
116
+ dropdownBtnSize = input<'xs' | 'small' | 'base' | 'large'>('small');
117
+ dropdownBtnBgClassNames = input('bas-bg-transparent');
118
+ dropdownBtnTextColorClassNames = input(
119
+ 'bas-text-[var(--bal-primary-700)] group-hover:bas-text-[var(--bal-primary-900)]',
120
+ );
121
+ dropdownBtnBorderClassNames = input<string | undefined>(undefined);
122
+ dropdownHeight = input<number | null>(null);
123
+ initializeSdk = input(true);
124
+ additionalDropdownItems = input<DropdownProfileAdditionalItem[] | null>(null);
125
+ /** Status-button label override. */
126
+ buttonText = input('');
127
+
128
+ success = output<string | undefined>();
129
+ errored = output<void>();
130
+ loggedOut = output<void>();
131
+
132
+ private readonly ba = inject(BlockAuthService);
133
+ protected readonly ws = this.ba.ws;
134
+ protected readonly ui = this.ba.ui;
135
+ private readonly locale = this.ba.locale;
136
+
137
+ private readonly isUiConfig = computed(
138
+ () => this.ui().uiConfig != null && Object.keys(this.ui().uiConfig as object).length > 0,
139
+ );
140
+ // Copia localizada del backend (`app()` -> uiConfig.config.content.btns):
141
+ // { reviewApi, signUp, logIn, restore } cada una keyed por locale (en/es).
142
+ private readonly btnsTexts = computed(
143
+ () =>
144
+ (
145
+ this.ui().uiConfig as
146
+ | { config?: { content?: { btns?: Record<string, Record<string, string>> } } }
147
+ | null
148
+ )?.config?.content?.btns,
149
+ );
150
+
151
+ protected readonly showDDP = computed(
152
+ () =>
153
+ this.ws().ready &&
154
+ this.ui().isSdkSignedIn &&
155
+ this.ui().isServiceConnected &&
156
+ this.showDropdownWhenConnected() &&
157
+ this.ui().isSignedFirstCheck,
158
+ );
159
+
160
+ // Azul mientras arranca, transparente una vez firmado en el SDK, naranja en
161
+ // on-deletion, rojo si la api key es inválida.
162
+ protected readonly bgClassNames = computed(() =>
163
+ !(this.ws().ready && this.isUiConfig())
164
+ ? 'bas-bg-[rgba(0,78,152,0.8)] hover:bas-bg-[rgba(0,62,121,0.8)]'
165
+ : this.ui().isSdkSignedIn
166
+ ? undefined
167
+ : this.ui().isOnDeletion
168
+ ? 'bas-bg-orange-500 hover:bas-bg-orange-600'
169
+ : 'bas-bg-red-500 hover:bas-bg-red-600',
170
+ );
171
+
172
+ protected readonly isLoading = computed(
173
+ () => !(this.ws().ready && this.isUiConfig() && this.ui().isSignedFirstCheck),
174
+ );
175
+
176
+ // Label localizado desde el mapa de texto del backend; `buttonText` sigue
177
+ // pudiendo sobreescribirlo.
178
+ private readonly restoreDefault: Record<string, string> = {
179
+ es: 'Restaurar cuenta',
180
+ en: 'Restore account',
181
+ };
182
+ protected readonly label = computed<string>(() => {
183
+ if (this.isLoading()) return '';
184
+ if (!this.ui().isSdkSignedIn) return this.btnsTexts()?.reviewApi?.[this.locale()] ?? '';
185
+ if (this.ui().isOnDeletion) {
186
+ return (
187
+ this.btnsTexts()?.restore?.[this.locale()] ??
188
+ this.restoreDefault[this.locale()] ??
189
+ this.restoreDefault['en']
190
+ );
191
+ }
192
+ if (!this.ui().isServiceConnected && !this.ui().isSigned) {
193
+ return this.btnsTexts()?.signUp?.[this.locale()] ?? '';
194
+ }
195
+ if (this.ui().isServiceConnected && !this.ui().isSigned) {
196
+ return this.btnsTexts()?.logIn?.[this.locale()] ?? '';
197
+ }
198
+ return '';
199
+ });
200
+
201
+ /** Reenvía el logout manual del dropdown al output `loggedOut`. */
202
+ protected readonly onLogoutForwarder = (): void => this.loggedOut.emit();
203
+
204
+ onFlowBAClicked(): void {
205
+ if (!(this.ui().isSdkSignedIn && this.ws().ready)) return;
206
+ if (this.ui().isOnDeletion) {
207
+ this.ba.openModal('restore');
208
+ return;
209
+ }
210
+ if (!this.ui().isServiceConnected && !this.ui().isSigned) {
211
+ this.ba.openModal('signup');
212
+ return;
213
+ }
214
+ if (this.ui().isServiceConnected && !this.ui().isSigned) {
215
+ // LOG-IN branch: emit the signin transaction, then open the signin modal
216
+ // and flag the signing loader. The runtime clears the loader once
217
+ // `user:txns` reports no pending txns.
218
+ this.ba.requestSignin();
219
+ const known = ['google', 'metamask', 'phantom', 'microsoft', 'blockauth'];
220
+ if (known.includes(this.ui().serviceName ?? '')) {
221
+ this.ba.openModal('signin');
222
+ setIsSigningLoader(true);
223
+ }
224
+ }
225
+ }
226
+ }