@proveanything/smartlinks 1.14.11 → 1.14.13
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/dist/docs/API_SUMMARY.md +7 -2
- package/dist/docs/overview.md +3 -0
- package/dist/docs/portal-auth-broadcast.md +26 -8
- package/dist/docs/portal-back-button.md +128 -36
- package/dist/docs/portal-request-action.md +289 -0
- package/dist/docs/portal-request-login.md +236 -0
- package/docs/API_SUMMARY.md +7 -2
- package/docs/overview.md +3 -0
- package/docs/portal-auth-broadcast.md +26 -8
- package/docs/portal-back-button.md +128 -36
- package/docs/portal-request-action.md +289 -0
- package/docs/portal-request-login.md +236 -0
- package/package.json +1 -1
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# Requesting Built-in Portal Actions
|
|
2
|
+
|
|
3
|
+
`requestAction` is the general-purpose sibling of
|
|
4
|
+
[`requestLogin`](./portal-request-login.md). It lets a sub-app
|
|
5
|
+
(container, widget, or iframe) trigger any of the portal's built-in
|
|
6
|
+
capabilities — open the QR scanner, log out, copy the current link,
|
|
7
|
+
navigate to another app — and await a result.
|
|
8
|
+
|
|
9
|
+
Use it whenever the answer to *"what do I want?"* is one of the
|
|
10
|
+
portal's standardized capabilities. The portal owns the implementation;
|
|
11
|
+
your sub-app declares intent.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why this exists
|
|
16
|
+
|
|
17
|
+
Linkpage-style apps, action grids and CTAs across the platform all need
|
|
18
|
+
the same primitives: scan a QR, take a photo, copy a link, jump to
|
|
19
|
+
another app, log the user out. Without a shared contract every app
|
|
20
|
+
re-implemented these against direct browser APIs, fragmenting UX and
|
|
21
|
+
breaking cross-iframe boundaries. `requestAction` is the contract.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Container / Widget Apps (Same React Tree)
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { usePortalActions } from '@proveanything/portal-framework';
|
|
29
|
+
|
|
30
|
+
function ShareButton() {
|
|
31
|
+
const portal = usePortalActions();
|
|
32
|
+
async function onClick() {
|
|
33
|
+
const result = await portal?.requestAction('__share', {
|
|
34
|
+
title: 'Check this out',
|
|
35
|
+
url: window.location.href,
|
|
36
|
+
});
|
|
37
|
+
// result.status === 'success' | 'cancelled' | 'unavailable' | 'error' | 'triggered'
|
|
38
|
+
}
|
|
39
|
+
return <button onClick={onClick}>Share</button>;
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`usePortalActions()` returns `null` when no `ActionRequestProvider` is
|
|
44
|
+
mounted upstream — treat that the same as `status: 'unavailable'`.
|
|
45
|
+
|
|
46
|
+
### Discovering available actions
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const ids = portal?.getAvailableActions() ?? [];
|
|
50
|
+
// → ['__refresh','__share','__copyLink','__navigate','__login','__logout',
|
|
51
|
+
// '__account','__qrScanner','__barcodeScanner','__nfcScanner', ...]
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Combine with `BUILTIN_ACTION_META` (also exported) for labels/icons in
|
|
55
|
+
your action pickers.
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Iframe Apps (Cross-Origin)
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const requestId = crypto.randomUUID();
|
|
63
|
+
|
|
64
|
+
window.addEventListener('message', function handler(ev) {
|
|
65
|
+
const d = ev.data;
|
|
66
|
+
if (!d || d._smartlinksIframeMessage !== true) return;
|
|
67
|
+
if (d.type !== 'smartlinks:action:result') return;
|
|
68
|
+
if (d.payload?.requestId !== requestId) return;
|
|
69
|
+
window.removeEventListener('message', handler);
|
|
70
|
+
|
|
71
|
+
const { status, value, error } = d.payload;
|
|
72
|
+
// …handle result
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
window.parent.postMessage({
|
|
76
|
+
_smartlinksIframeMessage: true,
|
|
77
|
+
type: 'smartlinks:action:request',
|
|
78
|
+
payload: {
|
|
79
|
+
requestId,
|
|
80
|
+
actionId: '__copyLink',
|
|
81
|
+
params: { url: window.location.href },
|
|
82
|
+
},
|
|
83
|
+
}, '*');
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Capabilities probe
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
window.parent.postMessage({
|
|
90
|
+
_smartlinksIframeMessage: true,
|
|
91
|
+
type: 'smartlinks:action:capabilities',
|
|
92
|
+
payload: { requestId },
|
|
93
|
+
}, '*');
|
|
94
|
+
// reply: type 'smartlinks:action:capabilities:result'
|
|
95
|
+
// payload: { requestId, actions: string[], meta: Record<id, {label, icon, description, available}> }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Use this to hide actions the host doesn't support (e.g. `__nfcScanner`
|
|
99
|
+
on iOS web).
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Built-in actions
|
|
104
|
+
|
|
105
|
+
| Action ID | Kind | Params | Success value |
|
|
106
|
+
|---|---|---|---|
|
|
107
|
+
| `__refresh` | sync | — | — |
|
|
108
|
+
| `__share` | sync | `{ title?, text?, url? }` | `{ method: 'native' \| 'clipboard' }` |
|
|
109
|
+
| `__copyLink` | sync | `{ text?, url? }` | `{ url }` |
|
|
110
|
+
| `__navigate` | sync | `{ appId, deepLink?, deepLinkParams? }` | — |
|
|
111
|
+
| `__login` | sync | `{ reason?, mode?, resolveIfAuthenticated? }` | `{ user, token }` |
|
|
112
|
+
| `__logout` | sync | — | — |
|
|
113
|
+
| `__account` | sync | login params | login → `{ user, token }`; logout → — |
|
|
114
|
+
| `__qrScanner` | overlay | `{ suppressNavigation?: boolean }` | `{ text, format, resolved?: { collectionId, productId?, proofId? } }` |
|
|
115
|
+
| `__barcodeScanner` | overlay | `{ suppressNavigation?: boolean }` | `{ text, format }` |
|
|
116
|
+
| `__nfcScanner` | overlay | `{ suppressNavigation?: boolean }` | `{ serial, payload? }` (Android only) |
|
|
117
|
+
| `__language` | overlay | — | — |
|
|
118
|
+
| `__profile` | page | — | — |
|
|
119
|
+
| `__settings` | page | — | — |
|
|
120
|
+
|
|
121
|
+
> **`__login` vs `requestLogin`:** For the common case of "I just need a
|
|
122
|
+
> logged-in user before continuing", prefer
|
|
123
|
+
> [`requestLogin`](./portal-request-login.md) — it is a typed convenience
|
|
124
|
+
> wrapper around `requestAction('__login', ...)` with a richer result
|
|
125
|
+
> contract. Use `__login` / `__account` via `requestAction` only when you
|
|
126
|
+
> are composing with other built-in actions in the same flow.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## App-registered actions
|
|
131
|
+
|
|
132
|
+
Sub-apps may register their own action IDs via `siteInjections.actions`
|
|
133
|
+
on `OrchestratedPortal`. Those IDs become callable through the same
|
|
134
|
+
`requestAction` / postMessage contract as built-ins. Unlike built-ins they
|
|
135
|
+
are not guaranteed to be present on every portal — always probe
|
|
136
|
+
`getAvailableActions()` before using them.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Result shape
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
type ActionRequestResult = {
|
|
144
|
+
status:
|
|
145
|
+
| 'success' // action completed (sync actions may carry a value; capture overlays always do)
|
|
146
|
+
| 'cancelled' // user dismissed the overlay without completing
|
|
147
|
+
| 'unavailable' // unknown action or capability not present on this host
|
|
148
|
+
| 'error'; // action threw
|
|
149
|
+
value?: unknown;
|
|
150
|
+
error?: string;
|
|
151
|
+
requestId?: string; // echoed on postMessage replies
|
|
152
|
+
};
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
All actions now resolve to one of these four terminal statuses. There is no
|
|
156
|
+
`triggered` intermediate status — capture-mode overlays (QR, barcode, NFC)
|
|
157
|
+
keep the caller's promise pending until the user completes the scan or
|
|
158
|
+
dismisses the overlay.
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Scanner actions in detail
|
|
163
|
+
|
|
164
|
+
QR, barcode, and NFC scanner actions open the portal's scanner overlay and
|
|
165
|
+
**await user input** — the promise (or postMessage reply) is held open until
|
|
166
|
+
the user either completes a scan or dismisses the overlay.
|
|
167
|
+
|
|
168
|
+
By default the portal also performs its standard navigation after a
|
|
169
|
+
successful scan (e.g. resolving a SmartLinks proof URL). Pass
|
|
170
|
+
`suppressNavigation: true` when you want the raw value and no portal routing.
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// Container / widget — get the scanned value, skip portal navigation
|
|
174
|
+
const result = await portal.requestAction('__qrScanner', {
|
|
175
|
+
suppressNavigation: true,
|
|
176
|
+
})
|
|
177
|
+
if (result.status === 'success') {
|
|
178
|
+
const { text, format, resolved } = result.value as QrScanResult
|
|
179
|
+
// text — raw scanned string
|
|
180
|
+
// format — e.g. 'QR_CODE', 'EAN_13'
|
|
181
|
+
// resolved — if the text decoded to a SmartLinks URL:
|
|
182
|
+
// { collectionId, productId?, proofId? }
|
|
183
|
+
}
|
|
184
|
+
if (result.status === 'cancelled') {
|
|
185
|
+
// user closed the scanner without scanning
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// Container / widget — let the portal navigate AND get the value
|
|
191
|
+
const result = await portal.requestAction('__qrScanner')
|
|
192
|
+
// portal navigates to the scanned link; sub-app also gets the value
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
// Iframe — same contract via postMessage
|
|
197
|
+
const requestId = crypto.randomUUID()
|
|
198
|
+
|
|
199
|
+
window.addEventListener('message', function handler(ev) {
|
|
200
|
+
const d = ev.data
|
|
201
|
+
if (!d?._smartlinksIframeMessage) return
|
|
202
|
+
if (d.type !== 'smartlinks:action:result') return
|
|
203
|
+
if (d.payload?.requestId !== requestId) return
|
|
204
|
+
window.removeEventListener('message', handler)
|
|
205
|
+
|
|
206
|
+
if (d.payload.status === 'success') {
|
|
207
|
+
const { text, format, resolved } = d.payload.value
|
|
208
|
+
// use the scan result
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
window.parent.postMessage({
|
|
213
|
+
_smartlinksIframeMessage: true,
|
|
214
|
+
type: 'smartlinks:action:request',
|
|
215
|
+
payload: {
|
|
216
|
+
requestId,
|
|
217
|
+
actionId: '__qrScanner',
|
|
218
|
+
params: { suppressNavigation: true },
|
|
219
|
+
},
|
|
220
|
+
}, '*')
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Scan value types
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
type QrScanResult = {
|
|
227
|
+
text: string
|
|
228
|
+
format: string // 'QR_CODE', 'EAN_13', etc.
|
|
229
|
+
resolved?: {
|
|
230
|
+
collectionId: string
|
|
231
|
+
productId?: string
|
|
232
|
+
proofId?: string
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
type BarcodeScanResult = {
|
|
237
|
+
text: string
|
|
238
|
+
format: string
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
type NfcScanResult = {
|
|
242
|
+
serial: string
|
|
243
|
+
payload?: string
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Relationship to `LinkTarget`
|
|
250
|
+
|
|
251
|
+
Linkpage-style UIs persist a `LinkTarget` of kind `'action'`:
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
type ActionLinkTarget = {
|
|
255
|
+
kind: 'action';
|
|
256
|
+
actionId: string;
|
|
257
|
+
params?: Record<string, unknown>;
|
|
258
|
+
};
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
At runtime, resolve it through the portal:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
await portal?.requestAction(target.actionId, target.params);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Never persist a resolved URL when an action is intended — the action
|
|
268
|
+
contract may change platform-side, the persisted ID won't.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Anti-patterns
|
|
273
|
+
|
|
274
|
+
❌ Calling `navigator.share` / `clipboard.writeText` directly from a
|
|
275
|
+
sub-app when `__share` / `__copyLink` exist — you lose host-level
|
|
276
|
+
error handling and capability negotiation.
|
|
277
|
+
|
|
278
|
+
❌ Re-implementing logout by clearing your own state. Always go through
|
|
279
|
+
`__logout` (or `useAuth().logout()` in-tree) so the whole portal
|
|
280
|
+
session ends cleanly.
|
|
281
|
+
|
|
282
|
+
❌ Hard-coding action IDs the host might not support. Probe
|
|
283
|
+
`getAvailableActions()` or the capabilities postMessage first.
|
|
284
|
+
|
|
285
|
+
❌ Reusing a single `requestId` across postMessage calls.
|
|
286
|
+
|
|
287
|
+
❌ Not awaiting the result of capture-mode scanner actions. The promise
|
|
288
|
+
stays pending until the user scans or cancels — fire and forget will
|
|
289
|
+
leak the listener.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Requesting Login from the Portal
|
|
2
|
+
|
|
3
|
+
This is the **recommended** way for a micro-app to get a logged-in user. The
|
|
4
|
+
sub-app does not render any auth UI of its own — it asks the portal to run
|
|
5
|
+
its standard AuthKit flow and awaits a result. The portal owns the modal,
|
|
6
|
+
WhatsApp / OAuth / magic-link redirects, session persistence, and the
|
|
7
|
+
header avatar swap.
|
|
8
|
+
|
|
9
|
+
Use `requestLogin` whenever the answer to "what do I want?" is simply
|
|
10
|
+
*"a logged-in user before I continue"*. Only fall back to
|
|
11
|
+
[publishing your own session](./portal-auth-broadcast.md) when the app runs
|
|
12
|
+
a genuinely custom auth flow (e.g. its own bidder API).
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why this exists
|
|
17
|
+
|
|
18
|
+
Historically every sub-app imported `@proveanything/smartlinks-auth-ui` and
|
|
19
|
+
rendered its own login modal. That meant:
|
|
20
|
+
|
|
21
|
+
- Two React copies of `AuthContext` could exist at once → silent session
|
|
22
|
+
desync between the header and the sub-app.
|
|
23
|
+
- Each app re-implemented the same WhatsApp / OAuth / magic-link flow.
|
|
24
|
+
- Redirect bounces would land back on the portal with no memory of which
|
|
25
|
+
sub-app had triggered the login.
|
|
26
|
+
|
|
27
|
+
The portal now owns the entire flow. Sub-apps just declare intent.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Container / Widget Apps (Same React Tree)
|
|
32
|
+
|
|
33
|
+
These share the portal's React context. Use `useSafeAuth()` from the
|
|
34
|
+
portal framework:
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { useSafeAuth } from '@proveanything/portal-framework';
|
|
38
|
+
|
|
39
|
+
function PlaceBidButton() {
|
|
40
|
+
const auth = useSafeAuth();
|
|
41
|
+
|
|
42
|
+
async function onClick() {
|
|
43
|
+
const result = await auth?.requestLogin?.({ reason: 'bid' });
|
|
44
|
+
if (result?.status === 'success' || result?.status === 'already-authenticated') {
|
|
45
|
+
// auth.user / auth.token are now populated.
|
|
46
|
+
await api.placeBid(...);
|
|
47
|
+
}
|
|
48
|
+
// 'cancelled' → user dismissed the modal.
|
|
49
|
+
// 'unavailable' → portal has no AuthKit configured.
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return <button onClick={onClick}>Place bid</button>;
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`requestLogin` is also exposed as `useAuthRequest().requestLogin` if you
|
|
57
|
+
prefer a dedicated hook, but the folded `useSafeAuth()` shape is preferred
|
|
58
|
+
so callers have one entry point for both session reads and login intent.
|
|
59
|
+
|
|
60
|
+
### Options
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
auth.requestLogin({
|
|
64
|
+
reason?: string, // analytics / copy hint
|
|
65
|
+
mode?: 'login' | 'signup' | 'either', // default 'either'
|
|
66
|
+
signupProminence?: SignupProminence, // overrides modal default
|
|
67
|
+
resolveIfAuthenticated?: boolean, // default true
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Option | Default | Effect |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `reason` | — | Free-form tag. Surfaced to analytics; some templates use it for copy. |
|
|
74
|
+
| `mode` | `'either'` | `'signup'` biases the modal to the registration tab. |
|
|
75
|
+
| `signupProminence` | portal default | One of `'minimal' | 'balanced' | 'emphasized'`. |
|
|
76
|
+
| `resolveIfAuthenticated` | `true` | When already logged in, resolve immediately without showing the modal. Set `false` to force a fresh prompt (e.g. re-auth before a sensitive action). |
|
|
77
|
+
|
|
78
|
+
### Result
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
type LoginRequestResult = {
|
|
82
|
+
status: 'success' | 'already-authenticated' | 'cancelled' | 'unavailable';
|
|
83
|
+
user?: PortalAuthBridgeUser | null;
|
|
84
|
+
token?: string | null;
|
|
85
|
+
};
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- `success` — fresh login completed inside the modal.
|
|
89
|
+
- `already-authenticated` — there was already a session; nothing was shown.
|
|
90
|
+
- `cancelled` — user dismissed the modal without logging in.
|
|
91
|
+
- `unavailable` — no AuthKit is configured on this portal. Treat as
|
|
92
|
+
not-logged-in and surface an appropriate message; the user cannot log
|
|
93
|
+
in from this portal at all.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Iframe Apps (Cross-Origin)
|
|
98
|
+
|
|
99
|
+
Iframe apps don't share React context with the portal. They post a request
|
|
100
|
+
message and listen for a single correlated reply:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
const requestId = crypto.randomUUID();
|
|
104
|
+
|
|
105
|
+
window.addEventListener('message', function handler(ev) {
|
|
106
|
+
const d = ev.data;
|
|
107
|
+
if (!d || d._smartlinksIframeMessage !== true) return;
|
|
108
|
+
if (d.type !== 'smartlinks:authkit:login-result') return;
|
|
109
|
+
if (d.payload?.requestId !== requestId) return;
|
|
110
|
+
window.removeEventListener('message', handler);
|
|
111
|
+
|
|
112
|
+
const { status, user, token } = d.payload;
|
|
113
|
+
if (status === 'success' || status === 'already-authenticated') {
|
|
114
|
+
// user + token populated — resume the action that needed auth
|
|
115
|
+
placeBid();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
window.parent.postMessage({
|
|
120
|
+
_smartlinksIframeMessage: true,
|
|
121
|
+
type: 'smartlinks:authkit:request-login',
|
|
122
|
+
payload: {
|
|
123
|
+
requestId,
|
|
124
|
+
reason: 'bid',
|
|
125
|
+
mode: 'either',
|
|
126
|
+
resolveIfAuthenticated: true,
|
|
127
|
+
},
|
|
128
|
+
}, '*');
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The portal opens its modal, runs the standard AuthKit flow (including any
|
|
132
|
+
WhatsApp / OAuth / magic-link redirect bounces), then posts exactly one
|
|
133
|
+
`login-result` back with the same `requestId`.
|
|
134
|
+
|
|
135
|
+
### Message contract
|
|
136
|
+
|
|
137
|
+
**Request — child → parent**
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
{
|
|
141
|
+
_smartlinksIframeMessage: true,
|
|
142
|
+
type: 'smartlinks:authkit:request-login',
|
|
143
|
+
payload: {
|
|
144
|
+
requestId: string, // generate per request; echoed in the reply
|
|
145
|
+
reason?: string,
|
|
146
|
+
mode?: 'login' | 'signup' | 'either',
|
|
147
|
+
signupProminence?: 'minimal' | 'balanced' | 'emphasized',
|
|
148
|
+
resolveIfAuthenticated?: boolean,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Reply — parent → child**
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
{
|
|
157
|
+
_smartlinksIframeMessage: true,
|
|
158
|
+
type: 'smartlinks:authkit:login-result',
|
|
159
|
+
payload: {
|
|
160
|
+
requestId: string, // matches the request
|
|
161
|
+
status: 'success' | 'already-authenticated' | 'cancelled' | 'unavailable',
|
|
162
|
+
user?: { uid, email?, displayName?, photoURL?, phoneNumber?, accountData? } | null,
|
|
163
|
+
token?: string | null,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Only one reply is sent per `requestId`. Stacking multiple request-logins
|
|
169
|
+
cancels prior pending requests — they receive `status: 'cancelled'`.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Redirect-Bounce Flows (WhatsApp, OAuth, Magic Link)
|
|
174
|
+
|
|
175
|
+
These are handled **inside the portal's AuthModal by AuthKit itself** —
|
|
176
|
+
your iframe stays mounted at the same URL throughout. There is no
|
|
177
|
+
sub-app state restoration to wire up; the modal closes when the redirect
|
|
178
|
+
resolves and the portal posts back your `login-result`.
|
|
179
|
+
|
|
180
|
+
If you have a sub-app that lives on a route the user navigates away from
|
|
181
|
+
during the redirect, snapshot whatever you need into your own
|
|
182
|
+
`sessionStorage` before calling `requestLogin`, the same as for any other
|
|
183
|
+
async UX. The portal does not (yet) snapshot sub-app state on your behalf.
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Reading the Current Session
|
|
188
|
+
|
|
189
|
+
You don't need to call `requestLogin` just to read the current session —
|
|
190
|
+
that comes for free:
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
// Container / widget
|
|
194
|
+
import { useSafeAuth } from '@proveanything/portal-framework';
|
|
195
|
+
const { user, token, isAuthenticated } = useSafeAuth() ?? {};
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
// Iframe — the IframeResponder caches the parent's user under `cache.user`.
|
|
200
|
+
// Read via the SDK's standard hooks/helpers.
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Anti-Patterns
|
|
206
|
+
|
|
207
|
+
❌ Sub-apps importing `@proveanything/smartlinks-auth-ui` and rendering
|
|
208
|
+
their own `<SmartlinksAuthUI />` modal. This is what `requestLogin`
|
|
209
|
+
replaces — duplicate React contexts cause silent header desync.
|
|
210
|
+
|
|
211
|
+
❌ Posting `request-login` and then also opening a local fallback modal.
|
|
212
|
+
Pick one; the portal modal is canonical.
|
|
213
|
+
|
|
214
|
+
❌ Reusing a single `requestId` across multiple calls. Generate a new ID
|
|
215
|
+
per request so replies can be correlated unambiguously.
|
|
216
|
+
|
|
217
|
+
❌ Treating `unavailable` as an error to retry. It means the portal has no
|
|
218
|
+
AuthKit configured — surface a "login isn't available here" message.
|
|
219
|
+
|
|
220
|
+
❌ Setting `resolveIfAuthenticated: false` for normal "gate this action"
|
|
221
|
+
use cases — you'll re-prompt logged-in users for no reason. Only use
|
|
222
|
+
it for genuine step-up auth.
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Relationship to `portal-auth-broadcast.md`
|
|
227
|
+
|
|
228
|
+
| Concern | Use |
|
|
229
|
+
|---|---|
|
|
230
|
+
| "Log the user in so I can call my API" | **`requestLogin`** (this doc) |
|
|
231
|
+
| "I ran my own custom auth flow, here's the resulting session" | [`portal-auth-broadcast.md`](./portal-auth-broadcast.md) |
|
|
232
|
+
| "Log the user out of the whole portal" | `useAuth().logout()` (container/widget) or `smartlinks:authkit:logout` postMessage (iframe) |
|
|
233
|
+
|
|
234
|
+
Both flows ultimately drive the same `PortalAuthProvider.login` /
|
|
235
|
+
`logout` calls and produce the same observable side effects (header
|
|
236
|
+
avatar, `useAuth()` re-render, `setBearerToken` on the SDK).
|