@12-apps/payments-frontend 3.40.0 → 3.42.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/payments-frontend",
3
- "version": "3.40.0",
3
+ "version": "3.42.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Browser half of the vendor-agnostic payments platform: plug-and-play MUI components for the per-provider settings page (credential form from each provider's schema, masked hints, verify/enable) and the checkout page (PIX QR + polling, card tokenization, hosted-checkout redirect), plus the headless hooks and fetch clients they build on. Talks only to the host's payments HTTP surface — never to a provider directly. Microfrontend-ready: no app coupling, host injects theme and auth.",
@@ -23,7 +23,7 @@
23
23
  "storybook:build": "storybook build"
24
24
  },
25
25
  "dependencies": {
26
- "@12-apps/payments-backend": "^4.30.0",
26
+ "@12-apps/payments-backend": "^4.32.0",
27
27
  "react-qr-code": "^2.2.0"
28
28
  },
29
29
  "peerDependencies": {
@@ -53,7 +53,10 @@ import { clearReturnedSettlement, takeReturnedSettlement } from './returned-sett
53
53
  * things that clear it are settlement and an explicit "give up".
54
54
  */
55
55
 
56
- /** How often to ask the provider whether the payment landed. */
56
+ /**
57
+ * How often to ask the provider whether the payment landed — the recorded
58
+ * third-party-wait exception, see {@link usePollTimer}.
59
+ */
57
60
  const DEFAULT_POLL_MS = 4000;
58
61
 
59
62
  /** Give up asking after this long, rather than polling a tab forever. */
@@ -126,6 +129,25 @@ interface ActivationCallbacks {
126
129
  /**
127
130
  * Ask the provider every few seconds, and stop asking eventually.
128
131
  *
132
+ * ## A deliberate poll: the recorded exception to "hear, do not poll" (FUT-2433)
133
+ *
134
+ * Every other wait in this package hears the host's channel and keeps no timer
135
+ * while it is live. This one cannot, and it is the one exception class the
136
+ * origin host records: a timer-driven read of a THIRD PARTY's state that no
137
+ * webhook or publisher of the host's reaches yet, bounded in time, and named.
138
+ * Each tick is not a read of the host's own rows — `verify-charge` asks the
139
+ * PROVIDER whether the activation charge was paid, and while nobody has asked,
140
+ * nothing server-side has moved that a hint could carry. The activation webhook
141
+ * that would settle it is not guaranteed to arrive (a tunnel, a preview box), so
142
+ * the wait is the confirmation. It runs only while the owner is paying on the
143
+ * provider's page and gives up at `pollTimeoutMs` (ten minutes by default).
144
+ *
145
+ * Where it is recorded: the origin host's realtime doc ("Allowed exceptions")
146
+ * and a decision record that amends ADR 0071 with it. This bounded third-party
147
+ * wait is an amendment to that ADR, not a supersession: 0071 still stands for
148
+ * every other wait. If activation ever becomes webhook-only, this relaxes to
149
+ * the channel exactly as the checkout's waits did and the exception goes away.
150
+ *
129
151
  * The give-up is a `failed` state carrying the host's own sentence, because the
130
152
  * wording is load-bearing: the charge is STILL valid, and an owner who has
131
153
  * genuinely paid must not be told they did not pay in time.
@@ -21,6 +21,7 @@ import {
21
21
  type ProviderChangeHandler,
22
22
  } from './settings-state';
23
23
  import { openSection, SetupGuideSection } from './SetupGuideSection';
24
+ import { useSettingsLive, type PaymentsSettingsLiveSignal } from './settings-live';
24
25
  import type { PaymentsSettingsCopy } from './settings-copy';
25
26
  import { PaymentsSettingsCopyProvider } from './settings-copy-context';
26
27
 
@@ -102,6 +103,16 @@ export interface PaymentProviderSettingsProps {
102
103
  * its history ({@link ProviderChangeHandler}).
103
104
  */
104
105
  onProviderChange?: ProviderChangeHandler;
106
+ /**
107
+ * The host's realtime channel for this store's payment settings (FUT-648).
108
+ *
109
+ * A hint re-reads the view, and so does every (re)connect: an OAuth grant
110
+ * completed in another tab, a colleague's save, a webhook settling the
111
+ * activation charge all reach this screen without a reload. Omit it and the
112
+ * screen reads on mount and after its own actions, exactly as before — it
113
+ * keeps no timer either way. See {@link PaymentsSettingsLiveSignal}.
114
+ */
115
+ live?: PaymentsSettingsLiveSignal;
105
116
  /**
106
117
  * The activation step, rendered under the connection card.
107
118
  *
@@ -299,8 +310,10 @@ export function PaymentProviderSettings({
299
310
  selectedProvider,
300
311
  onProviderChange,
301
312
  renderVerification,
313
+ live,
302
314
  }: PaymentProviderSettingsProps) {
303
315
  const { view, error, reload } = useSettingsState(client);
316
+ useSettingsLive(live, () => void reload());
304
317
  const { selected, setSelected } = useSelectedProvider(
305
318
  initialProvider,
306
319
  selectedProvider,
@@ -3,11 +3,17 @@
3
3
  import { Box, Button, Stack, Typography } from '@mui/material';
4
4
  import type { ReactNode } from 'react';
5
5
 
6
- import type { ConnectApplicationReport, PaymentEnvironment } from '@12-apps/payments-backend';
6
+ import type {
7
+ ConnectApplicationReport,
8
+ ConnectApplicationStatus,
9
+ PastedConnectCredentials,
10
+ PaymentEnvironment,
11
+ } from '@12-apps/payments-backend';
7
12
 
8
- import { CARD_SX, ConnectEnvironmentCard } from './ConnectEnvironmentCard';
13
+ import { CARD_SX, ConnectEnvironmentCard, TAP_SX } from './ConnectEnvironmentCard';
9
14
  import type { PlatformHomologacaoCopy } from './copy';
10
15
  import { PlatformCopyProvider, usePlatformCopy } from './copy-context';
16
+ import { PastedCredentialsCard } from './PastedCredentialsCard';
11
17
 
12
18
  /**
13
19
  * The platform's PagBank Connect application, per environment (FUT-479,
@@ -41,9 +47,17 @@ export interface ConnectApplicationPanelProps {
41
47
  /**
42
48
  * Which host-side variables feed one environment's application — the host's
43
49
  * own configuration surface, rendered as a collapsible per-environment help
44
- * when provided.
50
+ * when provided. ORDER MATTERS: the first three name the client id, the
51
+ * client secret and the account token, in that order — the credential
52
+ * preview and the paste fields are labelled by position.
45
53
  */
46
54
  configVarsFor?: (environment: PaymentEnvironment) => string[];
55
+ /**
56
+ * Consult with PASTED credentials — the host's mounted call to the backend's
57
+ * `consultPastedConnectApplication`, resolving to the status it answered.
58
+ * Omitted, the paste block is not rendered.
59
+ */
60
+ onConsultPasted?: (pasted: PastedConnectCredentials) => Promise<ConnectApplicationStatus>;
47
61
  /** Every word this panel and its environment cards render. REQUIRED. */
48
62
  copy: PlatformHomologacaoCopy;
49
63
  }
@@ -57,12 +71,12 @@ export function ConnectApplicationPanel(props: ConnectApplicationPanelProps): Re
57
71
  }
