@auth-link/web 0.0.5 → 2.0.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/CHANGELOG.md +27 -0
- package/README.md +111 -759
- package/dist/index.js +466 -178
- package/dist/index.js.map +3 -3
- package/dist/react.js +466 -177
- package/dist/react.js.map +3 -3
- package/dist/styles.css +11 -49
- package/dist/types/core.d.ts +2 -0
- package/dist/types/i18n.d.ts +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/modal.d.ts +3 -3
- package/dist/types/passcode.d.ts +28 -0
- package/dist/types/react.d.ts +5 -1
- package/dist/types/types.d.ts +2 -4
- package/dist/types/view.d.ts +5 -4
- package/dist/types/vue.d.ts +5 -1
- package/dist/vue.js +479 -176
- package/dist/vue.js.map +3 -3
- package/docs/README.en.md +39 -255
- package/docs/README.ja.md +39 -255
- package/docs/README.zh-CN.md +39 -255
- package/examples/react-client.tsx +16 -34
- package/examples/vue-client.vue +22 -46
- package/package.json +2 -2
package/docs/README.zh-CN.md
CHANGED
|
@@ -1,282 +1,66 @@
|
|
|
1
|
-
# AuthLink Web
|
|
1
|
+
# AuthLink Web 2.0
|
|
2
2
|
|
|
3
|
-
[English](
|
|
3
|
+
[English](README.en.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## 从 1.x 升级
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
所有弹窗统一使用极简布局。请删除 variant 参数或组件属性、modal.setVariant() 调用和 DialogVariant 类型导入。语言切换与验证回调保持不变。
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
npm install @auth-link/web@latest
|
|
11
|
-
# 另行安装你的项目使用的 vue,或 react / react-dom。
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
| 地址 | 前端配置 | 用途 |
|
|
15
|
-
| --- | --- | --- |
|
|
16
|
-
| 你的网站业务后台,如 `https://cms-api.example.com` | `baseUrl`、`allowedBackendOrigins` | 登录状态、创建请求和查询结果 |
|
|
17
|
-
| AuthLink 生产 API:`https://api.getauthlink.com` | `authLinkOrigin` | 核对 ticket 中的 AuthLink 服务来源 |
|
|
18
|
-
|
|
19
|
-
`baseUrl` 不是 AuthLink API,也不是 AuthLink 官网。接入方的网站和业务 API 仍使用自己的域名。需要已有的网站登录状态和下文的请求接口;Web SDK 不提供登录或会话接口。租户密钥不能放到浏览器。
|
|
9
|
+
## 直接验证动态码
|
|
20
10
|
|
|
21
|
-
|
|
11
|
+
App 直接显示六位动态码。日常验证不需要逐次批准、授权 requestId、businessId 或轮询。首次绑定仍需扫码验证设备,在网站输入 App 当前动态码后才完成绑定。
|
|
22
12
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
```ts
|
|
26
|
-
import { createAuthLinkClient } from '@auth-link/web';
|
|
27
|
-
import '@auth-link/web/styles.css';
|
|
13
|
+
打开弹窗前,CMS 后台必须为会话原子保留唯一且不可变的待办。其他标签页不能覆盖它。用户来自服务端会话,后台检查 CSRF、业务权限和原表单,验证动态码后将 verificationId 与原操作原子提交,再返回成功。onVerified 只更新界面。
|
|
28
14
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return createAuthLinkClient({
|
|
32
|
-
baseUrl: backendOrigin ? `${backendOrigin}/api/authlink` : '/api/authlink',
|
|
33
|
-
allowedBackendOrigins: backendOrigin ? [backendOrigin] : [],
|
|
34
|
-
credentials: backendOrigin ? 'include' : 'same-origin',
|
|
35
|
-
csrfToken: getCsrf,
|
|
36
|
-
authLinkOrigin: 'https://api.getauthlink.com',
|
|
37
|
-
});
|
|
38
|
-
}
|
|
15
|
+
```sh
|
|
16
|
+
npm install @auth-link/web@^2.0.0
|
|
39
17
|
```
|
|
40
18
|
|
|
41
|
-
先完成你的网站登录,再读取应用已有的会话接口。下面使用与 CMS 一致的 `{authenticated, csrf}` 响应;如果你的后台字段名为 csrfToken,读取它再交给 SDK 的 csrfToken 回调即可。
|
|
42
|
-
|
|
43
19
|
```ts
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
20
|
+
import { openPasscode, createPasscodeClient } from '@auth-link/web';
|
|
21
|
+
import '@auth-link/web/styles.css';
|
|
22
|
+
const client = createPasscodeClient({ baseUrl: '/api', csrfToken: () => csrfToken });
|
|
23
|
+
const modal = openPasscode({
|
|
24
|
+
client, expiresAt: pending.expiresAt, locale: 'zh-CN',
|
|
25
|
+
// First-time enrollment only:
|
|
26
|
+
// bindingQr: pending.bindingQr, authLinkOrigin: 'https://api.getauthlink.com',
|
|
27
|
+
onVerified: result => updateUI(result.verificationId),
|
|
48
28
|
});
|
|
49
|
-
|
|
50
|
-
let session = await response.json();
|
|
51
|
-
if (session.authenticated !== true || typeof session.csrf !== 'string' || !session.csrf) {
|
|
52
|
-
throw new Error('Log in to your website first');
|
|
53
|
-
}
|
|
54
|
-
const client = createWebsiteClient(() => session.csrf, backendOrigin);
|
|
55
|
-
// Same origin: createWebsiteClient(() => session.csrf)
|
|
56
|
-
// After token refresh, replace session or update session.csrf.
|
|
57
|
-
// On logout/account/session change, close/unmount old dialogs and recreate client state.
|
|
29
|
+
// modal.setLocale('en'); modal.destroy();
|
|
58
30
|
```
|
|
59
31
|
|
|
60
|
-
|
|
32
|
+
默认客户端向 CMS 的 POST /api/auth 提交 {passcode},携带当前会话与 CSRF。要随表单提交,可自行实现 client.verify,向 CMS 发送 {form,passcode};后台核对原保存快照。租户密钥与动态码密钥不能进入浏览器。
|
|
61
33
|
|
|
62
|
-
|
|
34
|
+
## 选项与回调
|
|
63
35
|
|
|
64
|
-
|
|
65
|
-
const backendOrigin = 'https://cms-api.example.com'; // YOUR application backend
|
|
66
|
-
const client = createAuthLinkClient({
|
|
67
|
-
baseUrl: `${backendOrigin}/api/authlink`, allowedBackendOrigins: [backendOrigin],
|
|
68
|
-
credentials: 'omit', accessToken: () => auth.accessToken,
|
|
69
|
-
authLinkOrigin: 'https://api.getauthlink.com',
|
|
70
|
-
});
|
|
71
|
-
```
|
|
36
|
+
必填:client.verify(passcode, signal)、expiresAt(CMS 待办截止时间)。可选:locale、仅首次绑定使用的 bindingQr 与 authLinkOrigin、onVerified、onError、onClose、onExpired。成功结果含 status、verificationId、authenticationPolicy、expiresAt,不含 requestId 或 businessId。
|
|
72
37
|
|
|
73
|
-
|
|
38
|
+
Cookie 模式必须配置 CSRF。Bearer 模式配置 accessToken 且 credentials=omit。跨域 CMS 必须显式配置 allowedBackendOrigins;回环开发之外使用 HTTPS。
|
|
74
39
|
|
|
75
|
-
|
|
40
|
+
## React 与 Vue
|
|
76
41
|
|
|
77
|
-
|
|
78
|
-
<script setup lang="ts">
|
|
79
|
-
import { markRaw, onUnmounted, ref, shallowRef } from 'vue';
|
|
80
|
-
import { createAuthLinkClient, requestKey } from '@auth-link/web';
|
|
81
|
-
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
82
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
83
|
-
import '@auth-link/web/styles.css';
|
|
42
|
+
React 组件使用相同选项,Vue 发出 verified、error、close、expired 事件。client 引用需保持稳定。支持 13 种语言,默认简体中文;统一使用响应式极简布局。
|
|
84
43
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
allowedBackendOrigins: props.backendOrigin ? [props.backendOrigin] : [],
|
|
95
|
-
credentials: props.backendOrigin ? 'include' : 'same-origin',
|
|
96
|
-
csrfToken: () => props.csrf,
|
|
97
|
-
authLinkOrigin: 'https://api.getauthlink.com',
|
|
98
|
-
}));
|
|
99
|
-
function finish(text: string) { ticket.value = null; message.value = text; }
|
|
100
|
-
function confirmed(result: Confirmation) {
|
|
101
|
-
finish(`${result.purpose}: ${result.requestId}`);
|
|
102
|
-
// Reload your website's account/status API if the page needs fresh state.
|
|
103
|
-
}
|
|
104
|
-
function showError(error: Error) { message.value = error.message; }
|
|
105
|
-
async function start(mode: Mode) {
|
|
106
|
-
if (busy.value || ticket.value) return;
|
|
107
|
-
busy.value = true; message.value = '';
|
|
108
|
-
try {
|
|
109
|
-
if (creationMode !== mode) { creationKey = null; creationMode = mode; }
|
|
110
|
-
creationKey ??= requestKey();
|
|
111
|
-
const value = await client.create(mode, { idempotencyKey: creationKey, signal: lifetime.signal });
|
|
112
|
-
if (lifetime.signal.aborted) return;
|
|
113
|
-
ticket.value = value; creationKey = null;
|
|
114
|
-
} catch (error) {
|
|
115
|
-
if (!lifetime.signal.aborted) showError(error as Error);
|
|
116
|
-
// Keep the same key when retrying an uncertain creation of the same mode.
|
|
117
|
-
} finally { busy.value = false; }
|
|
118
|
-
}
|
|
119
|
-
onUnmounted(() => { lifetime.abort(); ticket.value = null; creationKey = null; });
|
|
44
|
+
```tsx
|
|
45
|
+
import { AuthLinkPasscode } from '@auth-link/web/react';
|
|
46
|
+
// Keep client stable (for example with useMemo).
|
|
47
|
+
<AuthLinkPasscode client={client} expiresAt={pending.expiresAt}
|
|
48
|
+
locale="zh-CN" onVerified={updateUI} />
|
|
49
|
+
```
|
|
50
|
+
```vue
|
|
51
|
+
<script setup>
|
|
52
|
+
import { AuthLinkPasscode } from '@auth-link/web/vue';
|
|
120
53
|
</script>
|
|
121
|
-
|
|
122
54
|
<template>
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
<AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
|
|
126
|
-
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
127
|
-
@expired="finish('Expired; start again')" @error="showError" />
|
|
128
|
-
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
129
|
-
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
130
|
-
@expired="finish('Expired; start again')" @error="showError" />
|
|
131
|
-
<p role="status">{{ message }}</p>
|
|
55
|
+
<AuthLinkPasscode :client="client" :expires-at="pending.expiresAt"
|
|
56
|
+
locale="zh-CN" @verified="updateUI" />
|
|
132
57
|
</template>
|
|
133
58
|
```
|
|
134
59
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
## 4. React
|
|
138
|
-
|
|
139
|
-
使用上面的初始化方式创建稳定 client,再传给此组件;mode 为 bind 或 confirm。退出或切换账号/session 时卸载旧实例。
|
|
140
|
-
|
|
141
|
-
```tsx
|
|
142
|
-
"use client";
|
|
143
|
-
import { useEffect, useRef, useState } from 'react';
|
|
144
|
-
import { requestKey } from '@auth-link/web';
|
|
145
|
-
import type { AuthLinkClient, Mode, Ticket } from '@auth-link/web';
|
|
146
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/react';
|
|
147
|
-
// Import '@auth-link/web/styles.css' once in your application entry.
|
|
148
|
-
|
|
149
|
-
export function AuthLinkPanel({ client, mode }: { client: AuthLinkClient; mode: Mode }) {
|
|
150
|
-
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
151
|
-
const [busy, setBusy] = useState(false);
|
|
152
|
-
const [message, setMessage] = useState('');
|
|
153
|
-
const creation = useRef<{ mode: Mode; key: string } | null>(null);
|
|
154
|
-
const inFlight = useRef(false);
|
|
155
|
-
const controller = useRef<AbortController | null>(null);
|
|
156
|
-
useEffect(() => () => { controller.current?.abort(); }, []);
|
|
157
|
-
function finish(text: string) { setTicket(null); setMessage(text); }
|
|
158
|
-
async function start() {
|
|
159
|
-
if (inFlight.current || ticket) return;
|
|
160
|
-
inFlight.current = true; setBusy(true); setMessage('');
|
|
161
|
-
const attempt = new AbortController(); controller.current = attempt;
|
|
162
|
-
try {
|
|
163
|
-
if (creation.current?.mode !== mode) creation.current = { mode, key: requestKey() };
|
|
164
|
-
const value = await client.create(mode, { idempotencyKey: creation.current.key, signal: attempt.signal });
|
|
165
|
-
if (attempt.signal.aborted) return;
|
|
166
|
-
setTicket(value); creation.current = null;
|
|
167
|
-
} catch (error) { if (!attempt.signal.aborted) setMessage((error as Error).message); }
|
|
168
|
-
finally {
|
|
169
|
-
inFlight.current = false;
|
|
170
|
-
if (!attempt.signal.aborted) setBusy(false);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
const Component = mode === 'bind' ? AuthLinkBind : AuthLinkConfirm;
|
|
174
|
-
return <>
|
|
175
|
-
{!ticket && <button disabled={busy} onClick={() => void start()}>Start verification</button>}
|
|
176
|
-
{ticket && <Component ticket={ticket} client={client} variant="B"
|
|
177
|
-
onConfirmed={result => finish(`${result.purpose}: ${result.requestId}`)}
|
|
178
|
-
onClose={() => finish('Closed')} onRejected={() => finish('Rejected')}
|
|
179
|
-
onExpired={() => finish('Expired; start again')}
|
|
180
|
-
onError={error => setMessage(error.message)} />}
|
|
181
|
-
<p role="status">{message}</p>
|
|
182
|
-
</>;
|
|
183
|
-
}
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
## 5. 原生 JavaScript / 直接调用
|
|
187
|
-
|
|
188
|
-
```ts
|
|
189
|
-
import { openAuthLinkConfirm, requestKey } from '@auth-link/web';
|
|
190
|
-
import '@auth-link/web/styles.css';
|
|
191
|
-
|
|
192
|
-
// Inside a click handler; client is the stable instance created above.
|
|
193
|
-
const ticket = await client.create('confirm', { idempotencyKey: requestKey() });
|
|
194
|
-
const modal = openAuthLinkConfirm({
|
|
195
|
-
ticket, client, variant: 'B', locale: 'zh-CN',
|
|
196
|
-
onConfirmed: result => console.log(result.requestId),
|
|
197
|
-
onRejected: result => console.log(result.code),
|
|
198
|
-
onExpired: () => console.log('Expired'),
|
|
199
|
-
onError: error => console.log(error.message),
|
|
200
|
-
onClose: () => console.log('Closed'),
|
|
201
|
-
});
|
|
202
|
-
// Route/component cleanup: modal.destroy();
|
|
203
|
-
// Optional: modal.close(); modal.retry(); modal.setLocale('ja'); modal.setVariant('C');
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
绑定改用 `client.create('bind', ...)` 和 `openAuthLinkBind(...)`。确认或拒绝会先自动关闭再触发对应回调,不触发 onClose。关闭只停止前端观察;页面或组件退出时必须调用 destroy。每个实例对同一请求只通知一次成功;回调用于界面更新,受保护操作仍请求你的网站业务 API。
|
|
207
|
-
|
|
208
|
-
## 6. 前端需要的接口约定
|
|
209
|
-
|
|
210
|
-
| 网站接口 | 前端发送 | 前端读取 |
|
|
211
|
-
| --- | --- | --- |
|
|
212
|
-
| `POST /api/authlink/requests` | `{purpose:'bind'}` 或 confirm;`Idempotency-Key` | `Ticket` |
|
|
213
|
-
| `POST /api/authlink/requests/{requestId}/result` | `{}`;当前登录凭据 | `Result`,由 SDK 自动轮询 |
|
|
214
|
-
|
|
215
|
-
Cookie 模式两者均发送当前 CSRF 头。Ticket 包含 requestId、purpose、qr、pairingCode、expiresAt;confirm 还需要 displayCode。直接导入包内 Ticket / Result / Confirmation 类型,不自行生成 QR 内容或动态数字。只有 status=confirmed 触发成功事件;new/pending/approved/consumed 是等待状态,rejected/expired 分别触发对应事件。
|
|
216
|
-
|
|
217
|
-
若你的网站创建接口需要文章 ID、版本等额外参数,SDK create() 只发送 purpose,因此使用自己的 fetch 创建,然后校验 ticket 并交给同一组件。下面的 post、session、backendOrigin 均来自你自己的前端状态;幂等键应保存到重试状态,失败时不要重新生成。
|
|
218
|
-
|
|
219
|
-
```ts
|
|
220
|
-
import { requestKey, validateTicket } from '@auth-link/web';
|
|
221
|
-
|
|
222
|
-
// Keep this key across retries of the SAME operation and payload.
|
|
223
|
-
const idempotencyKey = requestKey();
|
|
224
|
-
const response = await fetch(`${backendOrigin}/api/authlink/requests`, {
|
|
225
|
-
method: 'POST', credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
226
|
-
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': session.csrf,
|
|
227
|
-
'Idempotency-Key': idempotencyKey },
|
|
228
|
-
body: JSON.stringify({ purpose: 'confirm', postId: post.id, version: post.version }),
|
|
229
|
-
});
|
|
230
|
-
if (!response.ok) throw new Error('Creation failed');
|
|
231
|
-
const ticket = validateTicket(await response.json(), 'confirm', 'https://api.getauthlink.com');
|
|
232
|
-
// Pass ticket and the same client to AuthLinkConfirm.
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
## 7. 参数、事件与样式
|
|
236
|
-
|
|
237
|
-
| 参数 | 类型 | 含义 |
|
|
238
|
-
| --- | --- | --- |
|
|
239
|
-
| `ticket` | `Ticket` | Required; the current request |
|
|
240
|
-
| `client` | `AuthLinkClient` | Required; keep the instance stable |
|
|
241
|
-
| `locale` | `Locale` | Default `zh-CN` |
|
|
242
|
-
| `variant` | `'A' / 'B' / 'C'` | Default `A` |
|
|
243
|
-
| `showQr` | `boolean` | Optional QR for confirm; bind always shows QR |
|
|
244
|
-
| `intervalMs` | `number` | Default 1500; range 250–10000 ms |
|
|
245
|
-
| `onRefresh` | `() => void` | Optional user action after expiry; create a new ticket |
|
|
246
|
-
| `className` | `string` | Custom dialog class; Vue also accepts `class` |
|
|
247
|
-
|
|
248
|
-
| React 回调 / Vue 事件 | 参数 | 行为 |
|
|
249
|
-
| --- | --- | --- |
|
|
250
|
-
| `onConfirmed` / `confirmed` | `Confirmation` | Success; dialog closes automatically |
|
|
251
|
-
| `onRejected` / `rejected` | `Rejection` | `{status:'rejected',code:'REJECTED',requestId,purpose}`; dialog closes |
|
|
252
|
-
| `onExpired` / `expired` | — | Request expired |
|
|
253
|
-
| `onClose` / `close` | — | User closes the dialog; clear ticket |
|
|
254
|
-
| `onError` / `error` | `Error` | Read `error.message`; retain the dialog for retry |
|
|
255
|
-
|
|
256
|
-
A 为默认居中卡片;B 为桌面分栏、窄屏纵排;C 为紧凑卡片、手机底部弹层。切换 locale/variant 不重新创建请求。原生 dialog.showModal() 浏览器支持是必需的。
|
|
257
|
-
|
|
258
|
-
CSS 需单独导入。弹窗挂到 document.body,主题变量放在 :root 或 .authlink-dialog:`--authlink-foreground`、`--authlink-background`、`--authlink-accent`、`--authlink-muted`。Vue 的 class 支持字符串、数组和对象,并与 className 合并。SSR 不展示弹窗或创建请求。
|
|
259
|
-
|
|
260
|
-
语言:`zh-CN` (default), `zh-TW`, `en`, `ja`, `bn`, `es`, `fil`, `hi`, `id`, `ms`, `pt`, `th`, `vi`。未指定或不支持时回退简体中文,可导入 supportedLocales 获取列表。
|
|
261
|
-
|
|
262
|
-
## 8. 传输配置与排错
|
|
263
|
-
|
|
264
|
-
| 配置 | 用途 |
|
|
265
|
-
| --- | --- |
|
|
266
|
-
| `baseUrl` | Your application API prefix; default `/api/authlink` |
|
|
267
|
-
| `allowedBackendOrigins` | Exact extra application API origins; no paths/wildcards |
|
|
268
|
-
| `credentials` | Default `same-origin`; cross-origin cookies need `include` |
|
|
269
|
-
| `csrfToken` | Sync/async getter for the current website CSRF token |
|
|
270
|
-
| `csrfHeaderName` | Default `X-CSRF-Token` |
|
|
271
|
-
| `accessToken` | Sync/async website Bearer token getter, without `Bearer ` |
|
|
272
|
-
| `headers` | Extra headers or sync/async getter |
|
|
273
|
-
| `authLinkOrigin` | `https://api.getauthlink.com` for production AuthLink |
|
|
274
|
-
| `fetch` | Optional fetch implementation |
|
|
275
|
-
|
|
276
|
-
自定义 headers 不能覆盖 Accept、Content-Type、Authorization、Idempotency-Key 或配置的 CSRF 头。错误信息可从 error.message 读取:CSRF_REQUIRED 检查当前 token;ACCESS_TOKEN_REQUIRED 检查登录 token;BACKEND_ORIGIN_NOT_ALLOWED 检查完整来源允许列表;NETWORK_ERROR 检查浏览器 Network 面板、网络和 CORS。SDK 请求超时为 10 秒,关闭/卸载会取消观察。
|
|
60
|
+
## 失败与清理
|
|
277
61
|
|
|
278
|
-
|
|
62
|
+
已使用的码不能用于另一个操作或会话;刚登录或首次绑定后需等待下一组数字。PASSCODE_INVALID / PASSCODE_USED / PASSCODE_LOCKED 是验证失败,不能当成成功。网络结果不明时,后台保留原幂等键和原码重试,业务不能重复执行。
|
|
279
63
|
|
|
280
|
-
|
|
64
|
+
destroy() 中止浏览器请求并清理计时器,不触发关闭回调;close() 还调用 onClose。关闭弹窗不会回滚服务端操作,取消与会话待办由 CMS 管理。等待和本地过期均不轮询。
|
|
281
65
|
|
|
282
|
-
|
|
66
|
+
旧 openAuthLink / Ticket / Consume 流程仍供此前协议使用;新 App 使用 openPasscode / AuthLinkPasscode 以及后台 Authenticate / authenticate。此次不提供旧绑定升级。
|
|
@@ -1,42 +1,24 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import type { OTPVerification, PasscodeClient } from '@auth-link/web';
|
|
4
|
+
import { AuthLinkPasscode } from '@auth-link/web/react';
|
|
6
5
|
// Import '@auth-link/web/styles.css' once in your application entry.
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
// The parent reserves one pending CMS action first. Its close handler cancels
|
|
8
|
+
// that backend slot before allowing another operation to start.
|
|
9
|
+
export function AuthLinkPanel({ client, pending, onVerified, onClose }: {
|
|
10
|
+
client: PasscodeClient;
|
|
11
|
+
pending: { expiresAt: string; bindingQr?: string };
|
|
12
|
+
onVerified: (result: OTPVerification) => void;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
}) {
|
|
11
15
|
const [message, setMessage] = useState('');
|
|
12
|
-
const creation = useRef<{ mode: Mode; key: string } | null>(null);
|
|
13
|
-
const inFlight = useRef(false);
|
|
14
|
-
const controller = useRef<AbortController | null>(null);
|
|
15
|
-
useEffect(() => () => { controller.current?.abort(); }, []);
|
|
16
|
-
function finish(text: string) { setTicket(null); setMessage(text); }
|
|
17
|
-
async function start() {
|
|
18
|
-
if (inFlight.current || ticket) return;
|
|
19
|
-
inFlight.current = true; setBusy(true); setMessage('');
|
|
20
|
-
const attempt = new AbortController(); controller.current = attempt;
|
|
21
|
-
try {
|
|
22
|
-
if (creation.current?.mode !== mode) creation.current = { mode, key: requestKey() };
|
|
23
|
-
const value = await client.create(mode, { idempotencyKey: creation.current.key, signal: attempt.signal });
|
|
24
|
-
if (attempt.signal.aborted) return;
|
|
25
|
-
setTicket(value); creation.current = null;
|
|
26
|
-
} catch (error) { if (!attempt.signal.aborted) setMessage((error as Error).message); }
|
|
27
|
-
finally {
|
|
28
|
-
inFlight.current = false;
|
|
29
|
-
if (!attempt.signal.aborted) setBusy(false);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
const Component = mode === 'bind' ? AuthLinkBind : AuthLinkConfirm;
|
|
33
16
|
return <>
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
onError={error => setMessage(error.message)} />}
|
|
17
|
+
<AuthLinkPasscode client={client} expiresAt={pending.expiresAt}
|
|
18
|
+
bindingQr={pending.bindingQr} authLinkOrigin="https://api.getauthlink.com"
|
|
19
|
+
locale="en" onVerified={onVerified} onClose={onClose}
|
|
20
|
+
onExpired={() => setMessage('Expired; start a new action')}
|
|
21
|
+
onError={error => setMessage(error.message)} />
|
|
40
22
|
<p role="status">{message}</p>
|
|
41
23
|
</>;
|
|
42
24
|
}
|
package/examples/vue-client.vue
CHANGED
|
@@ -1,55 +1,31 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { markRaw,
|
|
3
|
-
import {
|
|
4
|
-
import type {
|
|
5
|
-
import {
|
|
2
|
+
import { markRaw, ref } from 'vue';
|
|
3
|
+
import { createPasscodeClient } from '@auth-link/web';
|
|
4
|
+
import type { OTPVerification } from '@auth-link/web';
|
|
5
|
+
import { AuthLinkPasscode } from '@auth-link/web/vue';
|
|
6
6
|
import '@auth-link/web/styles.css';
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}));
|
|
22
|
-
function finish(text: string) { ticket.value = null; message.value = text; }
|
|
23
|
-
function confirmed(result: Confirmation) {
|
|
24
|
-
finish(`${result.purpose}: ${result.requestId}`);
|
|
25
|
-
// Reload your website's account/status API if the page needs fresh state.
|
|
26
|
-
}
|
|
8
|
+
// The CMS has already reserved this session's sole pending action. First
|
|
9
|
+
// enrollment includes bindingQr; ordinary verification needs no QR.
|
|
10
|
+
const props = defineProps<{
|
|
11
|
+
csrf: string;
|
|
12
|
+
pending: { expiresAt: string; bindingQr?: string };
|
|
13
|
+
}>();
|
|
14
|
+
const emit = defineEmits<{
|
|
15
|
+
verified: [result: OTPVerification];
|
|
16
|
+
close: [];
|
|
17
|
+
}>();
|
|
18
|
+
const message = ref('');
|
|
19
|
+
const client = markRaw(createPasscodeClient({ baseUrl: '/api', csrfToken: () => props.csrf }));
|
|
20
|
+
function verified(result: OTPVerification) { emit('verified', result); }
|
|
27
21
|
function showError(error: Error) { message.value = error.message; }
|
|
28
|
-
|
|
29
|
-
if (busy.value || ticket.value) return;
|
|
30
|
-
busy.value = true; message.value = '';
|
|
31
|
-
try {
|
|
32
|
-
if (creationMode !== mode) { creationKey = null; creationMode = mode; }
|
|
33
|
-
creationKey ??= requestKey();
|
|
34
|
-
const value = await client.create(mode, { idempotencyKey: creationKey, signal: lifetime.signal });
|
|
35
|
-
if (lifetime.signal.aborted) return;
|
|
36
|
-
ticket.value = value; creationKey = null;
|
|
37
|
-
} catch (error) {
|
|
38
|
-
if (!lifetime.signal.aborted) showError(error as Error);
|
|
39
|
-
// Keep the same key when retrying an uncertain creation of the same mode.
|
|
40
|
-
} finally { busy.value = false; }
|
|
41
|
-
}
|
|
42
|
-
onUnmounted(() => { lifetime.abort(); ticket.value = null; creationKey = null; });
|
|
22
|
+
// The parent cancels its backend slot before preparing another action.
|
|
43
23
|
</script>
|
|
44
24
|
|
|
45
25
|
<template>
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
@
|
|
50
|
-
@expired="finish('Expired; start again')" @error="showError" />
|
|
51
|
-
<AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
|
|
52
|
-
@confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
|
|
53
|
-
@expired="finish('Expired; start again')" @error="showError" />
|
|
26
|
+
<AuthLinkPasscode :client="client" :expires-at="pending.expiresAt"
|
|
27
|
+
:binding-qr="pending.bindingQr" auth-link-origin="https://api.getauthlink.com"
|
|
28
|
+
locale="en" @verified="verified" @close="emit('close')"
|
|
29
|
+
@expired="message = 'Expired; start a new action'" @error="showError" />
|
|
54
30
|
<p role="status">{{ message }}</p>
|
|
55
31
|
</template>
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@auth-link/web",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "A responsive minimal AuthLink dialog for phone binding and TOTP verification. Framework-neutral API, React and Vue, 13 languages, Simplified Chinese by default.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"files": [
|