@melio-eng/web-sdk 1.1.0-pr.94.438ff96 → 1.2.0-pr.93.4914b4e
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/README.md +21 -9
- package/dist/flows/Flow.d.ts +8 -3
- package/dist/flows/Flow.js +120 -57
- package/dist/flows/InitFlow.d.ts +5 -0
- package/dist/flows/InitFlow.js +116 -26
- package/dist/flows/logger.d.ts +38 -0
- package/dist/flows/logger.js +121 -0
- package/dist/index.js +75 -14
- package/dist/types.d.ts +17 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ const init = melioSDK.init("AUTH_CODE_FROM_YOUR_BACKEND", {
|
|
|
28
28
|
partnerName: "your-partner-name", // required
|
|
29
29
|
environment: "production", // optional — defaults to 'production'
|
|
30
30
|
keepAlive: true, // optional — keeps the session warm
|
|
31
|
+
debug: false, // optional — verbose console diagnostics
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
init.on("authenticationSucceeded", () => {
|
|
@@ -63,11 +64,20 @@ melioSDK.init(authenticationCode: string, options: InitOptions): InitFlowInstanc
|
|
|
63
64
|
- `options` (InitOptions, required): Configuration options
|
|
64
65
|
- `partnerName` (string, required): The partner name for the SDK instance
|
|
65
66
|
- `keepAlive` (boolean): If true, the session will be kept alive in the background via an invisible iframe
|
|
67
|
+
- `debug` (boolean): If true, the SDK prints debug-level diagnostics to the console. Warnings and errors always print
|
|
66
68
|
- `environment` (Environment): The environment to use for API endpoints (defaults to `'production'`)
|
|
67
69
|
- `branchOverride` (string): Advanced/testing only
|
|
68
70
|
|
|
69
71
|
`init` returns an `InitFlowInstance` you can listen on for `authenticationSucceeded` / `authenticationFailed`.
|
|
70
72
|
|
|
73
|
+
#### Debug logging
|
|
74
|
+
|
|
75
|
+
Every SDK console line is prefixed with `[melio-sdk]`, so you can filter it out of a busy
|
|
76
|
+
host page. Warnings and errors — a flow whose iframe never loaded, a failed authentication,
|
|
77
|
+
a `MELIO_ERROR` from the embedded app — always print. Pass `debug: true` to also get the
|
|
78
|
+
step-by-step trace: the host environment, the redacted flow URLs, iframe load timings and
|
|
79
|
+
every message received from the iframe. Credentials and customer details are redacted.
|
|
80
|
+
|
|
71
81
|
### Environment Configuration
|
|
72
82
|
|
|
73
83
|
The SDK supports multiple environments to facilitate development and testing:
|
|
@@ -178,14 +188,14 @@ flow.on("navigated", (p) => console.log("Navigated to:", p.target));
|
|
|
178
188
|
- `error`: A flow error occurred (e.g. `billsSyncFailed`)
|
|
179
189
|
- `exit`: The user exited the iframe
|
|
180
190
|
- `navigated`: Navigation occurred inside the iframe
|
|
181
|
-
- `
|
|
191
|
+
- `buttonClicked`: The user clicked a button whose destination is in your product (see below)
|
|
182
192
|
- `authenticationSucceeded` / `authenticationFailed`: emitted on the `init` instance
|
|
183
193
|
|
|
184
|
-
####
|
|
194
|
+
#### Button clicks (`buttonClicked`)
|
|
185
195
|
|
|
186
196
|
Some Melio screens contain buttons whose destination lives in **your** product, not in Melio —
|
|
187
197
|
today the "+ Quick payment" button on the payments dashboard, which should take the user to
|
|
188
|
-
your own supplier selection page. Melio cannot navigate the host page, so it
|
|
198
|
+
your own supplier selection page. Melio cannot navigate the host page, so it reports the click
|
|
189
199
|
and waits for you:
|
|
190
200
|
|
|
191
201
|
```typescript
|
|
@@ -193,19 +203,21 @@ const dashboard = melioSDK.openPaymentsDashboard({
|
|
|
193
203
|
containerId: "melio-dashboard-container",
|
|
194
204
|
});
|
|
195
205
|
|
|
196
|
-
dashboard.on("
|
|
197
|
-
if (
|
|
206
|
+
dashboard.on("buttonClicked", ({ type }) => {
|
|
207
|
+
if (type === "quickPayment") {
|
|
198
208
|
window.location.assign("/suppliers/select");
|
|
199
209
|
}
|
|
200
210
|
});
|
|
201
211
|
```
|
|
202
212
|
|
|
203
|
-
| `
|
|
213
|
+
| `type` | Emitted when | What you should do |
|
|
204
214
|
|---|---|---|
|
|
205
|
-
| `
|
|
215
|
+
| `quickPayment` | The user clicks the quick payment button on the payments dashboard | Take them to your supplier selection page, then open a pay flow |
|
|
206
216
|
|
|
207
|
-
Without a listener the button does nothing at all.
|
|
208
|
-
|
|
217
|
+
Without a listener the button does nothing at all. Only buttons whose destination is in your
|
|
218
|
+
product are reported here — buttons that end a flow keep their existing events, so "Back to
|
|
219
|
+
bills" still arrives as `completed`. Further button types ship in minor releases, so switch on
|
|
220
|
+
`type` and treat an unrecognised value as a no-op.
|
|
209
221
|
|
|
210
222
|
## 💡 Examples
|
|
211
223
|
|
package/dist/flows/Flow.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FlowInstance, FlowEventType, FlowEventCallback, FlowCompletionData, NavigationData,
|
|
1
|
+
import { FlowInstance, FlowEventType, FlowEventCallback, FlowCompletionData, NavigationData, ButtonClickedData, BaseFlowConfig, Environment, ErrorData } from '../types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Flow class implementation for handling iframe flows and events
|
|
4
4
|
*/
|
|
@@ -13,11 +13,15 @@ export declare class Flow implements FlowInstance {
|
|
|
13
13
|
private eventListeners;
|
|
14
14
|
protected keepAliveInterval: number | null;
|
|
15
15
|
private messageHandler;
|
|
16
|
+
/** When the iframe was injected, used to time load and first paint. */
|
|
17
|
+
private injectedAt;
|
|
16
18
|
constructor(containerId: string, config: BaseFlowConfig, partnerName: string, environment: Environment, branchOverride?: string | undefined);
|
|
17
19
|
/**
|
|
18
20
|
* Initialize the flow by creating and injecting the iframe
|
|
19
21
|
*/
|
|
20
22
|
initialize(): Promise<void>;
|
|
23
|
+
/** Milliseconds since the iframe was injected, when known. */
|
|
24
|
+
private sinceInject;
|
|
21
25
|
/**
|
|
22
26
|
* Construct the specific flow URL - can be overridden by subclasses
|
|
23
27
|
*/
|
|
@@ -27,13 +31,14 @@ export declare class Flow implements FlowInstance {
|
|
|
27
31
|
*/
|
|
28
32
|
protected createFlowUrl(): string;
|
|
29
33
|
private setupEventListeners;
|
|
34
|
+
private handleMessage;
|
|
30
35
|
/**
|
|
31
36
|
* Emit events to registered listeners
|
|
32
37
|
*/
|
|
33
38
|
protected emit(event: 'completed', data: FlowCompletionData): void;
|
|
34
39
|
protected emit(event: 'loaded'): void;
|
|
35
40
|
protected emit(event: 'exit'): void;
|
|
36
|
-
protected emit(event: '
|
|
41
|
+
protected emit(event: 'buttonClicked', data: ButtonClickedData): void;
|
|
37
42
|
protected emit(event: 'navigated', data: NavigationData): void;
|
|
38
43
|
protected emit(event: 'authenticationSucceeded'): void;
|
|
39
44
|
protected emit(event: 'authenticationFailed'): void;
|
|
@@ -45,7 +50,7 @@ export declare class Flow implements FlowInstance {
|
|
|
45
50
|
*/
|
|
46
51
|
on(event: 'completed', callback: (data: FlowCompletionData) => void): void;
|
|
47
52
|
on(event: 'exit', callback: () => void): void;
|
|
48
|
-
on(event: '
|
|
53
|
+
on(event: 'buttonClicked', callback: (data: ButtonClickedData) => void): void;
|
|
49
54
|
on(event: 'navigated', callback: (payload: NavigationData) => void): void;
|
|
50
55
|
on(event: 'authenticationSucceeded', callback: () => void): void;
|
|
51
56
|
on(event: 'authenticationFailed', callback: () => void): void;
|
package/dist/flows/Flow.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getBaseUrl } from './utils.js';
|
|
2
|
+
import { logger, describeError, describeConfig, redactUrl } from './logger.js';
|
|
2
3
|
/**
|
|
3
4
|
* Flow class implementation for handling iframe flows and events
|
|
4
5
|
*/
|
|
@@ -14,6 +15,8 @@ export class Flow {
|
|
|
14
15
|
this.eventListeners = new Map();
|
|
15
16
|
this.keepAliveInterval = null;
|
|
16
17
|
this.messageHandler = null;
|
|
18
|
+
/** When the iframe was injected, used to time load and first paint. */
|
|
19
|
+
this.injectedAt = null;
|
|
17
20
|
this.setupEventListeners();
|
|
18
21
|
}
|
|
19
22
|
/**
|
|
@@ -22,16 +25,44 @@ export class Flow {
|
|
|
22
25
|
async initialize() {
|
|
23
26
|
this.container = document.getElementById(this.containerId);
|
|
24
27
|
if (!this.container) {
|
|
28
|
+
logger.error(`Container with ID "${this.containerId}" not found — the flow cannot be injected`, { readyState: document.readyState });
|
|
25
29
|
throw new Error(`Container with ID "${this.containerId}" not found`);
|
|
26
30
|
}
|
|
31
|
+
const url = this.createFlowUrl();
|
|
32
|
+
const safeUrl = redactUrl(url);
|
|
27
33
|
this.iframe = document.createElement('iframe');
|
|
28
|
-
this.iframe.src =
|
|
34
|
+
this.iframe.src = url;
|
|
29
35
|
this.iframe.style.width = '100%';
|
|
30
36
|
this.iframe.style.height = '1000px';
|
|
31
37
|
this.iframe.style.border = 'none';
|
|
32
38
|
this.iframe.style.display = 'block';
|
|
39
|
+
// A cross-origin document that never loads is otherwise silent: no
|
|
40
|
+
// exception, no message, nothing in the host console.
|
|
41
|
+
this.iframe.addEventListener('error', () => {
|
|
42
|
+
logger.error('Flow iframe failed to load', {
|
|
43
|
+
url: safeUrl,
|
|
44
|
+
...this.sinceInject(),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
this.iframe.addEventListener('load', () => {
|
|
48
|
+
logger.debug('Flow iframe loaded', {
|
|
49
|
+
url: safeUrl,
|
|
50
|
+
...this.sinceInject(),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
logger.debug('Injecting flow iframe', {
|
|
54
|
+
url: safeUrl,
|
|
55
|
+
containerId: this.containerId,
|
|
56
|
+
});
|
|
57
|
+
this.injectedAt = Date.now();
|
|
33
58
|
this.container.appendChild(this.iframe);
|
|
34
59
|
}
|
|
60
|
+
/** Milliseconds since the iframe was injected, when known. */
|
|
61
|
+
sinceInject() {
|
|
62
|
+
return this.injectedAt === null
|
|
63
|
+
? {}
|
|
64
|
+
: { msSinceInject: Date.now() - this.injectedAt };
|
|
65
|
+
}
|
|
35
66
|
/**
|
|
36
67
|
* Construct the specific flow URL - can be overridden by subclasses
|
|
37
68
|
*/
|
|
@@ -42,11 +73,6 @@ export class Flow {
|
|
|
42
73
|
* Create flow URL using partner name and environment
|
|
43
74
|
*/
|
|
44
75
|
createFlowUrl() {
|
|
45
|
-
console.log('🔧 Creating flow URL...');
|
|
46
|
-
console.log('📝 Config:', this.config);
|
|
47
|
-
console.log('🏢 Partner:', this.partnerName);
|
|
48
|
-
console.log('🌍 Environment:', this.environment);
|
|
49
|
-
console.log('🔝 Branch Override:', this.branchOverride);
|
|
50
76
|
const baseUrl = getBaseUrl(this.environment);
|
|
51
77
|
let finalUrl = this.constructFlowUrl(baseUrl);
|
|
52
78
|
// Add cdn_branch_override parameter for non-production environments
|
|
@@ -54,67 +80,103 @@ export class Flow {
|
|
|
54
80
|
const separator = finalUrl.includes('?') ? '&' : '?';
|
|
55
81
|
finalUrl += `${separator}cdn_branch_override=${this.branchOverride}`;
|
|
56
82
|
}
|
|
57
|
-
|
|
83
|
+
logger.debug('Created flow URL', {
|
|
84
|
+
url: redactUrl(finalUrl),
|
|
85
|
+
partnerName: this.partnerName,
|
|
86
|
+
environment: this.environment,
|
|
87
|
+
branchOverride: this.branchOverride,
|
|
88
|
+
config: describeConfig(this.config),
|
|
89
|
+
});
|
|
58
90
|
return finalUrl;
|
|
59
91
|
}
|
|
60
92
|
setupEventListeners() {
|
|
61
93
|
// Add post message handlers for internal events - setHeight, scroll etc
|
|
62
94
|
// Also need to implement callbacks for flow completed exit etc in the platform-app
|
|
63
95
|
this.messageHandler = (event) => {
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
// A throw here is otherwise swallowed by the browser's event dispatch,
|
|
97
|
+
// so the flow would stall with nothing in the console. Rethrown to keep
|
|
98
|
+
// the existing behaviour: the error still escapes to the host page.
|
|
99
|
+
try {
|
|
100
|
+
this.handleMessage(event);
|
|
66
101
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
data,
|
|
71
|
-
origin: event.origin,
|
|
72
|
-
});
|
|
73
|
-
switch (type) {
|
|
74
|
-
case 'FLOW_COMPLETED':
|
|
75
|
-
this.emit('completed', data);
|
|
76
|
-
break;
|
|
77
|
-
case 'FLOW_EXIT':
|
|
78
|
-
this.emit('exit');
|
|
79
|
-
break;
|
|
80
|
-
case 'PARTNER_ACTION_REQUIRED':
|
|
81
|
-
// Only the new payment request is routed today; every other
|
|
82
|
-
// requiredAction is ignored until the SDK declares it.
|
|
83
|
-
if (data.data?.requiredAction === 'NAVIGATE_TO_NEW_PAYMENT') {
|
|
84
|
-
this.emit('partnerActionRequired', {
|
|
85
|
-
requiredAction: 'NAVIGATE_TO_NEW_PAYMENT',
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
break;
|
|
89
|
-
case 'NAVIGATED_TO_TARGET':
|
|
90
|
-
this.emit('navigated', data);
|
|
91
|
-
break;
|
|
92
|
-
case 'ONBOARDING_FORM_COMPLETED':
|
|
93
|
-
this.emit('onboardingCompleted');
|
|
94
|
-
break;
|
|
95
|
-
case 'ONBOARDING_REQUIRED':
|
|
96
|
-
this.emit('onboardingRequired');
|
|
97
|
-
break;
|
|
98
|
-
case 'READY_FOR_INTERACTION':
|
|
99
|
-
this.emit('loaded');
|
|
100
|
-
break;
|
|
101
|
-
case 'PAYMENT_SCHEDULED':
|
|
102
|
-
this.emit('completed', { flowName: 'payment', ...data });
|
|
103
|
-
break;
|
|
104
|
-
case 'MELIO_ERROR':
|
|
105
|
-
if (data.code === 'failed_to_sync_bills') {
|
|
106
|
-
this.emit('error', { errorCode: 'billsSyncFailed' });
|
|
107
|
-
}
|
|
108
|
-
break;
|
|
109
|
-
case 'HEIGHT_CHANGE':
|
|
110
|
-
if (this.iframe) {
|
|
111
|
-
this.iframe.style.height = `${data.height}px`;
|
|
112
|
-
}
|
|
113
|
-
break;
|
|
102
|
+
catch (error) {
|
|
103
|
+
logger.error('Failed to handle a message from the flow iframe', describeError(error));
|
|
104
|
+
throw error;
|
|
114
105
|
}
|
|
115
106
|
};
|
|
116
107
|
window.addEventListener('message', this.messageHandler);
|
|
117
108
|
}
|
|
109
|
+
handleMessage(event) {
|
|
110
|
+
if (!/melio\.com|melioservices\.com/.test(event.origin)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (typeof event.data !== 'object' || event.data === null) {
|
|
114
|
+
logger.warn('Ignoring a non-object message from a melio origin', {
|
|
115
|
+
origin: event.origin,
|
|
116
|
+
dataType: typeof event.data,
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const { type, ...data } = event.data;
|
|
121
|
+
logger.debug('Received message from flow iframe', {
|
|
122
|
+
type,
|
|
123
|
+
data,
|
|
124
|
+
origin: event.origin,
|
|
125
|
+
...this.sinceInject(),
|
|
126
|
+
});
|
|
127
|
+
switch (type) {
|
|
128
|
+
case 'FLOW_COMPLETED':
|
|
129
|
+
this.emit('completed', data);
|
|
130
|
+
break;
|
|
131
|
+
case 'FLOW_EXIT':
|
|
132
|
+
this.emit('exit');
|
|
133
|
+
break;
|
|
134
|
+
case 'PARTNER_ACTION_REQUIRED':
|
|
135
|
+
if (data.data?.requiredAction === 'NAVIGATE_TO_NEW_PAYMENT') {
|
|
136
|
+
this.emit('buttonClicked', { type: 'quickPayment' });
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
logger.debug('PARTNER_ACTION_REQUIRED has no matching action and was not forwarded', { requiredAction: data.data?.requiredAction });
|
|
140
|
+
}
|
|
141
|
+
break;
|
|
142
|
+
case 'NAVIGATED_TO_TARGET':
|
|
143
|
+
this.emit('navigated', data);
|
|
144
|
+
break;
|
|
145
|
+
case 'ONBOARDING_FORM_COMPLETED':
|
|
146
|
+
this.emit('onboardingCompleted');
|
|
147
|
+
break;
|
|
148
|
+
case 'ONBOARDING_REQUIRED':
|
|
149
|
+
this.emit('onboardingRequired');
|
|
150
|
+
break;
|
|
151
|
+
case 'READY_FOR_INTERACTION':
|
|
152
|
+
logger.debug('Flow is ready for interaction', this.sinceInject());
|
|
153
|
+
this.emit('loaded');
|
|
154
|
+
break;
|
|
155
|
+
case 'PAYMENT_SCHEDULED':
|
|
156
|
+
this.emit('completed', { flowName: 'payment', ...data });
|
|
157
|
+
break;
|
|
158
|
+
case 'MELIO_ERROR':
|
|
159
|
+
// Only one code maps to a host-facing event, so an unmapped code would
|
|
160
|
+
// otherwise be dropped silently.
|
|
161
|
+
logger.error('Embedded app reported MELIO_ERROR', {
|
|
162
|
+
...data,
|
|
163
|
+
forwardedToHost: data.code === 'failed_to_sync_bills',
|
|
164
|
+
});
|
|
165
|
+
if (data.code === 'failed_to_sync_bills') {
|
|
166
|
+
this.emit('error', { errorCode: 'billsSyncFailed' });
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
case 'HEIGHT_CHANGE':
|
|
170
|
+
if (this.iframe) {
|
|
171
|
+
this.iframe.style.height = `${data.height}px`;
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
default:
|
|
175
|
+
logger.debug('Flow iframe message type is not handled by the SDK', {
|
|
176
|
+
type,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
118
180
|
emit(event, data) {
|
|
119
181
|
const listeners = this.eventListeners.get(event);
|
|
120
182
|
if (listeners) {
|
|
@@ -123,7 +185,7 @@ export class Flow {
|
|
|
123
185
|
callback(data);
|
|
124
186
|
}
|
|
125
187
|
catch (error) {
|
|
126
|
-
|
|
188
|
+
logger.error(`A host listener for "${event}" threw`, describeError(error));
|
|
127
189
|
}
|
|
128
190
|
});
|
|
129
191
|
}
|
|
@@ -148,6 +210,7 @@ export class Flow {
|
|
|
148
210
|
* Close the flow and clean up resources
|
|
149
211
|
*/
|
|
150
212
|
close() {
|
|
213
|
+
logger.debug('Closing the flow', { containerId: this.containerId });
|
|
151
214
|
if (this.keepAliveInterval) {
|
|
152
215
|
clearInterval(this.keepAliveInterval);
|
|
153
216
|
this.keepAliveInterval = null;
|
package/dist/flows/InitFlow.d.ts
CHANGED
|
@@ -13,7 +13,11 @@ export declare class InitFlow implements InitFlowInstance {
|
|
|
13
13
|
private partnerName;
|
|
14
14
|
private environment;
|
|
15
15
|
private branchOverride?;
|
|
16
|
+
/** When the hidden iframe was injected, used to time the auth round trip. */
|
|
17
|
+
private injectedAt;
|
|
16
18
|
constructor(config: InitConfig, partnerName: string, environment: Environment, branchOverride?: string);
|
|
19
|
+
/** Milliseconds since the hidden iframe was injected, when known. */
|
|
20
|
+
private sinceInject;
|
|
17
21
|
/**
|
|
18
22
|
* Initialize the init flow by creating and injecting a hidden iframe
|
|
19
23
|
*/
|
|
@@ -30,6 +34,7 @@ export declare class InitFlow implements InitFlowInstance {
|
|
|
30
34
|
* Setup event listeners to only handle authentication events
|
|
31
35
|
*/
|
|
32
36
|
private setupEventListeners;
|
|
37
|
+
private handleMessage;
|
|
33
38
|
/**
|
|
34
39
|
* Emit events to registered listeners
|
|
35
40
|
*/
|
package/dist/flows/InitFlow.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isEmptyString, getBaseUrl } from './utils.js';
|
|
2
|
+
import { logger, describeError, redactUrl } from './logger.js';
|
|
2
3
|
/**
|
|
3
4
|
* Standalone InitFlow class for handling initialization with callbacks
|
|
4
5
|
*/
|
|
@@ -9,7 +10,10 @@ export class InitFlow {
|
|
|
9
10
|
this.eventListeners = new Map();
|
|
10
11
|
this.keepAliveInterval = null;
|
|
11
12
|
this.messageHandler = null;
|
|
13
|
+
/** When the hidden iframe was injected, used to time the auth round trip. */
|
|
14
|
+
this.injectedAt = null;
|
|
12
15
|
if (isEmptyString(config.authCode)) {
|
|
16
|
+
logger.error('Authorization code is required for init flow');
|
|
13
17
|
throw new Error('Authorization code is required for init flow');
|
|
14
18
|
}
|
|
15
19
|
this.authorizationCode = config.authCode;
|
|
@@ -19,19 +23,63 @@ export class InitFlow {
|
|
|
19
23
|
this.branchOverride = branchOverride;
|
|
20
24
|
this.setupEventListeners();
|
|
21
25
|
}
|
|
26
|
+
/** Milliseconds since the hidden iframe was injected, when known. */
|
|
27
|
+
sinceInject() {
|
|
28
|
+
return this.injectedAt === null
|
|
29
|
+
? {}
|
|
30
|
+
: { msSinceInject: Date.now() - this.injectedAt };
|
|
31
|
+
}
|
|
22
32
|
/**
|
|
23
33
|
* Initialize the init flow by creating and injecting a hidden iframe
|
|
24
34
|
*/
|
|
25
35
|
async initialize() {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
let safeUrl = '<url not yet built>';
|
|
37
|
+
try {
|
|
38
|
+
this.container = document.body;
|
|
39
|
+
// init() called from <head> before the parser reached <body> leaves nothing
|
|
40
|
+
// to inject into, and the flow then fails with no other symptom.
|
|
41
|
+
if (!this.container) {
|
|
42
|
+
logger.error('Cannot inject the auth iframe: document.body does not exist yet. Call init() after the document has a body.', { readyState: document.readyState });
|
|
43
|
+
throw new Error('document.body is not available');
|
|
44
|
+
}
|
|
45
|
+
const url = this.createFlowUrl();
|
|
46
|
+
safeUrl = redactUrl(url);
|
|
47
|
+
this.iframe = document.createElement('iframe');
|
|
48
|
+
this.iframe.src = url;
|
|
49
|
+
this.iframe.style.width = '1px';
|
|
50
|
+
this.iframe.style.height = '1px';
|
|
51
|
+
this.iframe.style.position = 'absolute';
|
|
52
|
+
this.iframe.style.left = '-9999px';
|
|
53
|
+
this.iframe.style.top = '-9999px';
|
|
54
|
+
// A cross-origin auth document that never loads is otherwise silent: no
|
|
55
|
+
// exception, no message, nothing in the host console.
|
|
56
|
+
this.iframe.addEventListener('error', () => {
|
|
57
|
+
logger.error('Auth iframe failed to load', {
|
|
58
|
+
url: safeUrl,
|
|
59
|
+
...this.sinceInject(),
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
this.iframe.addEventListener('load', () => {
|
|
63
|
+
logger.debug('Auth iframe loaded', {
|
|
64
|
+
url: safeUrl,
|
|
65
|
+
...this.sinceInject(),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
logger.debug('Injecting auth iframe', {
|
|
69
|
+
url: safeUrl,
|
|
70
|
+
environment: this.environment,
|
|
71
|
+
partnerName: this.partnerName,
|
|
72
|
+
});
|
|
73
|
+
this.injectedAt = Date.now();
|
|
74
|
+
this.container.appendChild(this.iframe);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
logger.error('Failed to initialize the auth flow', {
|
|
78
|
+
url: safeUrl,
|
|
79
|
+
...describeError(error),
|
|
80
|
+
});
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
35
83
|
}
|
|
36
84
|
/**
|
|
37
85
|
* Construct the specific flow URL for initialization
|
|
@@ -61,26 +109,55 @@ export class InitFlow {
|
|
|
61
109
|
*/
|
|
62
110
|
setupEventListeners() {
|
|
63
111
|
this.messageHandler = (event) => {
|
|
64
|
-
|
|
65
|
-
|
|
112
|
+
// A throw here is otherwise swallowed by the browser's event dispatch,
|
|
113
|
+
// so the flow would stall with nothing in the console. Rethrown to keep
|
|
114
|
+
// the existing behaviour: the error still escapes to the host page.
|
|
115
|
+
try {
|
|
116
|
+
this.handleMessage(event);
|
|
66
117
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
data,
|
|
71
|
-
origin: event.origin,
|
|
72
|
-
});
|
|
73
|
-
switch (type) {
|
|
74
|
-
case 'AUTHENTICATION_SUCCESS':
|
|
75
|
-
this.emit('authenticationSucceeded');
|
|
76
|
-
break;
|
|
77
|
-
case 'AUTHENTICATION_ERROR':
|
|
78
|
-
this.emit('authenticationFailed');
|
|
79
|
-
break;
|
|
118
|
+
catch (error) {
|
|
119
|
+
logger.error('Failed to handle a message from the auth iframe', describeError(error));
|
|
120
|
+
throw error;
|
|
80
121
|
}
|
|
81
122
|
};
|
|
82
123
|
window.addEventListener('message', this.messageHandler);
|
|
83
124
|
}
|
|
125
|
+
handleMessage(event) {
|
|
126
|
+
if (!/melio\.com|melioservices\.com/.test(event.origin)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (typeof event.data !== 'object' || event.data === null) {
|
|
130
|
+
logger.warn('Ignoring a non-object message from a melio origin', {
|
|
131
|
+
origin: event.origin,
|
|
132
|
+
dataType: typeof event.data,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const { type, ...data } = event.data;
|
|
137
|
+
logger.debug('Received message from auth iframe', {
|
|
138
|
+
type,
|
|
139
|
+
data,
|
|
140
|
+
origin: event.origin,
|
|
141
|
+
...this.sinceInject(),
|
|
142
|
+
});
|
|
143
|
+
switch (type) {
|
|
144
|
+
case 'AUTHENTICATION_SUCCESS':
|
|
145
|
+
logger.debug('Authentication succeeded', this.sinceInject());
|
|
146
|
+
this.emit('authenticationSucceeded');
|
|
147
|
+
break;
|
|
148
|
+
case 'AUTHENTICATION_ERROR':
|
|
149
|
+
logger.error('Authentication failed', {
|
|
150
|
+
...data,
|
|
151
|
+
...this.sinceInject(),
|
|
152
|
+
});
|
|
153
|
+
this.emit('authenticationFailed');
|
|
154
|
+
break;
|
|
155
|
+
default:
|
|
156
|
+
logger.debug('Auth iframe message type is not handled by the SDK', {
|
|
157
|
+
type,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
84
161
|
/**
|
|
85
162
|
* Emit events to registered listeners
|
|
86
163
|
*/
|
|
@@ -93,7 +170,7 @@ export class InitFlow {
|
|
|
93
170
|
callback(data);
|
|
94
171
|
}
|
|
95
172
|
catch (error) {
|
|
96
|
-
|
|
173
|
+
logger.error(`A host listener for "${event}" threw`, describeError(error));
|
|
97
174
|
}
|
|
98
175
|
});
|
|
99
176
|
}
|
|
@@ -121,6 +198,7 @@ export class InitFlow {
|
|
|
121
198
|
* Close the flow and clean up resources
|
|
122
199
|
*/
|
|
123
200
|
close() {
|
|
201
|
+
logger.debug('Closing the auth flow');
|
|
124
202
|
if (this.keepAliveInterval) {
|
|
125
203
|
clearInterval(this.keepAliveInterval);
|
|
126
204
|
this.keepAliveInterval = null;
|
|
@@ -137,8 +215,20 @@ export class InitFlow {
|
|
|
137
215
|
}
|
|
138
216
|
setupKeepAlive() {
|
|
139
217
|
this.keepAliveInterval = window.setInterval(() => {
|
|
218
|
+
// The ping doubles as a liveness heartbeat: if these lines stop appearing
|
|
219
|
+
// while the page is still open, the host page stopped executing.
|
|
140
220
|
if (this.iframe && this.iframe.contentWindow) {
|
|
141
|
-
|
|
221
|
+
try {
|
|
222
|
+
this.iframe.contentWindow.postMessage({ type: 'USER_ACTIVE_PING' }, '*');
|
|
223
|
+
logger.debug('Keep-alive ping sent');
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
logger.error('Failed to send a keep-alive ping', describeError(error));
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
logger.warn('Skipping the keep-alive ping: the auth iframe is gone or has no contentWindow');
|
|
142
232
|
}
|
|
143
233
|
}, 30000); // Send ping every 30 seconds
|
|
144
234
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight console logger for the SDK.
|
|
3
|
+
*
|
|
4
|
+
* Every line is prefixed with `[melio-sdk]` so partners can filter SDK output out
|
|
5
|
+
* of a busy host page.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Severity levels, lowest first. `debug` is opt-in via `init({ debug: true })`;
|
|
9
|
+
* `warn` and `error` always print, because a partner debugging a broken flow
|
|
10
|
+
* should not have to enable anything to see that something failed.
|
|
11
|
+
*/
|
|
12
|
+
export type LogLevel = 'debug' | 'warn' | 'error';
|
|
13
|
+
/** Enable or disable debug-level output. Called from `init({ debug })`. */
|
|
14
|
+
export declare const setDebugEnabled: (enabled: boolean) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Strip credential-bearing query params so a flow URL can be logged safely.
|
|
17
|
+
* On a URL we cannot parse we fall back to the path, never the raw string.
|
|
18
|
+
*/
|
|
19
|
+
export declare const redactUrl: (url: string) => string;
|
|
20
|
+
/** Flatten an unknown throwable into something that survives a console capture. */
|
|
21
|
+
export declare const describeError: (error: unknown) => Record<string, unknown>;
|
|
22
|
+
/**
|
|
23
|
+
* Shallow-copy a config with credentials and PII redacted, so it can be logged
|
|
24
|
+
* without printing customer details into the partner's console.
|
|
25
|
+
*/
|
|
26
|
+
export declare const describeConfig: (config: object | undefined) => Record<string, unknown>;
|
|
27
|
+
/**
|
|
28
|
+
* Describe the host environment once at init. The Safari regression on Xero's
|
|
29
|
+
* Bills page only reproduced on specific browser builds, and whether we run
|
|
30
|
+
* inside a frame decides which failure modes are possible — so both belong in
|
|
31
|
+
* any console capture we are asked to read.
|
|
32
|
+
*/
|
|
33
|
+
export declare const describeHost: () => Record<string, unknown>;
|
|
34
|
+
export declare const logger: {
|
|
35
|
+
debug(message: string, context?: Record<string, unknown>): void;
|
|
36
|
+
warn(message: string, context?: Record<string, unknown>): void;
|
|
37
|
+
error(message: string, context?: Record<string, unknown>): void;
|
|
38
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight console logger for the SDK.
|
|
3
|
+
*
|
|
4
|
+
* Every line is prefixed with `[melio-sdk]` so partners can filter SDK output out
|
|
5
|
+
* of a busy host page.
|
|
6
|
+
*/
|
|
7
|
+
const PREFIX = '[melio-sdk]';
|
|
8
|
+
/** Query params that may carry a credential and must never reach the console. */
|
|
9
|
+
const REDACTED_PARAMS = ['token', 'authCode', 'code'];
|
|
10
|
+
/**
|
|
11
|
+
* Config keys holding a credential or customer PII. Their presence is worth
|
|
12
|
+
* logging, their contents are not — this SDK runs inside a partner's page.
|
|
13
|
+
*
|
|
14
|
+
* `authCode` matters most: it is on both `InitConfig` and `BaseFlowConfig`, so
|
|
15
|
+
* logging a config verbatim prints a live auth token to the console.
|
|
16
|
+
*/
|
|
17
|
+
const REDACTED_CONFIG_KEYS = [
|
|
18
|
+
'authCode',
|
|
19
|
+
'token',
|
|
20
|
+
'code',
|
|
21
|
+
'userDetails',
|
|
22
|
+
'organizationDetails',
|
|
23
|
+
];
|
|
24
|
+
const LEVEL_WEIGHT = {
|
|
25
|
+
debug: 10,
|
|
26
|
+
warn: 20,
|
|
27
|
+
error: 30,
|
|
28
|
+
};
|
|
29
|
+
let minimumWeight = LEVEL_WEIGHT.warn;
|
|
30
|
+
/** Enable or disable debug-level output. Called from `init({ debug })`. */
|
|
31
|
+
export const setDebugEnabled = (enabled) => {
|
|
32
|
+
minimumWeight = enabled ? LEVEL_WEIGHT.debug : LEVEL_WEIGHT.warn;
|
|
33
|
+
};
|
|
34
|
+
const shouldLog = (level) => LEVEL_WEIGHT[level] >= minimumWeight;
|
|
35
|
+
/**
|
|
36
|
+
* Strip credential-bearing query params so a flow URL can be logged safely.
|
|
37
|
+
* On a URL we cannot parse we fall back to the path, never the raw string.
|
|
38
|
+
*/
|
|
39
|
+
export const redactUrl = (url) => {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = new URL(url);
|
|
42
|
+
REDACTED_PARAMS.forEach((param) => {
|
|
43
|
+
if (parsed.searchParams.has(param)) {
|
|
44
|
+
parsed.searchParams.set(param, 'redacted');
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return parsed.toString();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return `${url.split('?')[0]}?[unparsed-query-redacted]`;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
/** Flatten an unknown throwable into something that survives a console capture. */
|
|
54
|
+
export const describeError = (error) => {
|
|
55
|
+
if (error instanceof Error) {
|
|
56
|
+
return { name: error.name, message: error.message, stack: error.stack };
|
|
57
|
+
}
|
|
58
|
+
return { thrown: String(error) };
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Shallow-copy a config with credentials and PII redacted, so it can be logged
|
|
62
|
+
* without printing customer details into the partner's console.
|
|
63
|
+
*/
|
|
64
|
+
export const describeConfig = (config) => {
|
|
65
|
+
if (!config) {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
return Object.fromEntries(Object.entries(config).map(([key, value]) => [
|
|
69
|
+
key,
|
|
70
|
+
REDACTED_CONFIG_KEYS.includes(key) && value !== undefined
|
|
71
|
+
? '[redacted]'
|
|
72
|
+
: value,
|
|
73
|
+
]));
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Describe the host environment once at init. The Safari regression on Xero's
|
|
77
|
+
* Bills page only reproduced on specific browser builds, and whether we run
|
|
78
|
+
* inside a frame decides which failure modes are possible — so both belong in
|
|
79
|
+
* any console capture we are asked to read.
|
|
80
|
+
*/
|
|
81
|
+
export const describeHost = () => {
|
|
82
|
+
const host = {};
|
|
83
|
+
if (typeof navigator !== 'undefined') {
|
|
84
|
+
host.userAgent = navigator.userAgent;
|
|
85
|
+
}
|
|
86
|
+
if (typeof document !== 'undefined') {
|
|
87
|
+
host.readyState = document.readyState;
|
|
88
|
+
host.visibilityState = document.visibilityState;
|
|
89
|
+
}
|
|
90
|
+
if (typeof window !== 'undefined') {
|
|
91
|
+
try {
|
|
92
|
+
host.isFramed = window.top !== window.self;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Cross-origin parents throw on access, which itself proves we are framed.
|
|
96
|
+
host.isFramed = true;
|
|
97
|
+
}
|
|
98
|
+
host.origin = window.location?.origin;
|
|
99
|
+
}
|
|
100
|
+
return host;
|
|
101
|
+
};
|
|
102
|
+
export const logger = {
|
|
103
|
+
debug(message, context) {
|
|
104
|
+
if (!shouldLog('debug')) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
console.log(`${PREFIX} ${message}`, context ?? {});
|
|
108
|
+
},
|
|
109
|
+
warn(message, context) {
|
|
110
|
+
if (!shouldLog('warn')) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
console.warn(`${PREFIX} ${message}`, context ?? {});
|
|
114
|
+
},
|
|
115
|
+
error(message, context) {
|
|
116
|
+
if (!shouldLog('error')) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
console.error(`${PREFIX} ${message}`, context ?? {});
|
|
120
|
+
},
|
|
121
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { logger, setDebugEnabled, describeError, describeConfig, describeHost, } from './flows/logger.js';
|
|
1
2
|
import { InitFlow, OnboardingFlow, PayFlow, JustPayFlow, SettingsFlow, PaymentsDashboardFlow, isEmptyString, } from './flows/index.js';
|
|
2
3
|
/**
|
|
3
4
|
* Main SDK implementation - now partner agnostic
|
|
@@ -15,21 +16,31 @@ export class MelioSDK {
|
|
|
15
16
|
* Initialize the SDK - triggers auth event without creating iframe
|
|
16
17
|
*/
|
|
17
18
|
init(authenticationCode, options) {
|
|
19
|
+
// Set before anything else so every line below honours the flag.
|
|
20
|
+
setDebugEnabled(Boolean(options.debug));
|
|
18
21
|
if (this.initFlow) {
|
|
19
|
-
|
|
22
|
+
logger.warn('SDK already initialized. Returning existing init flow instance.');
|
|
20
23
|
return this.initFlow;
|
|
21
24
|
}
|
|
22
25
|
this.partnerName = options.partnerName;
|
|
23
26
|
this.environment = options.environment || 'production';
|
|
24
27
|
this.branchOverride = options.branchOverride;
|
|
25
28
|
this.authCode = authenticationCode;
|
|
26
|
-
|
|
29
|
+
logger.debug('Starting init flow', {
|
|
27
30
|
partnerName: this.partnerName,
|
|
28
31
|
environment: this.environment,
|
|
32
|
+
branchOverride: this.branchOverride,
|
|
33
|
+
keepAlive: Boolean(options.keepAlive),
|
|
34
|
+
...describeHost(),
|
|
29
35
|
});
|
|
30
36
|
const initFlow = new InitFlow({ authCode: authenticationCode, containerId: '' }, this.partnerName, this.environment, this.branchOverride);
|
|
31
|
-
|
|
32
|
-
|
|
37
|
+
// Nothing awaits this promise, so without the catch an injection failure
|
|
38
|
+
// would leave no trace at all. Rethrown so it still surfaces as an
|
|
39
|
+
// unhandled rejection, exactly as before.
|
|
40
|
+
initFlow.initialize().catch((error) => {
|
|
41
|
+
logger.error('Init flow failed to initialize', describeError(error));
|
|
42
|
+
throw error;
|
|
43
|
+
});
|
|
33
44
|
if (options.keepAlive)
|
|
34
45
|
initFlow.setupKeepAlive();
|
|
35
46
|
this.initFlow = initFlow;
|
|
@@ -40,10 +51,20 @@ export class MelioSDK {
|
|
|
40
51
|
*/
|
|
41
52
|
openOnboarding(config) {
|
|
42
53
|
if (isEmptyString(this.authCode)) {
|
|
43
|
-
|
|
54
|
+
const message = 'SDK not initialized. Please call init() before opening onboarding flow.';
|
|
55
|
+
logger.error(message);
|
|
56
|
+
throw new Error(message);
|
|
44
57
|
}
|
|
58
|
+
logger.debug('Opening onboarding flow', {
|
|
59
|
+
partnerName: this.partnerName,
|
|
60
|
+
environment: this.environment,
|
|
61
|
+
config: describeConfig(config),
|
|
62
|
+
});
|
|
45
63
|
const flow = new OnboardingFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
|
|
46
|
-
flow.initialize()
|
|
64
|
+
flow.initialize().catch((error) => {
|
|
65
|
+
logger.error('Onboarding flow failed to initialize', describeError(error));
|
|
66
|
+
throw error;
|
|
67
|
+
});
|
|
47
68
|
return flow;
|
|
48
69
|
}
|
|
49
70
|
/**
|
|
@@ -51,10 +72,20 @@ export class MelioSDK {
|
|
|
51
72
|
*/
|
|
52
73
|
openPayFlow(config) {
|
|
53
74
|
if (isEmptyString(this.authCode)) {
|
|
54
|
-
|
|
75
|
+
const message = 'SDK not initialized. Please call init() before opening pay flow.';
|
|
76
|
+
logger.error(message);
|
|
77
|
+
throw new Error(message);
|
|
55
78
|
}
|
|
79
|
+
logger.debug('Opening pay flow', {
|
|
80
|
+
partnerName: this.partnerName,
|
|
81
|
+
environment: this.environment,
|
|
82
|
+
config: describeConfig(config),
|
|
83
|
+
});
|
|
56
84
|
const flow = new PayFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
|
|
57
|
-
flow.initialize()
|
|
85
|
+
flow.initialize().catch((error) => {
|
|
86
|
+
logger.error('Pay flow failed to initialize', describeError(error));
|
|
87
|
+
throw error;
|
|
88
|
+
});
|
|
58
89
|
return flow;
|
|
59
90
|
}
|
|
60
91
|
/**
|
|
@@ -62,10 +93,20 @@ export class MelioSDK {
|
|
|
62
93
|
*/
|
|
63
94
|
openJustPayFlow(config) {
|
|
64
95
|
if (isEmptyString(this.authCode)) {
|
|
65
|
-
|
|
96
|
+
const message = 'SDK not initialized. Please call init() before opening just pay flow.';
|
|
97
|
+
logger.error(message);
|
|
98
|
+
throw new Error(message);
|
|
66
99
|
}
|
|
100
|
+
logger.debug('Opening just pay flow', {
|
|
101
|
+
partnerName: this.partnerName,
|
|
102
|
+
environment: this.environment,
|
|
103
|
+
config: describeConfig(config),
|
|
104
|
+
});
|
|
67
105
|
const flow = new JustPayFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
|
|
68
|
-
flow.initialize()
|
|
106
|
+
flow.initialize().catch((error) => {
|
|
107
|
+
logger.error('Just pay flow failed to initialize', describeError(error));
|
|
108
|
+
throw error;
|
|
109
|
+
});
|
|
69
110
|
return flow;
|
|
70
111
|
}
|
|
71
112
|
/**
|
|
@@ -73,10 +114,20 @@ export class MelioSDK {
|
|
|
73
114
|
*/
|
|
74
115
|
openSettings(config) {
|
|
75
116
|
if (isEmptyString(this.authCode)) {
|
|
76
|
-
|
|
117
|
+
const message = 'SDK not initialized. Please call init() before opening settings flow.';
|
|
118
|
+
logger.error(message);
|
|
119
|
+
throw new Error(message);
|
|
77
120
|
}
|
|
121
|
+
logger.debug('Opening settings flow', {
|
|
122
|
+
partnerName: this.partnerName,
|
|
123
|
+
environment: this.environment,
|
|
124
|
+
config: describeConfig(config),
|
|
125
|
+
});
|
|
78
126
|
const flow = new SettingsFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
|
|
79
|
-
flow.initialize()
|
|
127
|
+
flow.initialize().catch((error) => {
|
|
128
|
+
logger.error('Settings flow failed to initialize', describeError(error));
|
|
129
|
+
throw error;
|
|
130
|
+
});
|
|
80
131
|
return flow;
|
|
81
132
|
}
|
|
82
133
|
/**
|
|
@@ -84,10 +135,20 @@ export class MelioSDK {
|
|
|
84
135
|
*/
|
|
85
136
|
openPaymentsDashboard(config) {
|
|
86
137
|
if (isEmptyString(this.authCode)) {
|
|
87
|
-
|
|
138
|
+
const message = 'SDK not initialized. Please call init() before opening payments dashboard flow.';
|
|
139
|
+
logger.error(message);
|
|
140
|
+
throw new Error(message);
|
|
88
141
|
}
|
|
142
|
+
logger.debug('Opening payments dashboard flow', {
|
|
143
|
+
partnerName: this.partnerName,
|
|
144
|
+
environment: this.environment,
|
|
145
|
+
config: describeConfig(config),
|
|
146
|
+
});
|
|
89
147
|
const flow = new PaymentsDashboardFlow(config.containerId, config, this.partnerName, this.environment, this.authCode, this.branchOverride);
|
|
90
|
-
flow.initialize()
|
|
148
|
+
flow.initialize().catch((error) => {
|
|
149
|
+
logger.error('Payments dashboard flow failed to initialize', describeError(error));
|
|
150
|
+
throw error;
|
|
151
|
+
});
|
|
91
152
|
return flow;
|
|
92
153
|
}
|
|
93
154
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface InitOptions {
|
|
|
10
10
|
partnerName: string;
|
|
11
11
|
/** If true, the session will be kept alive in the background via an invisible iframe */
|
|
12
12
|
keepAlive?: boolean;
|
|
13
|
+
/**
|
|
14
|
+
* If true, the SDK prints debug-level diagnostics to the console in addition to
|
|
15
|
+
* warnings and errors. Leave it off in production: warnings and errors always print.
|
|
16
|
+
*/
|
|
17
|
+
debug?: boolean;
|
|
13
18
|
/** The environment to use for API endpoints. Defaults to 'production' */
|
|
14
19
|
environment?: Environment;
|
|
15
20
|
/** The branch to use for the melio platform. Defaults to 'main' */
|
|
@@ -130,22 +135,21 @@ export interface NavigationData {
|
|
|
130
135
|
targetAction: 'schedulePayment' | 'scheduleBatchPayments' | 'viewSubscriptionPlans' | 'viewSettingsCollaborators' | 'viewPayment' | 'viewPaidPayment' | 'viewBill' | 'addNewBill' | 'viewVendors' | 'addVendor' | 'viewSettings' | 'viewArInvoices' | 'redirect';
|
|
131
136
|
}
|
|
132
137
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
138
|
+
* Which button the user clicked inside the embedded Melio app, delivered by the
|
|
139
|
+
* `buttonClicked` event.
|
|
135
140
|
*
|
|
136
|
-
* `'
|
|
137
|
-
*
|
|
138
|
-
* open a payment flow.
|
|
141
|
+
* `'quickPayment'` is the new payment button on the payments dashboard: take the user to
|
|
142
|
+
* your own supplier selection page, from which you open a payment flow.
|
|
139
143
|
*
|
|
140
144
|
* @remarks
|
|
141
|
-
*
|
|
142
|
-
*
|
|
145
|
+
* Buttons that end a flow keep their existing events — "Back to bills" still arrives as
|
|
146
|
+
* `completed`.
|
|
143
147
|
*
|
|
144
|
-
* Further
|
|
148
|
+
* Further button types are added in minor releases, so switch on `type` and treat an
|
|
145
149
|
* unrecognised value as a no-op rather than an error.
|
|
146
150
|
*/
|
|
147
|
-
export interface
|
|
148
|
-
|
|
151
|
+
export interface ButtonClickedData {
|
|
152
|
+
type: 'quickPayment';
|
|
149
153
|
}
|
|
150
154
|
/**
|
|
151
155
|
* Flow completion data
|
|
@@ -168,11 +172,11 @@ export interface ErrorData {
|
|
|
168
172
|
/**
|
|
169
173
|
* Event types that can be listened to
|
|
170
174
|
*/
|
|
171
|
-
export type FlowEventType = 'completed' | 'exit' | '
|
|
175
|
+
export type FlowEventType = 'completed' | 'exit' | 'buttonClicked' | 'navigated' | 'authenticationSucceeded' | 'authenticationFailed' | 'error' | 'loaded' | 'onboardingCompleted' | 'onboardingRequired';
|
|
172
176
|
/**
|
|
173
177
|
* Event callback function types
|
|
174
178
|
*/
|
|
175
|
-
export type FlowEventCallback = ((data: FlowCompletionData | NavigationData | ErrorData |
|
|
179
|
+
export type FlowEventCallback = ((data: FlowCompletionData | NavigationData | ErrorData | ButtonClickedData) => void) | (() => void);
|
|
176
180
|
/**
|
|
177
181
|
* Flow instance interface for event handling
|
|
178
182
|
*/
|
|
@@ -184,7 +188,7 @@ export interface FlowInstance {
|
|
|
184
188
|
*/
|
|
185
189
|
on(event: 'completed', callback: (data: FlowCompletionData) => void): void;
|
|
186
190
|
on(event: 'exit', callback: () => void): void;
|
|
187
|
-
on(event: '
|
|
191
|
+
on(event: 'buttonClicked', callback: (data: ButtonClickedData) => void): void;
|
|
188
192
|
on(event: 'navigated', callback: (payload: NavigationData) => void): void;
|
|
189
193
|
on(event: 'authenticationSucceeded', callback: () => void): void;
|
|
190
194
|
on(event: 'authenticationFailed', callback: () => void): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@melio-eng/web-sdk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0-pr.93.4914b4e",
|
|
4
4
|
"description": "Melio Web SDK - Embed core Melio workflows directly into partner UI with minimal effort",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|