58
72
 
59
73
  function ConnectApplicationBody(props: ConnectApplicationPanelProps): ReactNode {
60
- const { report, onRefresh, configVarsFor } = props;
74
+ const { report, onRefresh, configVarsFor, onConsultPasted } = props;
61
75
  const copy = usePlatformCopy().connect;
62
76
  return (
63
77
  <Stack spacing={2} data-testid="connect-application-panel">
64
78
  <Stack spacing={0.5} data-testid="connect-expected-redirect" sx={CARD_SX}>
65
- <Typography variant="caption" color="text.secondary" fontWeight={600}>
79
+ <Typography variant="caption" fontWeight={600}>
66
80
  {copy.expectedRedirectHeading}
67
81
  </Typography>
68
82
  <Box
@@ -79,6 +93,9 @@ function ConnectApplicationBody(props: ConnectApplicationPanelProps): ReactNode
79
93
  configVars={configVarsFor?.(status.environment)}
80
94
  />
81
95
  ))}
96
+ {onConsultPasted ? (
97
+ <PastedCredentialsCard onConsult={onConsultPasted} configVarsFor={configVarsFor} />
98
+ ) : null}
82
99
  {onRefresh ? (
83
100
  <Box>
84
101
  <Button
@@ -86,6 +103,7 @@ function ConnectApplicationBody(props: ConnectApplicationPanelProps): ReactNode
86
103
  size="small"
87
104
  onClick={() => onRefresh()}
88
105
  data-testid="connect-refresh"
106
+ sx={TAP_SX}
89
107
  >
90
108
  {copy.consultAgain}
91
109
  </Button>
@@ -1,9 +1,13 @@
1
1
  'use client';
2
2
 
3
3
  import { Alert, Box, Button, Stack, Typography } from '@mui/material';
4
- import { useState, type ReactNode } from 'react';
4
+ import { Fragment, useState, type ReactNode } from 'react';
5
5
 
6
- import type { ConnectApplicationStatus, PaymentEnvironment } from '@12-apps/payments-backend';
6
+ import type {
7
+ ConnectApplicationStatus,
8
+ ConnectCredentialPreview,
9
+ PaymentEnvironment,
10
+ } from '@12-apps/payments-backend';
7
11
  import { usePlatformCopy } from './copy-context';
8
12
 
9
13
  /**
@@ -11,17 +15,15 @@ import { usePlatformCopy } from './copy-context';
11
15
  * Sandbox and production are SEPARATE applications with separate id/secret
12
16
  * pairs, so each card stands on its own: configured or not, what PagBank says
13
17
  * is registered, and whether the registered redirect URI matches the
14
- * deployment's callback.
18
+ * deployment's callback — plus the first characters of each value it was
19
+ * consulted with, so an operator can tell WHICH id, secret and token are in play.
15
20
  *
16
- * English, like the rest of this platform surface (FUT-760) — see
17
- * `ConnectApplicationPanel` for why.
21
+ * The same card renders the answer to a PASTED consult, so its test ids key on
22
+ * a `slot` — the environment for the host's two cards, `PASTED` for that one —
23
+ * rather than on the environment, which the pasted answer shares with one of
24
+ * them.
18
25
  */
19
26
 
20
- const ENV_LABEL: Record<PaymentEnvironment, string> = {
21
- SANDBOX: 'Sandbox',
22
- PRODUCTION: 'Production',
23
- };
24
-
25
27
  /** The bordered-card look every block of these screens shares. */
26
28
  export const CARD_SX = {
27
29
  border: 1,
@@ -30,13 +32,32 @@ export const CARD_SX = {
30
32
  p: 2,
31
33
  } as const;
32
34
 
35
+ /** A small button that is still a 40px tap target on a phone. */
36
+ export const TAP_SX = { minHeight: { xs: 40, sm: 'auto' } } as const;
37
+
38
+ /**
39
+ * A label like `PAGBANK_OAUTH_CLIENT_SECRET` is one token, so a narrow card
40
+ * would break it anywhere ("…SECRE / T"). A `<wbr>` after each underscore lets
41
+ * it wrap at the word joints instead.
42
+ */
43
+ function breakAfterUnderscores(label: string): ReactNode {
44
+ return label.split(/(?<=_)/).map((part, index) => (
45
+ // The parts are positional by construction; the label never reorders.
46
+ <Fragment key={index}>
47
+ {part}
48
+ <wbr />
49
+ </Fragment>
50
+ ));
51
+ }
52
+
33
53
  function Field({ label, children }: { label: string; children: ReactNode }): ReactNode {
34
54
  return (
35
55
  <Box
36
- sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 180, wordBreak: 'break-all' }}
56
+ // Whole words stay whole; only an unbroken id or URL breaks mid-run.
57
+ sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, minWidth: 180, overflowWrap: 'anywhere' }}
37
58
  >
38
- <Typography variant="caption" color="text.secondary" fontWeight={600}>
39
- {label}
59
+ <Typography variant="caption" fontWeight={600}>
60
+ {breakAfterUnderscores(label)}
40
61
  </Typography>
41
62
  <Typography component="span" variant="body2">
42
63
  {children}
@@ -46,32 +67,32 @@ function Field({ label, children }: { label: string; children: ReactNode }): Rea
46
67
  }
