@block-auth.io/angular 0.2.10 → 0.3.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.
@@ -48,60 +48,62 @@ interface TxnPreParams {
48
48
  class="bas-flex bas-h-full bas-w-full bas-flex-col bas-items-center bas-justify-between"
49
49
  >
50
50
  <div
51
- class="bal-ba-transition-3 bas-flex bas-w-full bas-flex-col bas-gap-8 bas-pt-2"
51
+ class="bal-ba-glass-rise bas-flex bas-w-full bas-flex-col bas-gap-8 bas-pt-2"
52
52
  >
53
- <div
54
- class="bal-ba-outline-none bas-grid bas-h-[30px] bas-cursor-pointer bas-grid-cols-[65px_auto_65px] bas-items-center bas-justify-center bas-gap-2"
55
- (click)="permissionsRequiredClicked.emit()"
56
- >
57
- <ba-arrow-icon direction="right" />
58
- <div class="bas-flex bas-h-full bas-w-full bas-items-end">
59
- <p
60
- class="bas-m-0 bas-text-center bas-text-lg bas-font-medium bas-text-[var(--bal-primary-900)]"
61
- >
53
+ <div class="bas-relative bas-flex bas-w-full">
54
+ <button
55
+ type="button"
56
+ id="btn-txn-permissions-required"
57
+ class="bal-ba-glass-btn"
58
+ (click)="permissionsRequiredClicked.emit()"
59
+ >
60
+ <span class="bal-ba-glass-btn-label">
62
61
  {{ permissionsRequiredTxtResolved() }}
63
- </p>
64
- </div>
65
- <ba-arrow-icon direction="left" />
62
+ </span>
63
+ </button>
66
64
  </div>
67
65
 
68
- <div
69
- [class]="
70
- 'bas-grid bas-h-[30px] bas-grid-cols-[65px_auto_65px] bas-items-center bas-justify-center bas-gap-2 ' +
71
- (hasSolicitedData()
72
- ? 'bas-cursor-pointer'
73
- : 'bas-cursor-not-allowed bas-opacity-40')
74
- "
75
- (click)="onSolicitedDataClick()"
76
- >
77
- <ba-arrow-icon direction="right" />
78
- <div
79
- class="bas-flex bas-h-full bas-w-full bas-items-end bas-justify-center"
66
+ <div class="bas-relative bas-flex bas-w-full">
67
+ <button
68
+ type="button"
69
+ id="btn-txn-solicited-data"
70
+ [disabled]="!hasSolicitedData()"
71
+ [class]="
72
+ 'bal-ba-glass-btn' +
73
+ (showSolicitedArrows() ? ' bal-ba-glass-btn-pending' : '') +
74
+ (acceptHint() === 'solicitedData' ? ' bal-ba-head-shake' : '')
75
+ "
76
+ (click)="onSolicitedDataClick()"
80
77
  >
81
- <p
82
- [class]="
83
- 'bas-m-0 bas-text-center bas-text-lg bas-transition-colors ' +
84
- (hasSolicitedData()
85
- ? 'bas-font-medium bas-text-[var(--bal-primary-900)]'
86
- : 'bas-text-slate-400')
87
- "
88
- >
78
+ <span class="bal-ba-glass-btn-label">
89
79
  {{ solicitedDataTxtResolved() }}
90
- </p>
91
- </div>
92
- <ba-arrow-icon direction="left" />
80
+ </span>
81
+ </button>
82
+ <span class="bal-ba-glass-btn-arrow bal-ba-glass-btn-arrow-left">
83
+ <ba-arrow-icon
84
+ direction="right"
85
+ [visible]="showSolicitedArrows()"
86
+ />
87
+ </span>
88
+ <span class="bal-ba-glass-btn-arrow bal-ba-glass-btn-arrow-right">
89
+ <ba-arrow-icon direction="left" [visible]="showSolicitedArrows()" />
90
+ </span>
93
91
  </div>
94
92
  </div>
95
93
 
96
- @if (!showLifetime()) {
97
94
  <button
98
95
  type="button"
99
96
  id="btn-configure-session-time"
100
- class="bal-ba-transition-2 bas-flex bas-w-full bas-cursor-pointer bas-flex-row bas-items-end bas-justify-center bas-gap-2 bas-border-0 bas-bg-transparent bas-text-[--bal-primary-700] hover:bas-text-[--bal-primary-900]"
97
+ class="bal-ba-glass-link bal-ba-glass-rise bas-flex bas-w-full bas-cursor-pointer bas-flex-row bas-items-end bas-justify-center bas-gap-2 bas-border-0 bas-bg-transparent bas-text-[--bal-primary-700] hover:bas-text-[--bal-primary-900]"
101
98
  (click)="showLifetime.set(true)"
102
99
  >
103
100
  <p class="bas-m-0 bas-text-center bas-text-base">
104
101
  {{ configureSessionTimeTxtResolved() }}
102
+ @if (isLifetimeConfigured()) {
103
+ <span id="txn-lifetime-value" class="bal-ba-glass-link-value">{{
104
+ lifetimeTimeValue()
105
+ }}</span>
106
+ }
105
107
  </p>
106
108
  <svg
107
109
  class="bas-h-5 bas-w-5"
@@ -119,37 +121,81 @@ interface TxnPreParams {
119
121
  />
120
122
  </svg>
121
123
  </button>
122
- } @else {
123
- <ba-lifetime-slider-base
124
- id="txn-lifetime-slider"
125
- [sliderValue]="lifetimeSign()"
126
- [step]="1"
127
- [min]="minLifetime()"
128
- [max]="maxLifetime()"
129
- [timeInputValue]="lifetimeTimeValue()"
130
- [isOpen]="isTimePickerOpen()"
131
- [isTimePickerInsideModal]="true"
132
- modalId="txns-overview"
133
- timePickerDropdownDirection="up"
134
- classNames="bas-grid bas-h-auto bas-w-full bas-items-center bas-gap-3 bas-grid-cols-[152px_1fr]"
135
- timePickerContainerClassNames="bas-flex bas-w-full bas-items-center bas-justify-center"
136
- (sliderValueChanged)="onLifetimeValueChanged($event)"
137
- (timeChanged)="onLifetimeValueChanged($event)"
138
- (isOpenChange)="isTimePickerOpen.set($event)"
139
- (toggleTimerDropdown)="toggleTimerDropdown()"
140
- />
124
+
125
+ @if (showLifetime()) {
126
+ <div
127
+ class="bal-ba-glass-backdrop bal-ba-backdrop-blur-base bal-ba-backdrop-blur-sm bas-absolute bas-inset-0 bas-z-30 bas-box-border bas-h-full bas-w-full"
128
+ (click)="closeLifetime()"
129
+ ></div>
130
+ <div
131
+ class="bal-ba-glass-popup-slot bas-absolute bas-inset-x-0 bas-top-44 bas-z-40"
132
+ >
133
+ <div
134
+ role="dialog"
135
+ id="txn-lifetime-modal"
136
+ [attr.aria-label]="configureSessionTimeTxtResolved()"
137
+ class="bal-ba-glass-panel bal-ba-glass-pop bal-ba-glass-lifetime bas-box-border bas-w-[334px] bas-p-4"
138
+ >
139
+ <div class="bal-ba-glass-modal-head">
140
+ <button
141
+ type="button"
142
+ id="txn-lifetime-close"
143
+ [attr.aria-label]="t('close')"
144
+ class="bal-ba-glass-close"
145
+ (click)="closeLifetime()"
146
+ >
147
+ <svg
148
+ viewBox="0 0 10 10"
149
+ fill="none"
150
+ stroke="currentColor"
151
+ stroke-width="1.6"
152
+ stroke-linecap="round"
153
+ aria-hidden="true"
154
+ >
155
+ <path d="M2 2l6 6M8 2 2 8" />
156
+ </svg>
157
+ </button>
158
+ </div>
159
+ <ba-lifetime-slider-base
160
+ id="txn-lifetime-slider"
161
+ [sliderValue]="lifetimeSign()"
162
+ [step]="1"
163
+ [min]="minLifetime()"
164
+ [max]="maxLifetime()"
165
+ [timeInputValue]="lifetimeTimeValue()"
166
+ [isOpen]="isTimePickerOpen()"
167
+ [isTimePickerInsideModal]="true"
168
+ modalId="txns-overview"
169
+ timePickerDropdownDirection="down"
170
+ classNames="bal-ba-glass-lifetime-body"
171
+ timePickerContainerClassNames="bal-ba-glass-time bas-flex bas-w-full bas-items-center bas-justify-center"
172
+ sliderContainerClassNames="bal-ba-glass-slider"
173
+ (sliderValueChanged)="onLifetimeValueChanged($event)"
174
+ (timeChanged)="onLifetimeValueChanged($event)"
175
+ (isOpenChange)="isTimePickerOpen.set($event)"
176
+ (toggleTimerDropdown)="toggleTimerDropdown()"
177
+ />
178
+ <button
179
+ type="button"
180
+ id="txn-lifetime-accept"
181
+ class="bal-ba-glass-action bal-ba-glass-action-primary"
182
+ (click)="closeLifetime()"
183
+ >
184
+ {{ acceptTxtResolved() }}
185
+ </button>
186
+ </div>
187
+ </div>
141
188
  }
142
189
 
143
190
  <div
144
- class="bal-ba-transition-3 bas-flex bas-w-full bas-flex-col bas-gap-8 bas-pb-4"
191
+ class="bal-ba-glass-rise bas-flex bas-w-full bas-flex-col bas-gap-8 bas-pb-4"
145
192
  >
146
193
  <div
147
194
  data-testid="txn-pptc-toggle"
148
195
  [class]="
149
- 'bas-group bal-ba-transition-2 bas-box-border bas-flex bas-h-fit bas-w-full bas-flex-row bas-items-center bas-gap-3 bas-rounded-md bas-border-2 bas-border-solid bas-border-[var(--bal-primary-100)] bas-p-1 bas-pl-2 bal-ba-ring-base bal-ba-hover-ring-2 ' +
150
- (pptcAccepted()
151
- ? 'bas-border-[var(--bal-primary-300)] bal-ba-hover-ring-primary-300'
152
- : 'bal-ba-hover-ring-primary-200')
196
+ 'bal-ba-glass-consent bas-group bas-box-border bas-flex bas-h-fit bas-w-full bas-flex-row bas-items-center bas-gap-3 bas-p-[5px] bas-pl-[9px]' +
197
+ (pptcAccepted() ? ' bal-ba-glass-consent-checked' : '') +
198
+ (acceptHint() === 'privacyPolicy' ? ' bal-ba-head-shake' : '')
153
199
  "
154
200
  (click)="pptcAccepted.set(!pptcAccepted())"
155
201
  >
@@ -206,7 +252,7 @@ interface TxnPreParams {
206
252
  <button
207
253
  type="button"
208
254
  id="btn-txn-reject"
209
- class="bal-ba-ring-base bal-ba-ring bal-ba-transition-2 bas-group bas-relative bas-box-border bas-flex bas-flex-row bas-gap-2 bas-items-center bas-overflow-hidden bas-bg-transparent bal-ba-hover-ring-slate-400 bal-ba-focus-ring-slate-400 bas-h-[42px] bas-px-2.5 bas-py-0 disabled:bas-cursor-not-allowed disabled:bas-opacity-50 bal-ba-hover-ring-2 bas-cursor-pointer bal-ba-focus-ring-2 focus:bas-opacity-90 bal-ba-focus-ring-offset-0 bas-w-full bas-justify-center bas-border-2 bas-border-slate-400 bas-rounded-md bas-border-solid"
255
+ class="bal-ba-glass-action bal-ba-glass-action-reject bas-group bas-box-border bas-flex bas-h-[42px] bas-w-full bas-cursor-pointer bas-flex-row bas-items-center bas-justify-center bas-gap-2 bas-px-2.5 bas-py-0"
210
256
  (click)="onTxnRejectClicked()"
211
257
  >
212
258
  <span
@@ -215,19 +261,28 @@ interface TxnPreParams {
215
261
  {{ rejectTxtResolved() }}
216
262
  </span>
217
263
  </button>
218
- <button
219
- type="button"
220
- id="btn-txn-accept"
221
- [disabled]="!pptcAccepted() || !allRequiredFilled()"
222
- class="bal-ba-ring-base bal-ba-ring bal-ba-transition-2 bas-group bas-relative bas-box-border bas-flex bas-flex-row bas-gap-2 bas-items-center bas-overflow-hidden bas-bg-[#38b000] hover:bas-bg-[#329e00] bal-ba-hover-ring-green-500 bal-ba-focus-ring-green-500 bas-h-[42px] bas-px-2.5 bas-py-0 disabled:bas-cursor-not-allowed disabled:bas-opacity-50 bal-ba-hover-ring-2 bas-cursor-pointer bal-ba-focus-ring-2 focus:bas-opacity-90 bal-ba-focus-ring-offset-2 bal-ba-hover-ring-offset-2 bas-w-full bas-justify-center bas-border-none bas-rounded-md"
223
- (click)="onTxnAcceptClicked()"
264
+ <div
265
+ data-testid="txn-accept-wrapper"
266
+ class="bas-flex bas-w-full"
267
+ (mouseenter)="showAcceptHint.set(true)"
268
+ (mouseleave)="showAcceptHint.set(false)"
269
+ (focusin)="showAcceptHint.set(true)"
270
+ (focusout)="showAcceptHint.set(false)"
224
271
  >
225
- <span
226
- class="bal-ba-transition-2 bas-text-white bas-text-center bas-text-base bas-font-medium bas-flex bas-h-full bas-items-center"
272
+ <button
273
+ type="button"
274
+ id="btn-txn-accept"
275
+ [disabled]="isAcceptDisabled()"
276
+ class="bal-ba-glass-action bal-ba-glass-action-accept bas-group bas-box-border bas-flex bas-h-[42px] bas-w-full bas-cursor-pointer bas-flex-row bas-items-center bas-justify-center bas-gap-2 bas-px-2.5 bas-py-0"
277
+ (click)="onTxnAcceptClicked()"
227
278
  >
228
- {{ acceptTxtResolved() }}
229
- </span>
230
- </button>
279
+ <span
280
+ class="bal-ba-transition-2 bas-text-white bas-text-center bas-text-base bas-font-medium bas-flex bas-h-full bas-items-center"
281
+ >
282
+ {{ acceptTxtResolved() }}
283
+ </span>
284
+ </button>
285
+ </div>
231
286
  </div>
232
287
  </div>
233
288
  </div>
@@ -245,6 +300,7 @@ export class TxnComponent {
245
300
  configureSessionTimeTxt = input<string>();
246
301
  hasSolicitedData = input(false);
247
302
  allRequiredFilled = input(true);
303
+ solicitedDataPending = input<boolean>();
248
304
  closeTxns = input<() => void>(() => {});
249
305
  /** Session lifetime in seconds (default 3600). */
250
306
  lifetime = input(3600);
@@ -252,6 +308,8 @@ export class TxnComponent {
252
308
  selectedTxnPreParams = input<TxnPreParams>({});
253
309
  /** User-provided parameter values for solicited data. */
254
310
  userParamValues = input<Record<string, string>>({});
311
+ grantedParams = input<string[]>([]);
312
+ revokedParams = input<string[]>([]);
255
313
  /** Callback when an error occurs during accept validation. */
256
314
  addError = input<(messageSlug: string) => void>(() => {});
257
315
 
@@ -292,6 +350,21 @@ export class TxnComponent {
292
350
 
293
351
  protected readonly pptcAccepted = signal(false);
294
352
  protected readonly showLifetime = signal(false);
353
+ protected readonly showAcceptHint = signal(false);
354
+ protected readonly isAcceptDisabled = computed(
355
+ () => !this.pptcAccepted() || !this.allRequiredFilled(),
356
+ );
357
+ protected readonly acceptHint = computed<
358
+ 'privacyPolicy' | 'solicitedData' | null
359
+ >(() => {
360
+ if (!this.showAcceptHint() || !this.isAcceptDisabled()) return null;
361
+ return this.pptcAccepted() ? 'solicitedData' : 'privacyPolicy';
362
+ });
363
+ protected readonly showSolicitedArrows = computed(
364
+ () =>
365
+ this.solicitedDataPending() ??
366
+ (this.hasSolicitedData() && !this.allRequiredFilled()),
367
+ );
295
368
 
296
369
  private readonly lifetimeData = computed(
297
370
  () => this.selectedTxnPreParams().lifetime ?? {},
@@ -308,6 +381,7 @@ export class TxnComponent {
308
381
  // en sync.
309
382
  protected readonly lifetimeTimeValue = signal(getTimeBySec(this.lifetime()));
310
383
  protected readonly isTimePickerOpen = signal(false);
384
+ protected readonly isLifetimeConfigured = signal(false);
311
385
 
312
386
  private autoCloseTimeout: ReturnType<typeof setTimeout> | undefined;
313
387
 
@@ -335,6 +409,12 @@ export class TxnComponent {
335
409
  onLifetimeValueChanged(value: number): void {
336
410
  this.lifetimeSign.set(value);
337
411
  this.lifetimeTimeValue.set(getTimeBySec(value));
412
+ this.isLifetimeConfigured.set(true);
413
+ }
414
+
415
+ closeLifetime(): void {
416
+ this.isTimePickerOpen.set(false);
417
+ this.showLifetime.set(false);
338
418
  }
339
419
 
340
420
  openPrivacyPolicy(): void {
@@ -364,10 +444,13 @@ export class TxnComponent {
364
444
  if (!preParams) return this.addError()('no_transactions_params_found');
365
445
  if (!preParams.appKey) return this.addError()('no_app_key_found');
366
446
  if (!preParams.to) return this.addError()('no_contract_address_found');
447
+ this.client.state.$ui.setKey('acceptTxnError', null);
367
448
  this.client.state.$ui.setKey('isAcceptingTxn', true);
368
449
  this.client.txns.accept({
369
450
  lifetime: this.lifetimeSign(),
370
451
  userParams: this.userParamValues(),
452
+ grantedParams: this.grantedParams(),
453
+ revokedParams: this.revokedParams(),
371
454
  });
372
455
  // Auto-close after 4s if txns are cleared (matches original behaviour).
373
456
  this.autoCloseTimeout = setTimeout(() => {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, afterEach } from 'vitest';
2
- import { render, screen, fireEvent } from '@testing-library/angular';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/angular';
3
3
  import { map } from 'nanostores';
4
4
  import { setExtensionInstalled, resetExtension } from '@block-auth.io/core';
5
5
  import { TxnsOverviewComponent } from './txns-overview.component';
@@ -19,12 +19,23 @@ vi.mock('@wagmi/connectors', () => ({
19
19
  describe('TxnsOverview', () => {
20
20
  function makeClient(ws: Record<string, any> = {}, ui: Record<string, any> = {}) {
21
21
  return {
22
- txns: { reject: vi.fn(), accept: vi.fn(), report: vi.fn() },
22
+ txns: {
23
+ reject: vi.fn(),
24
+ accept: vi.fn(),
25
+ report: vi.fn(),
26
+ dataRequest: vi.fn().mockResolvedValue(null),
27
+ },
23
28
  closeModal: vi.fn(),
24
29
  state: { $ui: map({ ...ui }), $ws: map({ requests: [], ...ws }) },
25
30
  } as any;
26
31
  }
27
32
 
33
+ const acceptButton = () => document.getElementById('btn-txn-accept') as HTMLButtonElement;
34
+ const visibleArrows = () =>
35
+ Array.from(document.querySelectorAll('[aria-hidden="true"]')).filter(
36
+ (el) => el.querySelector('.bal-ba-arrow') && !el.className.includes('bas-invisible'),
37
+ );
38
+
28
39
  it('renderiza la vista general de txns (estado vacio sin crashear)', async () => {
29
40
  await render(TxnsOverviewComponent, {
30
41
  providers: [provideBlockAuthClientForTest(makeClient({}, { isSigning: false }))],
@@ -102,10 +113,267 @@ describe('TxnsOverview', () => {
102
113
  ],
103
114
  });
104
115
  await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
116
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
105
117
  await fireEvent.click(screen.getByText('ACCEPT'));
106
118
  expect(screen.getByText('No application key found')).toBeDefined();
107
119
  });
108
120
 
121
+ describe('con datos solicitados', () => {
122
+ const pending = {
123
+ requests: [{ id: 7, pre_params: JSON.stringify({ appName: 'Shop', appKey: 'app', to: '0xabc' }) }],
124
+ };
125
+ const solicitedTitle = () => screen.getByText('Solicited data');
126
+ const solicitedButton = () => solicitedTitle().closest('button') as HTMLButtonElement;
127
+
128
+ it('pide los datos de la txn y exige ceder, rellenar y guardar los obligatorios', async () => {
129
+ const client = makeClient(pending);
130
+ client.txns.dataRequest.mockResolvedValue({
131
+ txnId: 7,
132
+ params: [
133
+ { slug: 'users-firstname', mode: 'required', status: 'shared', value: 'Ana' },
134
+ { slug: 'users-email', mode: 'required', status: 'consent', value: 'ana@block-auth.io' },
135
+ { slug: 'users-phone', mode: 'required', status: 'missing', value: null },
136
+ ],
137
+ });
138
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
139
+ expect(client.txns.dataRequest).toHaveBeenCalledWith(7);
140
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
141
+ await waitFor(() => expect(solicitedButton().disabled).toBe(false));
142
+ expect(acceptButton().disabled).toBe(true);
143
+ expect(visibleArrows()).toHaveLength(2);
144
+
145
+ await fireEvent.click(solicitedTitle());
146
+ expect(screen.getByText('First Name')).toBeDefined();
147
+ await fireEvent.click(screen.getByLabelText('Share Email'));
148
+ expect(acceptButton().disabled).toBe(true);
149
+ await fireEvent.input(document.querySelector('input[type="tel"]')!, {
150
+ target: { value: ' +34 600 000 000 ' },
151
+ });
152
+ expect(document.getElementById('solicited-data-share-users-phone')?.getAttribute('aria-checked')).toBe('true');
153
+ expect(acceptButton().disabled).toBe(true);
154
+ await fireEvent.click(screen.getByText('Save'));
155
+ expect(acceptButton().disabled).toBe(false);
156
+ expect(visibleArrows()).toHaveLength(0);
157
+
158
+ await fireEvent.click(acceptButton());
159
+ expect(client.txns.accept).toHaveBeenCalledWith({
160
+ lifetime: 3600,
161
+ userParams: { 'users-phone': '+34600000000' },
162
+ grantedParams: ['users-email'],
163
+ revokedParams: [],
164
+ });
165
+ });
166
+
167
+ it('cierra el modal de datos solicitados con el botón de cerrar', async () => {
168
+ const client = makeClient(pending);
169
+ client.txns.dataRequest.mockResolvedValue({
170
+ txnId: 7,
171
+ params: [{ slug: 'users-email', mode: 'required', status: 'granted', value: 'ana@block-auth.io' }],
172
+ });
173
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
174
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
175
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
176
+ await fireEvent.click(solicitedTitle());
177
+ expect(screen.getByText('Email')).toBeDefined();
178
+ await fireEvent.click(screen.getByRole('button', { name: 'Close' }));
179
+ expect(screen.queryByText('Email')).toBeNull();
180
+ });
181
+
182
+ it('Aceptar del popup de datos lo cierra conservando los cambios sin aceptar el login', async () => {
183
+ const client = makeClient(pending);
184
+ client.txns.dataRequest.mockResolvedValue({
185
+ txnId: 7,
186
+ params: [{ slug: 'users-email', mode: 'optional', status: 'missing', value: null }],
187
+ });
188
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
189
+ await waitFor(() => expect(solicitedButton().disabled).toBe(false));
190
+ await fireEvent.click(solicitedTitle());
191
+ const slot = document.querySelector('.bal-ba-glass-modal-slot')!;
192
+ expect(slot.classList.contains('bas-inset-y-6')).toBe(true);
193
+ await fireEvent.input(document.querySelector('input[type="email"]')!, {
194
+ target: { value: 'ana@block-auth.io' },
195
+ });
196
+ await fireEvent.click(document.getElementById('solicited-data-accept')!);
197
+ expect(document.querySelector('.bal-ba-glass-modal')).toBeNull();
198
+ expect(client.txns.accept).not.toHaveBeenCalled();
199
+ await fireEvent.click(solicitedTitle());
200
+ expect((document.querySelector('input[type="email"]') as HTMLInputElement).value).toBe(
201
+ 'ana@block-auth.io',
202
+ );
203
+ });
204
+
205
+ it('retira un dato opcional cedido al desmarcar Ceder', async () => {
206
+ const client = makeClient(pending);
207
+ client.txns.dataRequest.mockResolvedValue({
208
+ txnId: 7,
209
+ params: [
210
+ { slug: 'users-email', mode: 'required', status: 'granted', value: 'ana@block-auth.io' },
211
+ { slug: 'users-phone', mode: 'optional', status: 'granted', value: '+34 600 000 000' },
212
+ ],
213
+ });
214
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
215
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
216
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
217
+ expect(visibleArrows()).toHaveLength(0);
218
+ await fireEvent.click(solicitedTitle());
219
+ await fireEvent.click(screen.getByLabelText('Share Phone'));
220
+ await fireEvent.click(acceptButton());
221
+ expect(client.txns.accept).toHaveBeenCalledWith({
222
+ lifetime: 3600,
223
+ userParams: {},
224
+ grantedParams: ['users-email'],
225
+ revokedParams: ['users-phone'],
226
+ });
227
+ });
228
+
229
+ it('bloquea ACEPTAR y muestra las flechas si se cambia un dato sin decidir', async () => {
230
+ const client = makeClient(pending);
231
+ client.txns.dataRequest.mockResolvedValue({
232
+ txnId: 7,
233
+ params: [{ slug: 'users-email', mode: 'required', status: 'granted', value: 'ana@block-auth.io' }],
234
+ });
235
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
236
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
237
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
238
+ await fireEvent.click(solicitedTitle());
239
+ const emailInput = document.querySelector('input[type="email"]') as HTMLInputElement;
240
+ await fireEvent.input(emailInput, { target: { value: 'otra@block-auth.io' } });
241
+ expect(acceptButton().disabled).toBe(true);
242
+ expect(visibleArrows()).toHaveLength(2);
243
+ await fireEvent.click(screen.getByText('Undo'));
244
+ expect(acceptButton().disabled).toBe(false);
245
+ expect(emailInput.value).toBe('ana@block-auth.io');
246
+ });
247
+
248
+ it('al guardar vacío un dato cedido deja de cederlo sin enviarlo', async () => {
249
+ const client = makeClient(pending);
250
+ client.txns.dataRequest.mockResolvedValue({
251
+ txnId: 7,
252
+ params: [
253
+ { slug: 'users-email', mode: 'required', status: 'granted', value: 'ana@block-auth.io' },
254
+ { slug: 'users-phone', mode: 'optional', status: 'granted', value: '+34 600 000 000' },
255
+ ],
256
+ });
257
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
258
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
259
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
260
+ await fireEvent.click(solicitedTitle());
261
+ expect(
262
+ document
263
+ .querySelector('.bal-ba-glass-modal')
264
+ ?.parentElement?.classList.contains('bal-ba-glass-modal-slot'),
265
+ ).toBe(true);
266
+ await fireEvent.click(screen.getByLabelText('Clear field Phone'));
267
+ const phoneSwitch = document.getElementById('solicited-data-share-users-phone') as HTMLButtonElement;
268
+ expect(phoneSwitch.disabled).toBe(true);
269
+ expect(phoneSwitch.getAttribute('aria-checked')).toBe('false');
270
+ await fireEvent.click(screen.getByText('Save'));
271
+ expect(screen.getByText('This data will not be shared')).toBeDefined();
272
+ expect(acceptButton().disabled).toBe(false);
273
+ await fireEvent.click(acceptButton());
274
+ expect(client.txns.accept).toHaveBeenCalledWith({
275
+ lifetime: 3600,
276
+ userParams: {},
277
+ grantedParams: ['users-email'],
278
+ revokedParams: ['users-phone'],
279
+ });
280
+ });
281
+
282
+ it('un obligatorio guardado vacío queda pendiente y bloquea ACEPTAR', async () => {
283
+ const client = makeClient(pending);
284
+ client.txns.dataRequest.mockResolvedValue({
285
+ txnId: 7,
286
+ params: [{ slug: 'users-email', mode: 'required', status: 'granted', value: 'ana@block-auth.io' }],
287
+ });
288
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
289
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
290
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
291
+ await fireEvent.click(solicitedTitle());
292
+ await fireEvent.click(screen.getByLabelText('Clear field Email'));
293
+ await fireEvent.click(screen.getByText('Save'));
294
+ expect(acceptButton().disabled).toBe(true);
295
+ expect(
296
+ document
297
+ .getElementById('solicited-data-users-email')
298
+ ?.closest('.bal-ba-glass-field')
299
+ ?.classList.contains('bal-ba-glass-field-pending'),
300
+ ).toBe(true);
301
+ await fireEvent.input(document.querySelector('input[type="email"]')!, {
302
+ target: { value: 'otra@block-auth.io' },
303
+ });
304
+ expect(document.getElementById('solicited-data-share-users-email')?.getAttribute('aria-checked')).toBe('true');
305
+ await fireEvent.click(screen.getByText('Save'));
306
+ expect(acceptButton().disabled).toBe(false);
307
+ });
308
+
309
+ it('deja "Datos solicitados" desactivado cuando no hay nada que mostrar', async () => {
310
+ const client = makeClient(pending, {
311
+ uiConfig: { config: { params: { 'users-email': { mode: 'required' } } } },
312
+ });
313
+ client.txns.dataRequest.mockResolvedValue({ txnId: 7, params: [] });
314
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
315
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
316
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
317
+ expect(solicitedButton().disabled).toBe(true);
318
+ });
319
+
320
+ it('no bloquea ACEPTAR mientras espera los datos si la app no pide ninguno', async () => {
321
+ const client = makeClient(pending);
322
+ client.txns.dataRequest.mockReturnValue(new Promise(() => undefined));
323
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
324
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
325
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
326
+ });
327
+
328
+ it('sin respuesta del servidor mantiene el cálculo con uiConfig', async () => {
329
+ const client = makeClient(pending, {
330
+ uiConfig: { config: { params: { 'users-email': { mode: 'required' } } } },
331
+ });
332
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
333
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
334
+ await waitFor(() => expect(solicitedButton().disabled).toBe(false));
335
+ expect(acceptButton().disabled).toBe(true);
336
+ await fireEvent.click(solicitedTitle());
337
+ await fireEvent.input(document.querySelector('input[type="email"]')!, {
338
+ target: { value: 'ana@block-auth.io' },
339
+ });
340
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
341
+ await fireEvent.click(acceptButton());
342
+ expect(client.txns.accept).toHaveBeenCalledWith({
343
+ lifetime: 3600,
344
+ userParams: { 'users-email': 'ana@block-auth.io' },
345
+ grantedParams: [],
346
+ revokedParams: [],
347
+ });
348
+ });
349
+
350
+ it('si el servidor falla mantiene el cálculo con uiConfig', async () => {
351
+ const client = makeClient(pending, {
352
+ uiConfig: { config: { params: { 'users-email': { mode: 'optional' } } } },
353
+ });
354
+ client.txns.dataRequest.mockRejectedValue(new Error('boom'));
355
+ await render(TxnsOverviewComponent, { providers: [provideBlockAuthClientForTest(client)] });
356
+ await fireEvent.click(screen.getByTestId('txn-pptc-toggle'));
357
+ await waitFor(() => expect(acceptButton().disabled).toBe(false));
358
+ expect(solicitedButton().disabled).toBe(false);
359
+ });
360
+
361
+ it('muestra el error del servidor en los datos solicitados y permite corregir', async () => {
362
+ const client = makeClient(pending);
363
+ client.txns.dataRequest.mockResolvedValue({ txnId: 7, params: [] });
364
+ const { fixture } = await render(TxnsOverviewComponent, {
365
+ providers: [provideBlockAuthClientForTest(client)],
366
+ });
367
+ await waitFor(() => expect(client.txns.dataRequest).toHaveBeenCalledTimes(1));
368
+ client.state.$ui.setKey('acceptTxnError', 'Required data not authorized');
369
+ fixture.detectChanges();
370
+ await waitFor(() =>
371
+ expect(screen.getByRole('alert').textContent).toContain('required data is missing or invalid'),
372
+ );
373
+ await waitFor(() => expect(client.txns.dataRequest).toHaveBeenCalledTimes(2));
374
+ });
375
+ });
376
+
109
377
  describe('con la verificación OTP caducada', () => {
110
378
  const pending = { requests: [{ id: 7, pre_params: JSON.stringify({ appName: 'Shop' }) }] };
111
379
  afterEach(() => resetExtension());