@matanetwork/sovereign-id-react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/package.json +67 -0
- package/src/SignInButton.js +169 -0
- package/src/index.d.ts +225 -0
- package/src/index.js +56 -0
- package/src/useResumePendingSignIn.js +148 -0
- package/src/useSignIn.js +222 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MATA Network
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# @matanetwork/sovereign-id-react
|
|
2
|
+
|
|
3
|
+
React adapter for [`@matanetwork/sovereign-id`](https://www.npmjs.com/package/@matanetwork/sovereign-id).
|
|
4
|
+
Drop in a `<SignInButton/>`, get a signed JWT carrying a verified
|
|
5
|
+
DID + the user's consented claims.
|
|
6
|
+
|
|
7
|
+
Lowers your integration from ~20 LOC + manual state plumbing to one
|
|
8
|
+
component or one hook.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @matanetwork/sovereign-id-react @matanetwork/sovereign-id react
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`react` is a peer dependency — works with React 17, 18, and 19.
|
|
17
|
+
|
|
18
|
+
For the backend verifier, also install [`@matanetwork/sovereign-id-verify`](https://www.npmjs.com/package/@matanetwork/sovereign-id-verify).
|
|
19
|
+
|
|
20
|
+
## Quick start — drop-in button
|
|
21
|
+
|
|
22
|
+
```jsx
|
|
23
|
+
import { SignInButton } from '@matanetwork/sovereign-id-react';
|
|
24
|
+
|
|
25
|
+
function LoginPage() {
|
|
26
|
+
return (
|
|
27
|
+
<SignInButton
|
|
28
|
+
getNonce={() => fetch('/api/auth/nonce').then((r) => r.text())}
|
|
29
|
+
claims={{ required: ['did'], optional: ['email', 'name'] }}
|
|
30
|
+
onSuccess={({ jwt }) =>
|
|
31
|
+
fetch('/api/auth/mid', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers: { 'Content-Type': 'application/json' },
|
|
34
|
+
body: JSON.stringify({ jwt }),
|
|
35
|
+
}).then(() => (window.location.href = '/dashboard'))
|
|
36
|
+
}
|
|
37
|
+
onCancel={(code) => console.log('user cancelled:', code)}
|
|
38
|
+
onError={(err) => console.error(err.code, err.message)}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
That's it. The button:
|
|
45
|
+
|
|
46
|
+
- Fetches a nonce from your backend
|
|
47
|
+
- Probes for the MATA extension → falls back to the native-app deep
|
|
48
|
+
link → falls back to the install upsell modal
|
|
49
|
+
- Resolves the user's consent decision
|
|
50
|
+
- Hands you `{ jwt, surface }` on success
|
|
51
|
+
|
|
52
|
+
## Hook — custom button styling
|
|
53
|
+
|
|
54
|
+
When you want full control over the rendered button:
|
|
55
|
+
|
|
56
|
+
```jsx
|
|
57
|
+
import { useSignIn } from '@matanetwork/sovereign-id-react';
|
|
58
|
+
|
|
59
|
+
function LoginPage() {
|
|
60
|
+
const { signIn, isLoading, error } = useSignIn({
|
|
61
|
+
rpOrigin: 'https://acme.com',
|
|
62
|
+
getNonce: () => fetch('/api/auth/nonce').then((r) => r.text()),
|
|
63
|
+
defaultClaims: { required: ['did'], optional: ['email'] },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<>
|
|
68
|
+
<button
|
|
69
|
+
onClick={async () => {
|
|
70
|
+
try {
|
|
71
|
+
const { jwt } = await signIn();
|
|
72
|
+
await fetch('/api/auth/mid', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify({ jwt }),
|
|
76
|
+
});
|
|
77
|
+
window.location.href = '/dashboard';
|
|
78
|
+
} catch {
|
|
79
|
+
/* useSignIn already mirrors the error into `error` */
|
|
80
|
+
}
|
|
81
|
+
}}
|
|
82
|
+
disabled={isLoading}
|
|
83
|
+
className="my-styled-button"
|
|
84
|
+
>
|
|
85
|
+
{isLoading ? 'Signing in…' : 'Sign in with MATA'}
|
|
86
|
+
</button>
|
|
87
|
+
{error && <p role="alert">{error.message}</p>}
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`useSignIn` returns `{ signIn, reset, status, isLoading, result, error }`.
|
|
94
|
+
Status is `'idle' | 'pending' | 'success' | 'error'`.
|
|
95
|
+
|
|
96
|
+
## Resume after page reload
|
|
97
|
+
|
|
98
|
+
The install upsell flow stashes a pending sign-in in `sessionStorage`
|
|
99
|
+
so it survives a reload (common when Chrome prompts the user to
|
|
100
|
+
reload after extension install). Wire the resume at the root of your
|
|
101
|
+
app:
|
|
102
|
+
|
|
103
|
+
```jsx
|
|
104
|
+
import { useResumePendingSignIn } from '@matanetwork/sovereign-id-react';
|
|
105
|
+
|
|
106
|
+
function App() {
|
|
107
|
+
useResumePendingSignIn({
|
|
108
|
+
onSuccess: ({ jwt }) => {
|
|
109
|
+
fetch('/api/auth/mid', {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
headers: { 'Content-Type': 'application/json' },
|
|
112
|
+
body: JSON.stringify({ jwt }),
|
|
113
|
+
}).then(() => (window.location.href = '/dashboard'));
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return <Routes>{/* ... */}</Routes>;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The hook fires once on mount, looks for a stashed pending sign-in,
|
|
122
|
+
and invokes `onSuccess` if one resumes. If you want to delay
|
|
123
|
+
rendering your logged-out UI until the resume check completes (avoids
|
|
124
|
+
a flicker), watch the `noResume` flag:
|
|
125
|
+
|
|
126
|
+
```jsx
|
|
127
|
+
const { noResume, status } = useResumePendingSignIn({ onSuccess });
|
|
128
|
+
if (status === 'pending') return <Spinner />;
|
|
129
|
+
// noResume === true once we've confirmed nothing was pending
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## API
|
|
133
|
+
|
|
134
|
+
### `<SignInButton/>`
|
|
135
|
+
|
|
136
|
+
| Prop | Type | Required | Notes |
|
|
137
|
+
|---|---|---|---|
|
|
138
|
+
| `getNonce` | `() => Promise<string>` | yes | Async fn returning a fresh single-use nonce. |
|
|
139
|
+
| `claims` | `{ required, optional?, custom? }` | yes | Standard SDK claim shape. |
|
|
140
|
+
| `onSuccess` | `(result) => void` | yes | Called with `{ jwt, surface }`. |
|
|
141
|
+
| `onCancel` | `(code) => void` | no | Called on `user_denied` or `upsell_canceled`. |
|
|
142
|
+
| `onError` | `(err: SignInError) => void` | no | Called on any other failure path. |
|
|
143
|
+
| `rpOrigin` | `string` | no | Default: `window.location.origin`. |
|
|
144
|
+
| `installUpsell` | `boolean` | no | Default: SDK default (`true`). |
|
|
145
|
+
| `ref` | `string \| null` | no | Referral code. Default: hostname of `rpOrigin`. |
|
|
146
|
+
| `timeoutMs` | `number` | no | Default: 120000. |
|
|
147
|
+
| `nativeAppCallback` | `string` | no | Default: `window.location.href`. |
|
|
148
|
+
| `children` | `ReactNode` | no | Button label. Default: `"Sign in with Sovereign ID"`. |
|
|
149
|
+
| `className` | `string` | no | When set, default inline styles are NOT applied. |
|
|
150
|
+
| `style` | `CSSProperties` | no | Merged with default inline styles when `className` is unset. |
|
|
151
|
+
| `disabled` | `boolean` | no | OR'd with the internal in-flight disabled state. |
|
|
152
|
+
| `buttonProps` | `object` | no | Spread onto the `<button>` (aria-*, data-*, etc.). |
|
|
153
|
+
|
|
154
|
+
### `useSignIn(options?)`
|
|
155
|
+
|
|
156
|
+
| Option | Type | Notes |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `rpOrigin` | `string` | Default: `window.location.origin`. |
|
|
159
|
+
| `getNonce` | `() => Promise<string>` | Required unless you pass `nonce` to every `signIn()` call directly. |
|
|
160
|
+
| `defaultClaims` | `claims` | Default `claims` payload. |
|
|
161
|
+
| `installUpsell`, `ref`, `timeoutMs`, `nativeAppCallback` | — | Forwarded to the SDK. |
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
{
|
|
167
|
+
signIn(overrides?): Promise<{ jwt, surface } | null>;
|
|
168
|
+
reset(): void;
|
|
169
|
+
status: 'idle' | 'pending' | 'success' | 'error';
|
|
170
|
+
isLoading: boolean;
|
|
171
|
+
result: { jwt, surface } | null;
|
|
172
|
+
error: SignInError | null;
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The hook protects against stale calls — if you click sign-in twice
|
|
177
|
+
in a row, only the most recent call's result commits to state.
|
|
178
|
+
|
|
179
|
+
### `useResumePendingSignIn(options?)`
|
|
180
|
+
|
|
181
|
+
| Option | Type | Notes |
|
|
182
|
+
|---|---|---|
|
|
183
|
+
| `onSuccess` | `(result) => void` | Called when a resume completes with a JWT. |
|
|
184
|
+
| `onError` | `(err) => void` | Called when a resume threw. |
|
|
185
|
+
| `onNothingPending` | `() => void` | Called when there's nothing to resume — most boots. |
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
{
|
|
191
|
+
status: 'idle' | 'pending' | 'success' | 'error';
|
|
192
|
+
result: { jwt, surface } | null;
|
|
193
|
+
error: SignInError | null;
|
|
194
|
+
noResume: boolean;
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Re-exports from the core SDK
|
|
199
|
+
|
|
200
|
+
For convenience, this package re-exports everything from
|
|
201
|
+
`@matanetwork/sovereign-id` so you only need one import line:
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
import {
|
|
205
|
+
SignInError,
|
|
206
|
+
ERR_USER_DENIED,
|
|
207
|
+
ERR_UPSELL_CANCELED,
|
|
208
|
+
hasExtension,
|
|
209
|
+
defaultRefFromOrigin,
|
|
210
|
+
// ...
|
|
211
|
+
} from '@matanetwork/sovereign-id-react';
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Backend
|
|
215
|
+
|
|
216
|
+
Same as the core SDK — use [`@matanetwork/sovereign-id-verify`](https://www.npmjs.com/package/@matanetwork/sovereign-id-verify):
|
|
217
|
+
|
|
218
|
+
```js
|
|
219
|
+
import { verifyResponse } from '@matanetwork/sovereign-id-verify';
|
|
220
|
+
|
|
221
|
+
const verified = await verifyResponse(req.body.jwt, {
|
|
222
|
+
expectedAudience: 'https://acme.com',
|
|
223
|
+
expectedNonce: sessionNonce,
|
|
224
|
+
nowUnixSecs: Math.floor(Date.now() / 1000),
|
|
225
|
+
});
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
See the
|
|
229
|
+
[main docs](https://github.com/mata-network/mata/tree/main/docs/sovereign-id)
|
|
230
|
+
for the integration guide, error-handling matrix, and protocol spec.
|
|
231
|
+
|
|
232
|
+
## What you don't have to think about
|
|
233
|
+
|
|
234
|
+
- Wallet detection (extension vs. native app vs. install upsell)
|
|
235
|
+
- Resume after page reload
|
|
236
|
+
- Referral attribution (your domain rides along to `my.mata.network/signup` by default)
|
|
237
|
+
- Stale-call state collisions
|
|
238
|
+
- React 18 strict-mode double-mount during boot resume
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT — see [LICENSE](LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@matanetwork/sovereign-id-react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React adapter for @matanetwork/sovereign-id — drop-in <SignInButton/>, useSignIn() hook, useResumePendingSignIn() boot helper. Lowers Sovereign ID RP integration from ~20 LOC to one import.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"types": "src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test tests/index.test.js",
|
|
17
|
+
"prepublishOnly": "node --test tests/index.test.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src/",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"keywords": [
|
|
25
|
+
"mata",
|
|
26
|
+
"matanetwork",
|
|
27
|
+
"sovereign-id",
|
|
28
|
+
"mid",
|
|
29
|
+
"react",
|
|
30
|
+
"react-hook",
|
|
31
|
+
"sso",
|
|
32
|
+
"identity",
|
|
33
|
+
"did",
|
|
34
|
+
"did-mata",
|
|
35
|
+
"permissionless-auth",
|
|
36
|
+
"self-sovereign"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/mata-network/mata/tree/main/packages/mata-sovereign-id-react#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/mata-network/mata/issues"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/mata-network/mata.git",
|
|
49
|
+
"directory": "packages/mata-sovereign-id-react"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@matanetwork/sovereign-id": "^0.1.0",
|
|
53
|
+
"react": ">=17.0.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"react": {
|
|
57
|
+
"optional": false
|
|
58
|
+
},
|
|
59
|
+
"@matanetwork/sovereign-id": {
|
|
60
|
+
"optional": false
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@matanetwork/sovereign-id": "^0.1.0",
|
|
65
|
+
"react": "^18.3.1"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<SignInButton/>` — drop-in React button that runs the full
|
|
3
|
+
* Sovereign ID sign-in flow on click.
|
|
4
|
+
*
|
|
5
|
+
* RPs that just want a working button drop this in and pass an
|
|
6
|
+
* `onSuccess` callback that hands the JWT to their backend. Custom
|
|
7
|
+
* styling via `className` / `style` / `children`. RPs that need more
|
|
8
|
+
* control over the click handler should use `useSignIn()` directly.
|
|
9
|
+
*
|
|
10
|
+
* ## Rendering note (no JSX)
|
|
11
|
+
*
|
|
12
|
+
* Authored with `React.createElement` so this package can ship as
|
|
13
|
+
* pure ESM with no build step. Bundlers (Vite / Webpack / esbuild)
|
|
14
|
+
* still tree-shake, type-check, etc. — they just don't have to
|
|
15
|
+
* transpile JSX. Keeps the package single-file-readable and zero-
|
|
16
|
+
* configure.
|
|
17
|
+
*
|
|
18
|
+
* ## What clicking does
|
|
19
|
+
*
|
|
20
|
+
* 1. Sets internal `loading` state.
|
|
21
|
+
* 2. Resolves the nonce via `getNonce` (required prop).
|
|
22
|
+
* 3. Calls the SDK's `signIn({ rpOrigin, nonce, claims })`.
|
|
23
|
+
* 4. On success, calls `onSuccess({ jwt, surface })`.
|
|
24
|
+
* 5. On `user_denied` / `upsell_canceled`, calls `onCancel(code)`.
|
|
25
|
+
* 6. On any other failure, calls `onError(err)`.
|
|
26
|
+
* 7. Resets loading state and re-enables the button.
|
|
27
|
+
*
|
|
28
|
+
* The button is `disabled` for the duration of step 1-6 so duplicate
|
|
29
|
+
* clicks are no-ops.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createElement } from 'react';
|
|
33
|
+
import { SignInError } from '@matanetwork/sovereign-id';
|
|
34
|
+
import { useSignIn } from './useSignIn.js';
|
|
35
|
+
|
|
36
|
+
const DEFAULT_LABEL = 'Sign in with Sovereign ID';
|
|
37
|
+
|
|
38
|
+
const DEFAULT_BUTTON_STYLE = {
|
|
39
|
+
// Match the popup CSS palette so the button feels at-home next to
|
|
40
|
+
// the consent screen that opens after the click.
|
|
41
|
+
background: '#4b2ad5',
|
|
42
|
+
color: '#fff',
|
|
43
|
+
border: 'none',
|
|
44
|
+
borderRadius: '8px',
|
|
45
|
+
padding: '10px 18px',
|
|
46
|
+
font: 'inherit',
|
|
47
|
+
fontSize: '14px',
|
|
48
|
+
fontWeight: 600,
|
|
49
|
+
cursor: 'pointer',
|
|
50
|
+
display: 'inline-flex',
|
|
51
|
+
alignItems: 'center',
|
|
52
|
+
gap: '8px',
|
|
53
|
+
lineHeight: 1.3,
|
|
54
|
+
transition: 'background 0.12s ease',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const DEFAULT_BUTTON_STYLE_DISABLED = {
|
|
58
|
+
...DEFAULT_BUTTON_STYLE,
|
|
59
|
+
opacity: 0.6,
|
|
60
|
+
cursor: 'not-allowed',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {object} props
|
|
65
|
+
* @param {string} [props.rpOrigin] - Defaults to `window.location.origin`.
|
|
66
|
+
* @param {() => Promise<string>} props.getNonce - Async fn returning a
|
|
67
|
+
* fresh single-use nonce from your backend. Required.
|
|
68
|
+
* @param {object} props.claims - Claim catalog. `{ required: [...],
|
|
69
|
+
* optional?: [...], custom?: {...} }`. Required.
|
|
70
|
+
* @param {(result: {jwt: string, surface: 'extension' | 'native_app'}) => void} props.onSuccess
|
|
71
|
+
* Called with the resulting JWT.
|
|
72
|
+
* @param {(code: 'user_denied' | 'upsell_canceled') => void} [props.onCancel]
|
|
73
|
+
* Called when the user explicitly chose not to sign in.
|
|
74
|
+
* @param {(err: SignInError) => void} [props.onError]
|
|
75
|
+
* Called for any other failure path (timeout, wallet_unavailable,
|
|
76
|
+
* etc.). If omitted, errors are silently swallowed — RPs should
|
|
77
|
+
* usually pass at least a logger here.
|
|
78
|
+
* @param {boolean} [props.installUpsell] - SDK option pass-through.
|
|
79
|
+
* @param {string | null} [props.ref] - SDK referral code; defaults
|
|
80
|
+
* to the hostname of `rpOrigin`.
|
|
81
|
+
* @param {number} [props.timeoutMs] - SDK option pass-through.
|
|
82
|
+
* @param {string} [props.nativeAppCallback] - SDK option pass-through.
|
|
83
|
+
* @param {React.ReactNode} [props.children] - Button label. Defaults
|
|
84
|
+
* to `"Sign in with Sovereign ID"`.
|
|
85
|
+
* @param {string} [props.className] - When set, the default styles
|
|
86
|
+
* are NOT applied (assume the RP has full control via CSS).
|
|
87
|
+
* @param {React.CSSProperties} [props.style] - Merged with the
|
|
88
|
+
* default inline styles when `className` is not provided.
|
|
89
|
+
* @param {boolean} [props.disabled] - Explicitly disable the button.
|
|
90
|
+
* The button is also disabled internally while a sign-in is in
|
|
91
|
+
* flight; this prop is OR'd with that.
|
|
92
|
+
* @param {object} [props.buttonProps] - Spread onto the underlying
|
|
93
|
+
* `<button>` for things like `aria-*`, `data-*`, etc.
|
|
94
|
+
*/
|
|
95
|
+
export function SignInButton(props) {
|
|
96
|
+
const {
|
|
97
|
+
rpOrigin,
|
|
98
|
+
getNonce,
|
|
99
|
+
claims,
|
|
100
|
+
onSuccess,
|
|
101
|
+
onCancel,
|
|
102
|
+
onError,
|
|
103
|
+
installUpsell,
|
|
104
|
+
ref,
|
|
105
|
+
timeoutMs,
|
|
106
|
+
nativeAppCallback,
|
|
107
|
+
children,
|
|
108
|
+
className,
|
|
109
|
+
style,
|
|
110
|
+
disabled = false,
|
|
111
|
+
buttonProps = {},
|
|
112
|
+
} = props;
|
|
113
|
+
|
|
114
|
+
const { signIn, isLoading } = useSignIn({
|
|
115
|
+
rpOrigin,
|
|
116
|
+
getNonce,
|
|
117
|
+
defaultClaims: claims,
|
|
118
|
+
installUpsell,
|
|
119
|
+
ref,
|
|
120
|
+
timeoutMs,
|
|
121
|
+
nativeAppCallback,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const isDisabled = disabled || isLoading;
|
|
125
|
+
|
|
126
|
+
const handleClick = async () => {
|
|
127
|
+
if (isDisabled) return;
|
|
128
|
+
try {
|
|
129
|
+
const result = await signIn();
|
|
130
|
+
if (result && typeof onSuccess === 'function') {
|
|
131
|
+
onSuccess(result);
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof SignInError) {
|
|
135
|
+
if (
|
|
136
|
+
(err.code === 'user_denied' || err.code === 'upsell_canceled') &&
|
|
137
|
+
typeof onCancel === 'function'
|
|
138
|
+
) {
|
|
139
|
+
onCancel(err.code);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (typeof onError === 'function') {
|
|
144
|
+
onError(err);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// When `className` is provided, defer fully to caller CSS — don't
|
|
150
|
+
// apply the default inline styles at all. When not, ship a sensible
|
|
151
|
+
// styled button and let `style` override fields one at a time.
|
|
152
|
+
const resolvedStyle = className
|
|
153
|
+
? style
|
|
154
|
+
: { ...(isDisabled ? DEFAULT_BUTTON_STYLE_DISABLED : DEFAULT_BUTTON_STYLE), ...style };
|
|
155
|
+
|
|
156
|
+
return createElement(
|
|
157
|
+
'button',
|
|
158
|
+
{
|
|
159
|
+
type: 'button',
|
|
160
|
+
onClick: handleClick,
|
|
161
|
+
disabled: isDisabled,
|
|
162
|
+
'aria-busy': isLoading || undefined,
|
|
163
|
+
className,
|
|
164
|
+
style: resolvedStyle,
|
|
165
|
+
...buttonProps,
|
|
166
|
+
},
|
|
167
|
+
children ?? DEFAULT_LABEL
|
|
168
|
+
);
|
|
169
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for @matanetwork/sovereign-id-react.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the JS runtime exactly. The runtime is pure JS (no build
|
|
5
|
+
* step); this `.d.ts` exists so consumers using TypeScript get full
|
|
6
|
+
* type-checking + IDE intellisense.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CSSProperties, ReactNode, ButtonHTMLAttributes } from 'react';
|
|
10
|
+
import type {
|
|
11
|
+
SignInError,
|
|
12
|
+
SignInRequest,
|
|
13
|
+
SignInSuccess,
|
|
14
|
+
ErrorCode,
|
|
15
|
+
} from '@matanetwork/sovereign-id';
|
|
16
|
+
|
|
17
|
+
// ─── Hook: useSignIn ───────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface UseSignInOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Default `rpOrigin` used by `signIn()`. If omitted, falls back to
|
|
22
|
+
* `window.location.origin`. RPs running SSR should set this
|
|
23
|
+
* explicitly.
|
|
24
|
+
*/
|
|
25
|
+
rpOrigin?: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Async function that returns a fresh single-use nonce. Called
|
|
29
|
+
* once per `signIn()` invocation; the resolved string is passed
|
|
30
|
+
* through to the SDK. If omitted, `signIn()` must be called with
|
|
31
|
+
* a `nonce` override directly.
|
|
32
|
+
*/
|
|
33
|
+
getNonce?: () => Promise<string>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default `claims` payload, merged with the per-call override.
|
|
37
|
+
* Almost always `{ required: ['did'], optional: [...] }`.
|
|
38
|
+
*/
|
|
39
|
+
defaultClaims?: SignInRequest['claims'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Forwarded to the SDK. When `false`, throws
|
|
43
|
+
* `ERR_NO_WALLET_INSTALLED` raw instead of showing the install
|
|
44
|
+
* upsell. Default: SDK default (`true`).
|
|
45
|
+
*/
|
|
46
|
+
installUpsell?: boolean;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Forwarded to the SDK. Referral code attributed to signups that
|
|
50
|
+
* flow through the install upsell. Defaults to the hostname of
|
|
51
|
+
* `rpOrigin`. Pass `null` to opt out.
|
|
52
|
+
*/
|
|
53
|
+
ref?: string | null;
|
|
54
|
+
|
|
55
|
+
/** Forwarded to the SDK. */
|
|
56
|
+
timeoutMs?: number;
|
|
57
|
+
|
|
58
|
+
/** Forwarded to the SDK. */
|
|
59
|
+
nativeAppCallback?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SignInStatus = 'idle' | 'pending' | 'success' | 'error';
|
|
63
|
+
|
|
64
|
+
export interface SignInOverrides {
|
|
65
|
+
rpOrigin?: string;
|
|
66
|
+
nonce?: string;
|
|
67
|
+
claims?: SignInRequest['claims'];
|
|
68
|
+
installUpsell?: boolean;
|
|
69
|
+
ref?: string | null;
|
|
70
|
+
timeoutMs?: number;
|
|
71
|
+
nativeAppCallback?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface UseSignInReturn {
|
|
75
|
+
/**
|
|
76
|
+
* Fires a sign-in. Merges `overrides` over the hook-level
|
|
77
|
+
* options. Resolves to the result on success, `null` if the call
|
|
78
|
+
* was superseded by a newer signIn, or rejects with `SignInError`
|
|
79
|
+
* on failure.
|
|
80
|
+
*/
|
|
81
|
+
signIn(overrides?: SignInOverrides): Promise<SignInSuccess | null>;
|
|
82
|
+
/** Drops result/error and returns to idle. */
|
|
83
|
+
reset(): void;
|
|
84
|
+
status: SignInStatus;
|
|
85
|
+
/** True iff `status === 'pending'`. */
|
|
86
|
+
isLoading: boolean;
|
|
87
|
+
result: SignInSuccess | null;
|
|
88
|
+
error: SignInError | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function useSignIn(options?: UseSignInOptions): UseSignInReturn;
|
|
92
|
+
|
|
93
|
+
// ─── Hook: useResumePendingSignIn ──────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface UseResumeOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Called when a pending sign-in resumes and completes. RPs hand
|
|
98
|
+
* the JWT to their backend here.
|
|
99
|
+
*/
|
|
100
|
+
onSuccess?: (result: SignInSuccess) => void;
|
|
101
|
+
/**
|
|
102
|
+
* Called when a pending sign-in was found but its resume threw.
|
|
103
|
+
* `err.code` is the same set the regular signIn() throws.
|
|
104
|
+
*/
|
|
105
|
+
onError?: (err: SignInError) => void;
|
|
106
|
+
/**
|
|
107
|
+
* Called when there's no pending resume — most boots. Lets RPs
|
|
108
|
+
* record analytics on "did we hit the resume path on this boot"
|
|
109
|
+
* without watching the `noResume` flag.
|
|
110
|
+
*/
|
|
111
|
+
onNothingPending?: () => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface UseResumeReturn {
|
|
115
|
+
status: SignInStatus;
|
|
116
|
+
result: SignInSuccess | null;
|
|
117
|
+
error: SignInError | null;
|
|
118
|
+
/**
|
|
119
|
+
* `true` once we've finished checking and confirmed no pending
|
|
120
|
+
* sign-in was waiting. Useful for RPs that want to delay
|
|
121
|
+
* rendering their logged-out UI until the resume check is done.
|
|
122
|
+
*/
|
|
123
|
+
noResume: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function useResumePendingSignIn(options?: UseResumeOptions): UseResumeReturn;
|
|
127
|
+
|
|
128
|
+
// ─── Component: SignInButton ───────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export interface SignInButtonProps {
|
|
131
|
+
/** Defaults to `window.location.origin`. */
|
|
132
|
+
rpOrigin?: string;
|
|
133
|
+
/** Async fn returning a fresh single-use nonce from your backend. */
|
|
134
|
+
getNonce: () => Promise<string>;
|
|
135
|
+
/** Claim catalog. `{ required: [...], optional?: [...] }`. */
|
|
136
|
+
claims: SignInRequest['claims'];
|
|
137
|
+
/** Called with the resulting JWT. */
|
|
138
|
+
onSuccess: (result: SignInSuccess) => void;
|
|
139
|
+
/**
|
|
140
|
+
* Called when the user explicitly chose not to sign in
|
|
141
|
+
* (`user_denied` from the consent screen, `upsell_canceled` from
|
|
142
|
+
* the install upsell modal).
|
|
143
|
+
*/
|
|
144
|
+
onCancel?: (code: 'user_denied' | 'upsell_canceled') => void;
|
|
145
|
+
/**
|
|
146
|
+
* Called for any other failure path (timeout, wallet_unavailable,
|
|
147
|
+
* etc.). If omitted, errors are silently swallowed — RPs should
|
|
148
|
+
* usually pass at least a logger here.
|
|
149
|
+
*/
|
|
150
|
+
onError?: (err: SignInError) => void;
|
|
151
|
+
/** SDK option pass-through. */
|
|
152
|
+
installUpsell?: boolean;
|
|
153
|
+
/** SDK referral code; defaults to the hostname of `rpOrigin`. */
|
|
154
|
+
ref?: string | null;
|
|
155
|
+
/** SDK option pass-through. */
|
|
156
|
+
timeoutMs?: number;
|
|
157
|
+
/** SDK option pass-through. */
|
|
158
|
+
nativeAppCallback?: string;
|
|
159
|
+
/** Button label. Defaults to `"Sign in with Sovereign ID"`. */
|
|
160
|
+
children?: ReactNode;
|
|
161
|
+
/**
|
|
162
|
+
* When set, the default inline styles are NOT applied; the RP has
|
|
163
|
+
* full control via CSS.
|
|
164
|
+
*/
|
|
165
|
+
className?: string;
|
|
166
|
+
/**
|
|
167
|
+
* Merged with the default inline styles when `className` is not
|
|
168
|
+
* provided.
|
|
169
|
+
*/
|
|
170
|
+
style?: CSSProperties;
|
|
171
|
+
/**
|
|
172
|
+
* Explicitly disable the button. The button is also disabled
|
|
173
|
+
* internally while a sign-in is in flight.
|
|
174
|
+
*/
|
|
175
|
+
disabled?: boolean;
|
|
176
|
+
/** Spread onto the underlying `<button>`. */
|
|
177
|
+
buttonProps?: Omit<
|
|
178
|
+
ButtonHTMLAttributes<HTMLButtonElement>,
|
|
179
|
+
'onClick' | 'disabled' | 'type' | 'style' | 'className' | 'children'
|
|
180
|
+
>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function SignInButton(props: SignInButtonProps): JSX.Element;
|
|
184
|
+
|
|
185
|
+
// ─── Re-exports from @matanetwork/sovereign-id ────────────────────────────
|
|
186
|
+
|
|
187
|
+
export {
|
|
188
|
+
SignInError,
|
|
189
|
+
hasExtension,
|
|
190
|
+
showInstallUpsell,
|
|
191
|
+
pickInstallCta,
|
|
192
|
+
defaultRefFromOrigin,
|
|
193
|
+
clearPendingSignIn,
|
|
194
|
+
resumePendingSignIn,
|
|
195
|
+
signIn,
|
|
196
|
+
PROTOCOL_VERSION,
|
|
197
|
+
WINDOW_MID_GLOBAL,
|
|
198
|
+
MESSAGE_DISCRIMINATOR,
|
|
199
|
+
KIND_SIGN_IN_REQUEST,
|
|
200
|
+
KIND_SIGN_IN_RESPONSE,
|
|
201
|
+
URL_SCHEME,
|
|
202
|
+
SCHEME_PATH_REQUEST,
|
|
203
|
+
QUERY_PARAM_PAYLOAD,
|
|
204
|
+
FRAGMENT_KEY_RESPONSE,
|
|
205
|
+
ERR_USER_DENIED,
|
|
206
|
+
ERR_ORIGIN_MISMATCH,
|
|
207
|
+
ERR_INVALID_REQUEST,
|
|
208
|
+
ERR_WALLET_UNAVAILABLE,
|
|
209
|
+
ERR_REQUIRED_CLAIM_UNAVAILABLE,
|
|
210
|
+
ERR_INTERNAL,
|
|
211
|
+
ERR_NO_WALLET_INSTALLED,
|
|
212
|
+
ERR_TIMEOUT,
|
|
213
|
+
ERR_UPSELL_CANCELED,
|
|
214
|
+
} from '@matanetwork/sovereign-id';
|
|
215
|
+
|
|
216
|
+
export type {
|
|
217
|
+
SignInRequest,
|
|
218
|
+
SignInSuccess,
|
|
219
|
+
ErrorCode,
|
|
220
|
+
CustomClaim,
|
|
221
|
+
InstallCta,
|
|
222
|
+
InstallUpsellOptions,
|
|
223
|
+
InstallUpsellResult,
|
|
224
|
+
SignInOptions,
|
|
225
|
+
} from '@matanetwork/sovereign-id';
|
package/src/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @matanetwork/sovereign-id-react — React adapter for the
|
|
3
|
+
* `@matanetwork/sovereign-id` SDK.
|
|
4
|
+
*
|
|
5
|
+
* Three entry points:
|
|
6
|
+
*
|
|
7
|
+
* - [`<SignInButton/>`](./SignInButton.js) — drop-in styled button.
|
|
8
|
+
* Three required props: `getNonce`, `claims`, `onSuccess`.
|
|
9
|
+
*
|
|
10
|
+
* - [`useSignIn`](./useSignIn.js) — hook for RPs that want their
|
|
11
|
+
* own button. Returns `{ signIn, status, isLoading, result, error,
|
|
12
|
+
* reset }`.
|
|
13
|
+
*
|
|
14
|
+
* - [`useResumePendingSignIn`](./useResumePendingSignIn.js) — boot-
|
|
15
|
+
* time hook. Call once at the root of your app to handle the
|
|
16
|
+
* resume-after-reload path that the install upsell sets up.
|
|
17
|
+
*
|
|
18
|
+
* Everything else (`SignInError`, error codes, `defaultRefFromOrigin`,
|
|
19
|
+
* etc.) is re-exported from `@matanetwork/sovereign-id` so RPs only
|
|
20
|
+
* need one import line.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export { SignInButton } from './SignInButton.js';
|
|
24
|
+
export { useSignIn } from './useSignIn.js';
|
|
25
|
+
export { useResumePendingSignIn } from './useResumePendingSignIn.js';
|
|
26
|
+
|
|
27
|
+
// Re-export the core SDK surface so RPs don't have to install both
|
|
28
|
+
// packages just to type their callbacks against `SignInError`.
|
|
29
|
+
export {
|
|
30
|
+
SignInError,
|
|
31
|
+
hasExtension,
|
|
32
|
+
showInstallUpsell,
|
|
33
|
+
pickInstallCta,
|
|
34
|
+
defaultRefFromOrigin,
|
|
35
|
+
clearPendingSignIn,
|
|
36
|
+
resumePendingSignIn,
|
|
37
|
+
signIn,
|
|
38
|
+
PROTOCOL_VERSION,
|
|
39
|
+
WINDOW_MID_GLOBAL,
|
|
40
|
+
MESSAGE_DISCRIMINATOR,
|
|
41
|
+
KIND_SIGN_IN_REQUEST,
|
|
42
|
+
KIND_SIGN_IN_RESPONSE,
|
|
43
|
+
URL_SCHEME,
|
|
44
|
+
SCHEME_PATH_REQUEST,
|
|
45
|
+
QUERY_PARAM_PAYLOAD,
|
|
46
|
+
FRAGMENT_KEY_RESPONSE,
|
|
47
|
+
ERR_USER_DENIED,
|
|
48
|
+
ERR_ORIGIN_MISMATCH,
|
|
49
|
+
ERR_INVALID_REQUEST,
|
|
50
|
+
ERR_WALLET_UNAVAILABLE,
|
|
51
|
+
ERR_REQUIRED_CLAIM_UNAVAILABLE,
|
|
52
|
+
ERR_INTERNAL,
|
|
53
|
+
ERR_NO_WALLET_INSTALLED,
|
|
54
|
+
ERR_TIMEOUT,
|
|
55
|
+
ERR_UPSELL_CANCELED,
|
|
56
|
+
} from '@matanetwork/sovereign-id';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useResumePendingSignIn` — React hook around
|
|
3
|
+
* `resumePendingSignIn()`.
|
|
4
|
+
*
|
|
5
|
+
* Call once at the root of your app. The hook fires
|
|
6
|
+
* `resumePendingSignIn()` on first mount, manages the resolution
|
|
7
|
+
* state, and invokes `onSuccess` / `onError` (if provided) when the
|
|
8
|
+
* resume completes.
|
|
9
|
+
*
|
|
10
|
+
* ## What it solves
|
|
11
|
+
*
|
|
12
|
+
* The install-upsell flow stashes a pending sign-in in
|
|
13
|
+
* `sessionStorage` so it survives a page reload (common when Chrome
|
|
14
|
+
* prompts the user to reload after extension install). On the next
|
|
15
|
+
* boot, the SDK's `resumePendingSignIn()` picks the flow back up. RPs
|
|
16
|
+
* that want this resume path need to call it explicitly — this hook
|
|
17
|
+
* makes that "drop it in App.jsx and forget" simple.
|
|
18
|
+
*
|
|
19
|
+
* ## Idempotent at boot
|
|
20
|
+
*
|
|
21
|
+
* Internally guards with a ref so React 18's strict-mode
|
|
22
|
+
* double-invoke doesn't fire two parallel resume calls. Whichever
|
|
23
|
+
* one wins commits; the other's result is dropped.
|
|
24
|
+
*
|
|
25
|
+
* ## Status reporting
|
|
26
|
+
*
|
|
27
|
+
* Exposes the same `status` shape as `useSignIn` so RPs that mount
|
|
28
|
+
* loading UI can share the rendering branch:
|
|
29
|
+
*
|
|
30
|
+
* - `idle` — hook hasn't started checking yet (one paint at most).
|
|
31
|
+
* - `pending` — `resumePendingSignIn()` is in flight.
|
|
32
|
+
* - `success` — a resume returned a JWT. Result is in `.result`.
|
|
33
|
+
* - `error` — a resume found a pending request and the SDK threw
|
|
34
|
+
* while completing it. Error is in `.error`.
|
|
35
|
+
* - `idle` (with `noResume: true`) — explicitly: no pending
|
|
36
|
+
* resume was found.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { useEffect, useRef, useState } from 'react';
|
|
40
|
+
import {
|
|
41
|
+
resumePendingSignIn as sdkResume,
|
|
42
|
+
SignInError,
|
|
43
|
+
} from '@matanetwork/sovereign-id';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {object} UseResumeOptions
|
|
47
|
+
* @property {(result: {jwt: string, surface: 'extension' | 'native_app'}) => void} [onSuccess]
|
|
48
|
+
* Called when a pending sign-in resumes and completes. RPs hand
|
|
49
|
+
* the JWT to their backend here.
|
|
50
|
+
* @property {(err: SignInError) => void} [onError]
|
|
51
|
+
* Called when a pending sign-in was found but its resume threw.
|
|
52
|
+
* `err.code` is the same set the regular signIn() throws.
|
|
53
|
+
* @property {() => void} [onNothingPending]
|
|
54
|
+
* Called when there's no pending resume — most boots. Lets RPs
|
|
55
|
+
* telemetry "did we hit the resume path on this boot" without
|
|
56
|
+
* watching the `noResume` flag.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} UseResumeReturn
|
|
61
|
+
* @property {'idle' | 'pending' | 'success' | 'error'} status
|
|
62
|
+
* @property {{jwt: string, surface: 'extension' | 'native_app'} | null} result
|
|
63
|
+
* @property {SignInError | null} error
|
|
64
|
+
* @property {boolean} noResume
|
|
65
|
+
* `true` once we've finished checking and confirmed no pending
|
|
66
|
+
* sign-in was waiting. Useful for RPs that want to delay
|
|
67
|
+
* rendering their logged-out UI until the resume check is done
|
|
68
|
+
* (avoids a flicker through the logged-out state when the
|
|
69
|
+
* resume succeeds).
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {UseResumeOptions} [options]
|
|
74
|
+
* @returns {UseResumeReturn}
|
|
75
|
+
*/
|
|
76
|
+
export function useResumePendingSignIn(options = {}) {
|
|
77
|
+
const [state, setState] = useState({
|
|
78
|
+
status: 'idle',
|
|
79
|
+
result: null,
|
|
80
|
+
error: null,
|
|
81
|
+
noResume: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Guard against React 18 strict-mode double-mount. The first mount
|
|
85
|
+
// fires the resume; the dev-mode second mount is a no-op.
|
|
86
|
+
const hasStartedRef = useRef(false);
|
|
87
|
+
|
|
88
|
+
// Keep callbacks in a ref so the boot effect doesn't need to
|
|
89
|
+
// re-fire when the parent re-renders with new inline callbacks.
|
|
90
|
+
const optionsRef = useRef(options);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
optionsRef.current = options;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (hasStartedRef.current) return;
|
|
97
|
+
hasStartedRef.current = true;
|
|
98
|
+
|
|
99
|
+
let cancelled = false;
|
|
100
|
+
setState((s) => ({ ...s, status: 'pending' }));
|
|
101
|
+
|
|
102
|
+
(async () => {
|
|
103
|
+
try {
|
|
104
|
+
const result = await sdkResume();
|
|
105
|
+
if (cancelled) return;
|
|
106
|
+
if (result) {
|
|
107
|
+
setState({
|
|
108
|
+
status: 'success',
|
|
109
|
+
result,
|
|
110
|
+
error: null,
|
|
111
|
+
noResume: false,
|
|
112
|
+
});
|
|
113
|
+
optionsRef.current.onSuccess?.(result);
|
|
114
|
+
} else {
|
|
115
|
+
setState({
|
|
116
|
+
status: 'idle',
|
|
117
|
+
result: null,
|
|
118
|
+
error: null,
|
|
119
|
+
noResume: true,
|
|
120
|
+
});
|
|
121
|
+
optionsRef.current.onNothingPending?.();
|
|
122
|
+
}
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (cancelled) return;
|
|
125
|
+
const err =
|
|
126
|
+
e instanceof SignInError
|
|
127
|
+
? e
|
|
128
|
+
: new SignInError(
|
|
129
|
+
'internal_error',
|
|
130
|
+
`useResumePendingSignIn: unexpected error: ${e?.message ?? e}`
|
|
131
|
+
);
|
|
132
|
+
setState({
|
|
133
|
+
status: 'error',
|
|
134
|
+
result: null,
|
|
135
|
+
error: err,
|
|
136
|
+
noResume: false,
|
|
137
|
+
});
|
|
138
|
+
optionsRef.current.onError?.(err);
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
cancelled = true;
|
|
144
|
+
};
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
return state;
|
|
148
|
+
}
|
package/src/useSignIn.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useSignIn` — React hook around `@matanetwork/sovereign-id`'s `signIn`.
|
|
3
|
+
*
|
|
4
|
+
* Manages the full state machine of a sign-in attempt: idle → pending
|
|
5
|
+
* → (success | error). Exposes `signIn(extraRequest)` for the click
|
|
6
|
+
* handler, `reset()` for a clean retry, and the current status fields
|
|
7
|
+
* for rendering.
|
|
8
|
+
*
|
|
9
|
+
* Use this when you want full control over the button / link that
|
|
10
|
+
* triggers sign-in. If you just want a drop-in button, use
|
|
11
|
+
* `<SignInButton/>` from the same package.
|
|
12
|
+
*
|
|
13
|
+
* ## State machine
|
|
14
|
+
*
|
|
15
|
+
* idle ──signIn()──▶ pending ──┬──▶ success (result populated)
|
|
16
|
+
* └──▶ error (error populated)
|
|
17
|
+
*
|
|
18
|
+
* Any of those terminal states can transition back to `idle` via
|
|
19
|
+
* `reset()` or to `pending` again via a fresh `signIn()` call.
|
|
20
|
+
*
|
|
21
|
+
* ## Why a hook (not a one-shot helper)
|
|
22
|
+
*
|
|
23
|
+
* Sign-in is async, the UI needs to reflect three distinct phases
|
|
24
|
+
* (idle / pending / done), and the result drives downstream effects
|
|
25
|
+
* (call `/api/auth/mid`, set session, navigate). A hook is the
|
|
26
|
+
* idiomatic React shape for "stateful async operation."
|
|
27
|
+
*
|
|
28
|
+
* ## Stale-call protection
|
|
29
|
+
*
|
|
30
|
+
* If a component re-renders mid-flight or the user clicks the button
|
|
31
|
+
* twice in a row, only the **most recent** signIn() call resolves into
|
|
32
|
+
* state. Earlier calls' results are dropped (their promises still
|
|
33
|
+
* resolve, but their setStates are gated by a request-id check). This
|
|
34
|
+
* prevents a slow first call from clobbering a fast second call's
|
|
35
|
+
* result — common bug class for naive async hooks.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
39
|
+
import { signIn as sdkSignIn, SignInError } from '@matanetwork/sovereign-id';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} UseSignInOptions
|
|
43
|
+
* @property {string} [rpOrigin] - Default `rpOrigin` used by `signIn()`.
|
|
44
|
+
* If omitted, falls back to `window.location.origin`. RPs running
|
|
45
|
+
* SSR should set this explicitly.
|
|
46
|
+
* @property {() => Promise<string>} [getNonce] - Async function that
|
|
47
|
+
* returns a fresh single-use nonce. Called once per `signIn()`
|
|
48
|
+
* invocation; the resolved string is passed through to the SDK.
|
|
49
|
+
* If omitted, `signIn()` must be called with a `nonce` directly.
|
|
50
|
+
* @property {object} [defaultClaims] - Default `claims` payload, merged
|
|
51
|
+
* with the per-call override.
|
|
52
|
+
* @property {boolean} [installUpsell] - Forwarded to the SDK.
|
|
53
|
+
* @property {string | null} [ref] - Forwarded to the SDK. See the
|
|
54
|
+
* `defaultRefFromOrigin` docs for the auto-derive behavior.
|
|
55
|
+
* @property {number} [timeoutMs] - Forwarded to the SDK.
|
|
56
|
+
* @property {string} [nativeAppCallback] - Forwarded to the SDK.
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} UseSignInState
|
|
61
|
+
* @property {'idle' | 'pending' | 'success' | 'error'} status
|
|
62
|
+
* @property {boolean} isLoading - True iff `status === 'pending'`.
|
|
63
|
+
* @property {{jwt: string, surface: 'extension' | 'native_app'} | null} result
|
|
64
|
+
* @property {SignInError | null} error
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} UseSignInReturn
|
|
69
|
+
* @property {(overrides?: object) => Promise<{jwt: string, surface: 'extension' | 'native_app'} | null>} signIn
|
|
70
|
+
* Fires a sign-in. Merges `overrides` over the hook-level options;
|
|
71
|
+
* `overrides.nonce` overrides `getNonce()`. Resolves to the result
|
|
72
|
+
* on success, `null` if the call was superseded by a newer signIn,
|
|
73
|
+
* or rejects with `SignInError` on failure.
|
|
74
|
+
* @property {() => void} reset - Drops result/error and returns to idle.
|
|
75
|
+
* @property {'idle' | 'pending' | 'success' | 'error'} status
|
|
76
|
+
* @property {boolean} isLoading
|
|
77
|
+
* @property {{jwt: string, surface: 'extension' | 'native_app'} | null} result
|
|
78
|
+
* @property {SignInError | null} error
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {UseSignInOptions} [options]
|
|
83
|
+
* @returns {UseSignInReturn}
|
|
84
|
+
*/
|
|
85
|
+
export function useSignIn(options = {}) {
|
|
86
|
+
const [state, setState] = useState({
|
|
87
|
+
status: 'idle',
|
|
88
|
+
result: null,
|
|
89
|
+
error: null,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Stale-call protection: every signIn() bumps this counter; the
|
|
93
|
+
// resulting promise only commits its outcome to state if it matches
|
|
94
|
+
// the latest counter value at resolve time. Stops a slow first call
|
|
95
|
+
// from overwriting a fast second call.
|
|
96
|
+
const currentRequestIdRef = useRef(0);
|
|
97
|
+
|
|
98
|
+
// Mount-tracking so we don't `setState` after unmount (React 18
|
|
99
|
+
// tolerates it, but it still logs a warning). The ref is set by the
|
|
100
|
+
// cleanup function returned from useEffect.
|
|
101
|
+
const isMountedRef = useRef(true);
|
|
102
|
+
useEffect(
|
|
103
|
+
() => () => {
|
|
104
|
+
isMountedRef.current = false;
|
|
105
|
+
},
|
|
106
|
+
[]
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Refs over options so the `signIn` callback doesn't have to be
|
|
110
|
+
// recreated every render when the caller passes inline objects.
|
|
111
|
+
// Stable reference → safe to use as a dep in the caller's own
|
|
112
|
+
// useEffect/useMemo.
|
|
113
|
+
const optionsRef = useRef(options);
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
optionsRef.current = options;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const signIn = useCallback(async (overrides = {}) => {
|
|
119
|
+
const myRequestId = ++currentRequestIdRef.current;
|
|
120
|
+
setState({ status: 'pending', result: null, error: null });
|
|
121
|
+
|
|
122
|
+
const opts = optionsRef.current;
|
|
123
|
+
|
|
124
|
+
// Resolve nonce: caller override > hook-level getNonce().
|
|
125
|
+
let nonce = overrides.nonce;
|
|
126
|
+
if (typeof nonce !== 'string' || nonce.length === 0) {
|
|
127
|
+
if (typeof opts.getNonce !== 'function') {
|
|
128
|
+
const err = new SignInError(
|
|
129
|
+
'invalid_request',
|
|
130
|
+
'useSignIn: no nonce provided and no `getNonce` configured.'
|
|
131
|
+
);
|
|
132
|
+
// Commit only if still latest.
|
|
133
|
+
if (
|
|
134
|
+
currentRequestIdRef.current === myRequestId &&
|
|
135
|
+
isMountedRef.current
|
|
136
|
+
) {
|
|
137
|
+
setState({ status: 'error', result: null, error: err });
|
|
138
|
+
}
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
nonce = await opts.getNonce();
|
|
143
|
+
} catch (e) {
|
|
144
|
+
const err =
|
|
145
|
+
e instanceof SignInError
|
|
146
|
+
? e
|
|
147
|
+
: new SignInError(
|
|
148
|
+
'invalid_request',
|
|
149
|
+
`useSignIn: getNonce() threw: ${e?.message ?? e}`
|
|
150
|
+
);
|
|
151
|
+
if (
|
|
152
|
+
currentRequestIdRef.current === myRequestId &&
|
|
153
|
+
isMountedRef.current
|
|
154
|
+
) {
|
|
155
|
+
setState({ status: 'error', result: null, error: err });
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Resolve rpOrigin.
|
|
162
|
+
const rpOrigin =
|
|
163
|
+
overrides.rpOrigin ??
|
|
164
|
+
opts.rpOrigin ??
|
|
165
|
+
(typeof window !== 'undefined' ? window.location.origin : undefined);
|
|
166
|
+
|
|
167
|
+
// Resolve claims. Caller-level overrides win field-by-field.
|
|
168
|
+
const claims = overrides.claims ??
|
|
169
|
+
opts.defaultClaims ?? { required: ['did'] };
|
|
170
|
+
|
|
171
|
+
const request = { rpOrigin, nonce, claims };
|
|
172
|
+
|
|
173
|
+
const sdkOpts = {
|
|
174
|
+
installUpsell:
|
|
175
|
+
overrides.installUpsell ?? opts.installUpsell ?? undefined,
|
|
176
|
+
ref: overrides.ref ?? opts.ref,
|
|
177
|
+
timeoutMs: overrides.timeoutMs ?? opts.timeoutMs ?? undefined,
|
|
178
|
+
nativeAppCallback:
|
|
179
|
+
overrides.nativeAppCallback ?? opts.nativeAppCallback ?? undefined,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const result = await sdkSignIn(request, sdkOpts);
|
|
184
|
+
if (
|
|
185
|
+
currentRequestIdRef.current === myRequestId &&
|
|
186
|
+
isMountedRef.current
|
|
187
|
+
) {
|
|
188
|
+
setState({ status: 'success', result, error: null });
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
} catch (e) {
|
|
192
|
+
const err =
|
|
193
|
+
e instanceof SignInError
|
|
194
|
+
? e
|
|
195
|
+
: new SignInError(
|
|
196
|
+
'internal_error',
|
|
197
|
+
`useSignIn: unexpected error: ${e?.message ?? e}`
|
|
198
|
+
);
|
|
199
|
+
if (
|
|
200
|
+
currentRequestIdRef.current === myRequestId &&
|
|
201
|
+
isMountedRef.current
|
|
202
|
+
) {
|
|
203
|
+
setState({ status: 'error', result: null, error: err });
|
|
204
|
+
}
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
209
|
+
const reset = useCallback(() => {
|
|
210
|
+
currentRequestIdRef.current++;
|
|
211
|
+
setState({ status: 'idle', result: null, error: null });
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
signIn,
|
|
216
|
+
reset,
|
|
217
|
+
status: state.status,
|
|
218
|
+
isLoading: state.status === 'pending',
|
|
219
|
+
result: state.result,
|
|
220
|
+
error: state.error,
|
|
221
|
+
};
|
|
222
|
+
}
|