47
68
 
48
69
  /** The verdict the screen exists for: does the registered callback match ours? */
49
- function MismatchAlert({ status }: { status: ConnectApplicationStatus }): ReactNode {
70
+ function MismatchAlert({ status, slot }: { status: ConnectApplicationStatus; slot: string }): ReactNode {
50
71
  const copy = usePlatformCopy().connect;
51
72
  if (status.application === null) return null;
52
73
  if (status.redirectUriMismatch === true) {
53
74
  return (
54
- <Alert severity="error" data-testid={`connect-mismatch-${status.environment}`}>
75
+ <Alert severity="error" data-testid={`connect-mismatch-${slot}`}>
55
76
  {copy.redirectDiffers}
56
77
  </Alert>
57
78
  );
58
79
  }
59
80
  if (status.redirectUriMismatch === false) {
60
81
  return (
61
- <Alert severity="success" data-testid={`connect-match-${status.environment}`}>
82
+ <Alert severity="success" data-testid={`connect-match-${slot}`}>
62
83
  {copy.redirectMatches}
63
84
  </Alert>
64
85
  );
65
86
  }
66
87
  return (
67
- <Alert severity="warning" data-testid={`connect-unknown-${status.environment}`}>
88
+ <Alert severity="warning" data-testid={`connect-unknown-${slot}`}>
68
89
  {copy.redirectUnreported}
69
90
  </Alert>
70
91
  );
71
92
  }
72
93
 
73
94
  /** What PagBank reports as registered, plus whatever extra keys came back. */
74
- function ApplicationFields({ status }: { status: ConnectApplicationStatus }): ReactNode {
95
+ function ApplicationFields({ status, slot }: { status: ConnectApplicationStatus; slot: string }): ReactNode {
75
96
  const copy = usePlatformCopy().connect;
76
97
  const app = status.application;
77
98
  if (app === null) return null;
@@ -88,8 +109,8 @@ function ApplicationFields({ status }: { status: ConnectApplicationStatus }): Re
88
109
  </Field>
89
110
  </Box>
90
111
  {extraKeys.length > 0 ? (
91
- <Box data-testid={`connect-extra-${status.environment}`}>
92
- <Typography variant="caption" color="text.secondary" fontWeight={600}>
112
+ <Box data-testid={`connect-extra-${slot}`}>
113
+ <Typography variant="caption" fontWeight={600}>
93
114
  {copy.extraKeys}
94
115
  </Typography>
95
116
  <Box component="pre" sx={{ m: 0, fontSize: 12, overflowX: 'auto' }}>
@@ -101,6 +122,69 @@ function ApplicationFields({ status }: { status: ConnectApplicationStatus }): Re
101
122
  );
102
123
  }
103
124
 
125
+ const EMPTY_PREVIEW: ConnectCredentialPreview = {
126
+ clientId: null,
127
+ clientSecret: null,
128
+ accountToken: null,
129
+ };
130
+
131
+ /** One previewed value: unset, inherited from a fallback variable, or its own. */
132
+ function PreviewValue({ value, inherited }: { value: string | null; inherited: boolean }): ReactNode {
133
+ const copy = usePlatformCopy().connect;
134
+ if (value === null) return copy.previewUnset;
135
+ const code = (
136
+ <Box component="code" sx={{ fontFamily: 'monospace', fontSize: 13 }}>
137
+ {`${value}…`}
138
+ </Box>
139
+ );
140
+ if (!inherited) return code;
141
+ return (
142
+ <>
143
+ {copy.previewInherited} {code}
144
+ </>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * The first characters of each value the application was consulted with —
150
+ * never the value. Each is named by the host's own variable when it supplies
151
+ * them (`configVars`, in id / secret / token order), by the copy's names when not.
152
+ */
153
+ function CredentialPreview({
154
+ status,
155
+ slot,
156
+ configVars,
157
+ }: {
158
+ status: ConnectApplicationStatus;
159
+ slot: string;
160
+ configVars?: string[];
161
+ }): ReactNode {
162
+ const copy = usePlatformCopy().connect;
163
+ // A payload from a backend older than the preview carries none — render it unset, never crash.
164
+ const preview = status.credentialPreview ?? EMPTY_PREVIEW;
165
+ const names = copy.credentialNames;
166
+ const fields = ['clientId', 'clientSecret', 'accountToken'] as const;
167
+ const rows = fields.map((field, index) => ({
168
+ name: configVars?.[index] ?? names[field],
169
+ value: preview[field],
170
+ inherited: preview.inherited?.includes(field) ?? false,
171
+ }));
172
+ return (
173
+ <Stack spacing={1} data-testid={`connect-preview-${slot}`}>
174
+ <Typography variant="caption" fontWeight={600}>
175
+ {copy.previewHeading}
176
+ </Typography>
177
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2.5 }}>
178
+ {rows.map(({ name, value, inherited }) => (
179
+ <Field key={name} label={name}>
180
+ <PreviewValue value={value} inherited={inherited} />
181
+ </Field>
182
+ ))}
183
+ </Box>
184
+ </Stack>
185
+ );
186
+ }
187
+
104
188
  /**
105
189
  * The collapsible "what feeds this environment" help. The variable names are
106
190
  * the HOST's own configuration surface, so they arrive via `configVars`; with
@@ -123,6 +207,7 @@ function ConfigHelp({
123
207
  size="small"
124
208
  onClick={() => setOpen((value) => !value)}
125
209
  data-testid={`connect-config-toggle-${environment}`}
210
+ sx={TAP_SX}
126
211
  >
127
212
  {open ? copy.hideConfig : copy.showConfig}
128
213
  </Button>
@@ -149,18 +234,30 @@ function ConfigHelp({
149
234
  export function ConnectEnvironmentCard({
150
235
  status,
151
236
  configVars,
237
+ slot = status.environment,
238
+ showConfigHelp = true,
152
239
  }: {
153
240
  status: ConnectApplicationStatus;
154
241
  configVars?: string[];
242
+ /** What the test ids key on — see the module doc. */
243
+ slot?: string;
244
+ /** The variable help belongs to a host-configured environment, not a pasted answer. */
245
+ showConfigHelp?: boolean;
155
246
  }): ReactNode {
156
247
  const copy = usePlatformCopy().connect;
157
248
  return (
158
- <Stack spacing={1.5} data-testid={`connect-env-${status.environment}`} sx={CARD_SX}>
159
- <Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1.5, wordBreak: 'break-all' }}>
160
- <Typography variant="body2" fontWeight={600}>
161
- {ENV_LABEL[status.environment]}
249
+ <Stack spacing={1.5} data-testid={`connect-env-${slot}`} sx={CARD_SX}>
250
+ {/* The label never yields to a long client_id: the id wraps beneath it. */}
251
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', columnGap: 1.5 }}>
252
+ <Typography variant="body2" fontWeight={600} sx={{ whiteSpace: 'nowrap' }}>
253
+ {copy.environmentLabels[status.environment]}
162
254
  </Typography>
163
- <Typography component="span" variant="caption" color="text.secondary">
255
+ <Typography
256
+ component="span"
257
+ variant="caption"
258
+ color="text.secondary"
259
+ sx={{ wordBreak: 'break-all', minWidth: 0 }}
260
+ >
164
261
  {status.clientId !== null ? `client_id: ${status.clientId}` : ''}
165
262
  </Typography>
166
263
  </Box>
@@ -168,13 +265,14 @@ export function ConnectEnvironmentCard({
168
265
  <Typography variant="body2" color="text.secondary">{copy.noApplication}</Typography>
169
266
  ) : null}
170
267
  {status.error !== null ? (
171
- <Alert severity="warning" data-testid={`connect-error-${status.environment}`}>
268
+ <Alert severity="warning" data-testid={`connect-error-${slot}`}>
172
269
  {status.error}
173
270
  </Alert>
174
271
  ) : null}
175
- <MismatchAlert status={status} />
176
- <ApplicationFields status={status} />
177
- <ConfigHelp environment={status.environment} configVars={configVars} />
272
+ <MismatchAlert status={status} slot={slot} />
273
+ <ApplicationFields status={status} slot={slot} />
274
+ <CredentialPreview status={status} slot={slot} configVars={configVars} />
275
+ {showConfigHelp ? <ConfigHelp environment={status.environment} configVars={configVars} /> : null}
178
276
  </Stack>
179
277
  );
180
278
  }
@@ -0,0 +1,227 @@
1
+ 'use client';
2
+
3
+ import { Alert, Box, Button, Stack, TextField, Typography } from '@mui/material';
4
+ import { useRef, useState, type ReactNode } from 'react';
5
+
6
+ import type {
7
+ ConnectApplicationStatus,
8
+ PastedConnectCredentials,
9
+ PaymentEnvironment,
10
+ } from '@12-apps/payments-backend';
11
+
12
+ import { CARD_SX, ConnectEnvironmentCard, TAP_SX } from './ConnectEnvironmentCard';
13
+ import { usePlatformCopy } from './copy-context';
14
+
15
+ /**
16
+ * Consult a Connect application with credentials PASTED here rather than the
17
+ * ones the host holds — the backend's `consultPastedConnectApplication`.
18
+ *
19
+ * The host's two cards answer "what is configured?"; this answers "is the
20
+ * application I am about to configure the right one, and is its redirect_uri
21
+ * ours?" before anyone edits a secret and redeploys to find out. The answer
22
+ * comes back in the same card the environments use, previews included, so the
23
+ * three blocks read the same way.
24
+ *
25
+ * Whatever `onConsult` sends is the host's to keep out of storage and logs —
26
+ * this card holds the values in component state and nowhere else.
27
+ */
28
+
29
+ type PastedValues = Omit<PastedConnectCredentials, 'environment'>;
30
+
31
+ const EMPTY: PastedValues = { clientId: '', clientSecret: '', accountToken: '' };
32
+
33
+ /** The three paste fields; the secret two are masked as they are typed. */
34
+ function CredentialInputs({
35
+ names,
36
+ values,
37
+ onChange,
38
+ }: {
39
+ names: [string, string, string];
40
+ values: PastedValues;
41
+ onChange: (values: PastedValues) => void;
42
+ }): ReactNode {
43
+ const fields: [keyof PastedValues, string, boolean][] = [
44
+ ['clientId', names[0], false],
45
+ ['clientSecret', names[1], true],
46
+ ['accountToken', names[2], true],
47
+ ];
48
+ return fields.map(([key, label, secret]) => (
49
+ <TextField
50
+ key={key}
51
+ size="small"
52
+ type={secret ? 'password' : 'text'}
53
+ label={label}
54
+ value={values[key]}
55
+ // "off", never "new-password": that value is the hint password managers use to
56
+ // offer saving what was typed, and these values are never to be stored.
57
+ autoComplete="off"
58
+ onChange={(event) => onChange({ ...values, [key]: event.target.value })}
59
+ // Shrunk for good: the names share a long prefix, so an empty field's
60
+ // placeholder-sized label is cut to the same "PAGBANK_OAUTH_CLIENT…" twice.
61
+ slotProps={{
62
+ inputLabel: { shrink: true },
63
+ htmlInput: {
64
+ 'data-testid': `connect-pasted-${key}`,
65
+ // Password managers that ignore autocomplete honour these.
66
+ 'data-1p-ignore': true,
67
+ 'data-lpignore': 'true',
68
+ },
69
+ }}
70
+ />
71
+ ));
72
+ }
73
+
74
+ /** The input names: the host's own variables when it names them, the copy's when not. */
75
+ function useCredentialNames(
76
+ environment: PaymentEnvironment,
77
+ configVarsFor?: (environment: PaymentEnvironment) => string[],
78
+ ): [string, string, string] {
79
+ const fallback = usePlatformCopy().connect.credentialNames;
80
+ const vars = configVarsFor?.(environment) ?? [];
81
+ return [
82
+ vars[0] ?? fallback.clientId,
83
+ vars[1] ?? fallback.clientSecret,
84
+ vars[2] ?? fallback.accountToken,
85
+ ];
86
+ }
87
+
88
+ /** One consult at a time: what it answered, whether it failed, whether it is running. */
89
+ function usePastedConsult(
90
+ onConsult: (pasted: PastedConnectCredentials) => Promise<ConnectApplicationStatus>,
91
+ ): {
92
+ run: (pasted: PastedConnectCredentials) => void;
93
+ reset: () => void;
94
+ pending: boolean;
95
+ failed: boolean;
96
+ result: ConnectApplicationStatus | null;
97
+ } {
98
+ const [pending, setPending] = useState(false);
99
+ const [failed, setFailed] = useState(false);
100
+ const [result, setResult] = useState<ConnectApplicationStatus | null>(null);
101
+ // Which call the screen still wants an answer to. `reset` and every new `run`
102
+ // move it on, so a consult that settles after the values changed is dropped
103
+ // instead of landing beside values it was not given.
104
+ const current = useRef(0);
105
+ const run = (pasted: PastedConnectCredentials): void => {
106
+ const id = ++current.current;
107
+ const live = (): boolean => id === current.current;
108
+ setPending(true);
109
+ setFailed(false);
110
+ onConsult(pasted)
111
+ .then((status) => live() && setResult(status))
112
+ .catch(() => live() && setFailed(true))
113
+ .finally(() => live() && setPending(false));
114
+ };
115
+ // An answer belongs to the values it was given: editing any of them retires it.
116
+ const reset = (): void => {
117
+ current.current += 1;
118
+ setPending(false);
119
+ setFailed(false);
120
+ setResult(null);
121
+ };
122
+ return { run, reset, pending, failed, result };
123
+ }
124
+
125
+ export function PastedCredentialsCard({
126
+ onConsult,
127
+ configVarsFor,
128
+ }: {
129
+ onConsult: (pasted: PastedConnectCredentials) => Promise<ConnectApplicationStatus>;
130
+ configVarsFor?: (environment: PaymentEnvironment) => string[];
131
+ }): ReactNode {
132
+ const copy = usePlatformCopy().connect;
133
+ const [environment, setEnvironment] = useState<PaymentEnvironment>('SANDBOX');
134
+ const [values, setValues] = useState<PastedValues>(EMPTY);
135
+ const names = useCredentialNames(environment, configVarsFor);
136
+ const consult = usePastedConsult(onConsult);
137
+ const complete = Object.values(values).every((value) => value.trim() !== '');
138
+ const trimmed = {
139
+ environment,
140
+ clientId: values.clientId.trim(),
141
+ clientSecret: values.clientSecret.trim(),
142
+ accountToken: values.accountToken.trim(),
143
+ };
144
+
145
+ return (
146
+ <Stack spacing={1.5} data-testid="connect-pasted" sx={CARD_SX}>
147
+ <Typography variant="body2" fontWeight={600}>
148
+ {copy.pasted.heading}
149
+ </Typography>
150
+ <Typography variant="caption" component="p">
151
+ {copy.pasted.lede}
152
+ </Typography>
153
+ <Stack spacing={1.5} sx={{ maxWidth: 560 }}>
154
+ <TextField
155
+ select
156
+ size="small"
157
+ label={copy.pasted.environment}
158
+ value={environment}
159
+ onChange={(event) => {
160
+ setEnvironment(event.target.value as PaymentEnvironment);
161
+ consult.reset();
162
+ }}
163
+ slotProps={{
164
+ select: { native: true },
165
+ htmlInput: { 'data-testid': 'connect-pasted-environment' },
166
+ }}
167
+ >
168
+ <option value="SANDBOX">{copy.environmentLabels.SANDBOX}</option>
169
+ <option value="PRODUCTION">{copy.environmentLabels.PRODUCTION}</option>
170
+ </TextField>
171
+ <CredentialInputs
172
+ names={names}
173
+ values={values}
174
+ onChange={(next) => {
175
+ setValues(next);
176
+ consult.reset();
177
+ }}
178
+ />
179
+ <Box>
180
+ <Button
181
+ variant="contained"
182
+ size="small"
183
+ disabled={!complete || consult.pending}
184
+ onClick={() => consult.run(trimmed)}
185
+ data-testid="connect-pasted-submit"
186
+ sx={TAP_SX}
187
+ >
188
+ {copy.pasted.submit}
189
+ </Button>
190
+ </Box>
191
+ </Stack>
192
+ <PastedAnswer consult={consult} configVarsFor={configVarsFor} />
193
+ </Stack>
194
+ );
195
+ }
196
+
197
+ /** The failure line, or the answer in the same card the environments use. */
198
+ function PastedAnswer({
199
+ consult,
200
+ configVarsFor,
201
+ }: {
202
+ consult: ReturnType<typeof usePastedConsult>;
203
+ configVarsFor?: (environment: PaymentEnvironment) => string[];
204
+ }): ReactNode {
205
+ const copy = usePlatformCopy().connect;
206
+ if (consult.failed) {
207
+ return (
208
+ <Alert severity="error" data-testid="connect-pasted-failed">
209
+ {copy.pasted.failed}
210
+ </Alert>
211
+ );
212
+ }
213
+ if (!consult.result) return null;
214
+ return (
215
+ <Stack spacing={1}>
216
+ <Alert severity="info" data-testid="connect-pasted-secret-not-checked">
217
+ {copy.pasted.secretNotChecked}
218
+ </Alert>
219
+ <ConnectEnvironmentCard
220
+ status={consult.result}
221
+ slot="PASTED"
222
+ configVars={configVarsFor?.(consult.result.environment)}
223
+ showConfigHelp={false}
224
+ />
225
+ </Stack>
226
+ );
227
+ }
@@ -102,6 +102,32 @@ export interface ConnectApplicationCopy {
102
102
  hideConfig: string;
103
103
  /** The heading above whatever extra keys PagBank's response carried. */
104
104
  extraKeys: string;
105
+ /** Each card's heading — the environment, in the operator's words. */
106
+ environmentLabels: Readonly<Record<'SANDBOX' | 'PRODUCTION', string>>;
107
+ /**
108
+ * The preview of the values an application was consulted with: a heading,
109
+ * what an unset value reads as, and each value's name when the host names no
110
+ * configuration variables of its own (`configVarsFor`).
111
+ */
112
+ previewHeading: string;
113
+ previewUnset: string;
114
+ /** A value read from a fallback variable (production's token from the unsuffixed name). */
115
+ previewInherited: string;
116
+ credentialNames: { clientId: string; clientSecret: string; accountToken: string };
117
+ /** The block that consults with PASTED credentials — rendered only when the host wires it. */
118
+ pasted: {
119
+ heading: string;
120
+ /** Must say the values are not stored: the operator is pasting secrets. */
121
+ lede: string;
122
+ environment: string;
123
+ submit: string;
124
+ failed: string;
125
+ /**
126
+ * The consult authenticates with the account token and looks up the client
127
+ * id; the secret is never sent. The answer must not read as "all three are good".
128
+ */
129
+ secretNotChecked: string;
130
+ };
105
131
  }
106
132
 
107
133
  /** The whole platform surface, in one object a host passes at the mount. */
@@ -72,7 +72,7 @@ export const EN_US_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
72
72
  // operator compares the two strings character by character.
73
73
  redirectMatches: 'The registered redirect_uri matches the callback this deploy uses.',
74
74
  redirectDiffers:
75
- 'The redirect_uri registered with PagBank differs from the callback this deploy uses.',
75
+ 'The redirect_uri registered with PagBank differs from the callback this deploy uses. The OAuth authorization flow fails silently until the registration is fixed at PagBank.',
76
76
  redirectUnreported:
77
77
  "PagBank's response carried no redirect_uri, so it could not be compared with the callback.",
78
78
  fields: {
@@ -85,9 +85,25 @@ export const EN_US_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
85
85
  fieldEmpty: '—',
86
86
  redirectNotReported: 'not reported',
87
87
  resolvedFrom:
88
- "This environment's application is resolved strictly from these variables, with no fallback between environments:",
88
+ "This environment's application is resolved from these variables. The client_id and client_secret are strict, with no fallback between environments; the others, such as the token, fall back to the unsuffixed variable when this environment's own is unset:",
89
89
  showConfig: 'Show environment variables',
90
90
  hideConfig: 'Hide environment variables',
91
- extraKeys: 'Other fields returned',
91
+ extraKeys: 'Other fields returned (undocumented schema)',
92
+ environmentLabels: { SANDBOX: 'Sandbox', PRODUCTION: 'Production' },
93
+ previewHeading: 'First 5 characters of each variable',
94
+ previewUnset: 'not set',
95
+ previewInherited: 'not set — uses the unsuffixed variable:',
96
+ // `client_id` / `client_secret` are PagBank's own parameter names.
97
+ credentialNames: { clientId: 'client_id', clientSecret: 'client_secret', accountToken: 'account token' },
98
+ pasted: {
99
+ heading: 'Test other credentials',
100
+ lede:
101
+ "Paste an application's credentials to look it up at PagBank before changing the environment variables. The values are used for this lookup only and are not saved.",
102
+ environment: 'Environment',
103
+ submit: 'Look up with these credentials',
104
+ failed: 'The pasted credentials could not be looked up.',
105
+ secretNotChecked:
106
+ 'PagBank does not check the client_secret in this lookup — only the client_id and the token. A wrong client_secret only shows at the first store authorization.',
107
+ },
92
108
  },
93
109
  };
@@ -52,7 +52,7 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
52
52
  noApplication: 'Nenhuma aplicação configurada neste ambiente.',
53
53
  redirectMatches: 'O redirect_uri registrado confere com o callback que este deploy usa.',
54
54
  redirectDiffers:
55
- 'O redirect_uri registrado no PagBank é diferente do callback que este deploy usa.',
55
+ 'O redirect_uri registrado no PagBank é diferente do callback que este deploy usa. O fluxo de autorização OAuth falha silenciosamente até o cadastro ser corrigido no PagBank.',
56
56
  redirectUnreported:
57
57
  'A resposta do PagBank não trouxe redirect_uri, então não foi possível comparar com o callback.',
58
58
  fields: {
@@ -65,9 +65,24 @@ export const PT_BR_PLATFORM_HOMOLOGACAO_COPY: PlatformHomologacaoCopy = {
65
65
  fieldEmpty: '—',
66
66
  redirectNotReported: 'não informado',
67
67
  resolvedFrom:
68
- 'A aplicação deste ambiente é resolvida estritamente a partir destas variáveis, sem fallback entre ambientes:',
68
+ 'A aplicação deste ambiente é resolvida a partir destas variáveis. O client_id e o client_secret são estritos, sem fallback entre ambientes; as demais, como o token, caem para a variável sem sufixo quando a deste ambiente não existe:',
69
69
  showConfig: 'Mostrar variáveis de ambiente',
70
70
  hideConfig: 'Ocultar variáveis de ambiente',
71
- extraKeys: 'Outros campos retornados',
71
+ extraKeys: 'Outros campos retornados (schema não documentado)',
72
+ environmentLabels: { SANDBOX: 'Sandbox', PRODUCTION: 'Produção' },
73
+ previewHeading: 'Primeiros 5 caracteres de cada variável',
74
+ previewUnset: 'não definida',
75
+ previewInherited: 'não definida — usa o valor da variável sem sufixo:',
76
+ credentialNames: { clientId: 'client_id', clientSecret: 'client_secret', accountToken: 'token da conta' },
77
+ pasted: {
78
+ heading: 'Testar outras credenciais',
79
+ lede:
80
+ 'Cole as credenciais de uma aplicação para consultá-la no PagBank antes de alterar as variáveis de ambiente. Os valores são usados só nesta consulta e não são salvos.',
81
+ environment: 'Ambiente',
82
+ submit: 'Consultar com estas credenciais',
83
+ failed: 'Não foi possível consultar com as credenciais coladas.',
84
+ secretNotChecked:
85
+ 'O PagBank não verifica o client_secret nesta consulta — só o client_id e o token. Um client_secret errado só aparece na primeira autorização de loja.',
86
+ },
72
87
  },
73
88
  };
@@ -0,0 +1,66 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ /**
6
+ * The host's realtime channel for the settings screen (FUT-648).
7
+ *
8
+ * A provider's connection moves while nobody touches this tab: the OAuth grant
9
+ * completes in the tab the owner sent to the provider, a colleague saves a key
10
+ * or reorders the chain, a webhook or a sweep settles the activation charge.
11
+ * The screen read its view once on mount and then sat on it, so all of that
12
+ * reached the owner only through a reload.
13
+ *
14
+ * Nothing here knows what the channel is — the same split as the checkout's
15
+ * `CheckoutLiveSignal`. A host that can hear "this store's payment settings
16
+ * moved" passes whether its channel is live and a way to be told when it
17
+ * fires; the screen re-reads its view on every hint and once each time the
18
+ * channel (re)opens, because a hint published while it was down reached
19
+ * nobody. The hint carries nothing and is never trusted as data: the re-read
20
+ * goes through the same client, which is the only thing that decides what the
21
+ * owner may see.
22
+ *
23
+ * There is no timer, live or not. The screen never polled, so a host without a
24
+ * channel — or one whose channel is down — keeps exactly the mount read and its
25
+ * own actions' re-reads, as before.
26
+ */
27
+ export interface PaymentsSettingsLiveSignal {
28
+ /** Whether the host's channel is connected right now. */
29
+ live: boolean;
30
+ /**
31
+ * Be told when the channel says this store's payment settings moved.
32
+ * Returns the unsubscribe. Should be stable across renders: a new function
33
+ * re-subscribes.
34
+ */
35
+ subscribe: (onHint: () => void) => () => void;
36
+ }
37
+
38
+ /**
39
+ * Re-read on every hint, and once whenever the channel goes from not-live to
40
+ * live — the first connect after mount included, since the mount read may
41
+ * have been answered before the subscription existed.
42
+ *
43
+ * `reload` is read through a ref so a host re-rendering with a new function
44
+ * neither re-subscribes nor re-reads.
45
+ */
46
+ export function useSettingsLive(
47
+ signal: PaymentsSettingsLiveSignal | undefined,
48
+ reload: () => void,
49
+ ): void {
50
+ const latest = useRef(reload);
51
+ latest.current = reload;
52
+ const live = signal?.live === true;
53
+ const wasLive = useRef(live);
54
+ const subscribe = signal?.subscribe;
55
+
56
+ useEffect(() => {
57
+ const opened = live && !wasLive.current;
58
+ wasLive.current = live;
59
+ if (opened) latest.current();
60
+ }, [live]);
61
+
62
+ useEffect(() => {
63
+ if (!subscribe) return undefined;
64
+ return subscribe(() => latest.current());
65
+ }, [subscribe]);
66
+ }
@@ -18,10 +18,11 @@
18
18
  * settings page behind.
19
19
  *
20
20
  * **`prepareConnect`, `selectedProvider` / `onProviderChange`,
21
- * `initialProvider`, `onChanged`.** The OAuth CSRF state is minted against
21
+ * `initialProvider`, `onChanged`, `live`.** The OAuth CSRF state is minted against
22
22
  * the host's admin session; which provider is open may live in the host's
23
23
  * URL. Both are the host's, per render, and a factory that froze them would
24
- * make the page uncontrollable.
24
+ * make the page uncontrollable. `live` is the host's realtime channel, whose
25
+ * status changes from render to render (FUT-648).
25
26
  */
26
27
 
27
28
  import type { ComponentType, JSX } from "react";
@@ -32,6 +33,9 @@ import {
32
33
  } from "../components/PaymentProviderSettings";
33
34
  import type { PaymentsSettingsClient } from "../client";
34
35
 
36
+ /** The `live` prop's shape — the host's channel, see `../components/settings-live`. */
37
+ export type { PaymentsSettingsLiveSignal } from "../components/settings-live";
38
+
35
39
  /** What a host binds ONCE: the settings port. */
36
40
  export interface PaymentsSettingsWebConfig {
37
41
  /**
package/src/index.ts CHANGED
@@ -323,7 +323,7 @@ export {
323
323
  type PaymentProviderSettingsProps,
324
324
  } from './components/PaymentProviderSettings';
325
325
  export { createWebPaymentsSettings } from './flows/create-payments-settings';
326
- export type { BoundPaymentsSettingsProps, PaymentsSettingsSurface, PaymentsSettingsWebConfig } from './flows/create-payments-settings';
326
+ export type { BoundPaymentsSettingsProps, PaymentsSettingsLiveSignal, PaymentsSettingsSurface, PaymentsSettingsWebConfig } from './flows/create-payments-settings';
327
327
 
328
328
  // ---------------------------------------------------------------------------
329
329
  // The PLATFORM operations screens (FUT-479 / FUT-483, packaged by FUT-573) —