@auth-link/web 0.0.3 → 0.0.5
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 +11 -0
- package/README.md +581 -1783
- package/docs/README.en.md +200 -323
- package/docs/README.ja.md +200 -323
- package/docs/README.zh-CN.md +201 -394
- package/examples/react-client.tsx +42 -0
- package/examples/vue-client.vue +55 -0
- package/package.json +6 -3
- package/docs/go-vue.en.md +0 -254
- package/docs/go-vue.ja.md +0 -254
- package/docs/go-vue.zh-CN.md +0 -258
- package/examples/vue-cms.vue +0 -109
package/docs/README.zh-CN.md
CHANGED
|
@@ -2,474 +2,281 @@
|
|
|
2
2
|
|
|
3
3
|
[English](../README.md) | **简体中文** | [日本語](./README.ja.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
用于手机绑定与 App 确认的前端组件,支持 Vue 3、React 18/19 和原生 JavaScript。本文按前端接入顺序说明安装、登录状态读取、客户端初始化、弹窗和事件处理。
|
|
6
6
|
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
按实际 CMS 流程接入,推荐先读 [Go + Vue:从登录、绑定到确认发布](./go-vue.zh-CN.md),完整组件见 [Vue CMS 示例](../examples/vue-cms.vue)。
|
|
10
|
-
|
|
11
|
-
顺序是:网站登录 → Go `/api/session` 返回 `authenticated` 与 `csrf` → Vue 通过 `csrfToken: () => session.csrf` 配置跨域客户端 → 创建 bind/confirm → Go 验证并消费结果后落库 → Vue 清理弹窗 → 独立业务接口再校验已保存回执并发布。`baseUrl` 是 Go 业务 API,`authLinkOrigin` 是 AuthLink API;租户密钥始终留在 Go 后台。带文章 ID/版本的确认通过网站自己的 fetch 创建并用 `validateTicket` 校验,SDK 的 `create` 只发送 purpose。
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
## 功能
|
|
15
|
-
|
|
16
|
-
- **扫码绑定**:网站显示二维码,用户使用 AuthLink App 扫码并确认绑定。
|
|
17
|
-
- **手机确认**:电脑与 App 显示同步变化的六位数字,每 30 秒更新,用户核对后在 App 内批准。
|
|
18
|
-
- **结果通知**:提供确认成功、拒绝、过期和错误事件,方便网站更新界面。
|
|
19
|
-
- **框架支持**:React 组件与 hook、Vue 组件与 composable,以及无框架客户端。
|
|
20
|
-
- **多语言与主题**:支持下表中的 13 种语言,默认简体中文;独立 CSS 和可覆盖的主题变量。
|
|
21
|
-
- **生命周期管理**:请求切换或组件卸载时取消轮询,数字过期后自动隐藏。
|
|
22
|
-
|
|
23
|
-
此包是 AuthLink 的网页接入层,需要配套应用后台、AuthLink 服务和手机 App。完整 AuthLink 流程校验原手机硬件密钥证明及 App 内批准,不要求系统通行密钥、指纹或锁屏;安装此包本身不会建立鉴权服务。
|
|
24
|
-
|
|
25
|
-
## 接入步骤
|
|
26
|
-
|
|
27
|
-
1. 在应用后台完成站点租户配置,并实现本文的创建请求、查询结果两个业务后台接口(同源或显式配置的跨域后台)。
|
|
28
|
-
2. 安装包、导入样式,创建指向应用后台的客户端。
|
|
29
|
-
3. 用户点击绑定或验证时创建请求,将返回的 ticket 交给 React/Vue 组件。
|
|
30
|
-
4. 用户在 App 确认后,应用后台验证并消费结果,组件收到可信确认事件。
|
|
31
|
-
|
|
32
|
-
租户密钥只保存在应用服务端。用户是否能够继续调用受保护接口,也必须由应用后台判断。
|
|
33
|
-
|
|
34
|
-
## 流程
|
|
35
|
-
|
|
36
|
-
应用后台根据当前登录用户创建 `bind` 或 `confirm` 请求,前端拿到 ticket 后交给组件。组件只轮询应用后台。应用后台在验证归属、state、双重认证策略并消费一次性结果后,才返回 `confirmed`。
|
|
37
|
-
|
|
38
|
-
定时数字用于电脑与手机核对;用户仍在 AuthLink App 内完成 原手机硬件密钥校验和 App 内批准。它不是用户输入网站的 TOTP 登录密码,不能单独授权。请求最长 120 秒;数字变化不延长请求有效期。前端不生成数字、不接收生成 seed,不在数字更新时新建请求。
|
|
39
|
-
|
|
40
|
-
需要配套更新后的 Go 服务和客户端协议 7。服务端与 App 必须使用当前单一协议,不能只升级网页。
|
|
41
|
-
|
|
42
|
-
## 安装
|
|
43
|
-
|
|
44
|
-
在应用项目中安装:
|
|
7
|
+
## 1. 安装并区分两个 API 地址
|
|
45
8
|
|
|
46
9
|
```sh
|
|
47
|
-
npm install @auth-link/web
|
|
10
|
+
npm install @auth-link/web@latest
|
|
11
|
+
# 另行安装你的项目使用的 vue,或 react / react-dom。
|
|
48
12
|
```
|
|
49
13
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`
|
|
53
|
-
|
|
54
|
-
下面采用页面 meta 标签的方式。后台需要先准备 token:
|
|
14
|
+
| 地址 | 前端配置 | 用途 |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| 你的网站业务后台,如 `https://cms-api.example.com` | `baseUrl`、`allowedBackendOrigins` | 登录状态、创建请求和查询结果 |
|
|
17
|
+
| AuthLink 生产 API:`https://api.getauthlink.com` | `authLinkOrigin` | 核对 ticket 中的 AuthLink 服务来源 |
|
|
55
18
|
|
|
56
|
-
|
|
57
|
-
2. 服务端使用当前 session 的 token 渲染下面的标签,正确转义 HTML 属性;`{{ csrfToken }}` 表示服务器模板变量,不是可直接照抄的 token。包含会话 token 的页面不能被公开缓存。
|
|
58
|
-
3. 创建和查询接口都读取 `X-CSRF-Token`,与同一登录 session 保存的 token 校验;缺失、不匹配或请求来源不符合网站要求时拒绝请求。仅让前端发送这个头,并不会自动启用后台校验。
|
|
59
|
-
4. 登录、退出或会话/token 更新后,让页面重新取得当前 token。SPA 也可以由网站实现同源会话初始化接口返回 token,再通过 `csrfToken` 回调提供;这不是 SDK 自带接口。
|
|
19
|
+
`baseUrl` 不是 AuthLink API,也不是 AuthLink 官网。接入方的网站和业务 API 仍使用自己的域名。需要已有的网站登录状态和下文的请求接口;Web SDK 不提供登录或会话接口。租户密钥不能放到浏览器。
|
|
60
20
|
|
|
61
|
-
|
|
62
|
-
<!-- 由你的网站后台渲染到当前用户的页面 head 中 -->
|
|
63
|
-
<meta name="csrf-token" content="{{ csrfToken }}">
|
|
64
|
-
```
|
|
21
|
+
导入入口:根入口提供客户端和类型;`/vue`、`/react` 提供组件及 useAuthLink;`/styles.css` 必须显式导入。ESM、TypeScript,工具环境 Node 22.13+;React 18.2+/19、Vue 3.3+,只安装所用框架。
|
|
65
22
|
|
|
66
|
-
|
|
23
|
+
## 2. 读取当前登录状态并创建客户端
|
|
67
24
|
|
|
68
25
|
```ts
|
|
69
|
-
// authlink-client.ts
|
|
70
26
|
import { createAuthLinkClient } from '@auth-link/web';
|
|
71
27
|
import '@auth-link/web/styles.css';
|
|
72
28
|
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
export const client = createAuthLinkClient({
|
|
82
|
-
baseUrl: '/api/authlink',
|
|
83
|
-
csrfToken: readCsrfTokenFromYourCms,
|
|
84
|
-
// Replace this example with the configured AuthLink service origin.
|
|
85
|
-
authLinkOrigin: 'https://auth.example.com',
|
|
86
|
-
});
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
把 `https://auth.example.com` 换成实际 AuthLink 服务来源。SDK 会在每次请求时调用读取函数,自动把值放入 `X-CSRF-Token`,并携带同源登录 cookie;前端不需要读取 HttpOnly 登录 cookie。若网站框架默认使用其他 CSRF 头名,可用 `csrfHeaderName` 配成后台期望的头名,或者实现自定义 `AuthLinkClient`。
|
|
90
|
-
|
|
91
|
-
`create` 只传用途,网站账号由后台登录 session 决定。客户端通常只初始化一次;用户点击按钮时才创建请求。完整调用见下方 React 示例,不要把 `client.create()` 放在模块加载、React effect 或 Vue watch 中。
|
|
92
|
-
|
|
93
|
-
## 前后端分离与跨域后台(0.0.2 起)
|
|
94
|
-
|
|
95
|
-
默认仍使用同源 `/api/authlink`。直接访问另一个后台来源时,同时配置完整 `baseUrl` 和 `allowedBackendOrigins`,例如网页在 `https://www.example.com`,业务后台在 `https://api.example.com`。来源是协议、主机和端口的组合;同一主域名下不同子域名也属于跨域。
|
|
96
|
-
|
|
97
|
-
`allowedBackendOrigins` 是前端明确授权的**业务后台来源**,不能带路径、通配符、用户名或密码,不能来自用户输入。生产环境使用 HTTPS;HTTP 仅允许 localhost / 127.0.0.1 / [::1] 开发环境,端口必须匹配。SDK 会先校验目标再读取凭据,并拒绝 HTTP 重定向。`authLinkOrigin` 则用于校验二维码中的 AuthLink 服务来源,两者作用不同,可按需要同时配置。
|
|
98
|
-
|
|
99
|
-
### 跨域 Cookie / session
|
|
100
|
-
|
|
101
|
-
下面给出完整的初始化函数。`GET /api/session` 是接入网站自己实现的示例会话接口,不是包或 AuthLink 服务自带的接口;它应根据现有登录 cookie 返回 `{ "csrfToken": "当前会话的真实CSRF token" }`,设置 `Cache-Control: no-store` 并配置下文的 CORS。部署时将路径换成网站实际的会话初始化接口。
|
|
102
|
-
|
|
103
|
-
这里的 `csrfToken` 是通用示例选定的 JSON 字段名。Go CMS 实例实际返回 `{ authenticated: true, csrf: '…' }`;读取 `session.csrf` 并交给同名 SDK 配置回调即可,无须改动后台响应。完整映射见上方 Go + Vue 指南。
|
|
104
|
-
|
|
105
|
-
```ts
|
|
106
|
-
import { createAuthLinkClient } from '@auth-link/web';
|
|
107
|
-
|
|
108
|
-
/** /api/session is an example endpoint implemented by your application backend. */
|
|
109
|
-
export async function createSessionBackendClient(backendOrigin: string) {
|
|
110
|
-
const response = await fetch(`${backendOrigin}/api/session`, {
|
|
111
|
-
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
29
|
+
// getCsrf reads your website's CURRENT session token on every request.
|
|
30
|
+
export function createWebsiteClient(getCsrf: () => string, backendOrigin?: string) {
|
|
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',
|
|
112
37
|
});
|
|
113
|
-
if (!response.ok) throw new Error('Session initialization failed');
|
|
114
|
-
const session: unknown = await response.json();
|
|
115
|
-
if (!session || typeof session !== 'object' || !('csrfToken' in session) ||
|
|
116
|
-
typeof session.csrfToken !== 'string' || !session.csrfToken) {
|
|
117
|
-
throw new Error('The session endpoint must return { csrfToken: string }');
|
|
118
|
-
}
|
|
119
|
-
let currentCsrfToken = session.csrfToken;
|
|
120
|
-
return {
|
|
121
|
-
client: createAuthLinkClient({
|
|
122
|
-
baseUrl: `${backendOrigin}/api/authlink`,
|
|
123
|
-
allowedBackendOrigins: [backendOrigin],
|
|
124
|
-
credentials: 'include',
|
|
125
|
-
csrfToken: () => currentCsrfToken,
|
|
126
|
-
}),
|
|
127
|
-
// Call after your application's session/token refresh; recreate on account changes.
|
|
128
|
-
updateCsrfToken(value: string) { currentCsrfToken = value; },
|
|
129
|
-
};
|
|
130
38
|
}
|
|
131
39
|
```
|
|
132
40
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
### 跨域 Bearer token / JWT
|
|
136
|
-
|
|
137
|
-
传入网站自己的登录状态对象;网站完成登录或刷新后更新这个对象的 `accessToken`。回调会在每次创建和轮询时重新读取最新 token,无需重建弹窗。
|
|
41
|
+
先完成你的网站登录,再读取应用已有的会话接口。下面使用与 CMS 一致的 `{authenticated, csrf}` 响应;如果你的后台字段名为 csrfToken,读取它再交给 SDK 的 csrfToken 回调即可。
|
|
138
42
|
|
|
139
43
|
```ts
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
});
|
|
44
|
+
// Run in your website's existing authenticated browser flow.
|
|
45
|
+
const backendOrigin = 'https://cms-api.example.com'; // YOUR application backend
|
|
46
|
+
const response = await fetch(`${backendOrigin}/api/session`, {
|
|
47
|
+
credentials: 'include', cache: 'no-store', redirect: 'error',
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) throw new Error('Session unavailable');
|
|
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');
|
|
150
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.
|
|
151
58
|
```
|
|
152
59
|
|
|
153
|
-
`
|
|
154
|
-
|
|
155
|
-
### 后台 CORS 配置
|
|
60
|
+
`GET /api/session` 是网站已有接口的示例,请按实际路径替换。同源可以使用相对路径和 same-origin Cookie;跨域 Cookie 使用 include,并要求当前前端来源已被业务后台允许。Cookie 策略仍可能限制请求。浏览器无法读取 HttpOnly Cookie,只需要传回当前会话的 CSRF token;不能用手机核对码或浏览器随机值代替。
|
|
156
61
|
|
|
157
|
-
|
|
62
|
+
已有 Bearer / JWT 登录的项目可用下列配置,auth 是你的网站登录状态,刷新流程更新 auth.accessToken。此 token 不是 AuthLink 租户密钥。仅在 credentials=omit 的 Bearer 模式可以不传 CSRF;若同时发送 Cookie,仍需 csrfToken。
|
|
158
63
|
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
64
|
+
```ts
|
|
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
|
+
});
|
|
165
71
|
```
|
|
166
72
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
### 请求客户端配置
|
|
170
|
-
|
|
171
|
-
| 配置 | 说明 |
|
|
172
|
-
| --- | --- |
|
|
173
|
-
| `baseUrl` | 业务后台前缀,默认 `/api/authlink`;跨域时填写完整地址 |
|
|
174
|
-
| `allowedBackendOrigins` | 额外允许的精确后台来源数组,默认只允许当前网页同源 |
|
|
175
|
-
| `credentials` | Cookie 模式默认 `same-origin`;提供 `accessToken` 时默认 `omit`;跨域 Cookie 显式设置 `include` |
|
|
176
|
-
| `csrfToken` | 每次请求读取 CSRF token 的同步或异步函数;Cookie 模式必填 |
|
|
177
|
-
| `csrfHeaderName` | CSRF 请求头名,默认 `X-CSRF-Token`,例如可设为 `X-XSRF-TOKEN` |
|
|
178
|
-
| `accessToken` | 每次请求读取网站 Bearer token 的同步或异步函数;不要包含 `Bearer ` 前缀 |
|
|
179
|
-
| `headers` | 额外的 `HeadersInit` 或返回它的同步/异步函数,可传工作区等自定义头 |
|
|
180
|
-
| `authLinkOrigin` | 核对二维码中的 AuthLink 服务来源,与业务后台来源独立 |
|
|
181
|
-
| `fetch` | 可选 fetch 实现;仍受目标来源和协议校验约束 |
|
|
73
|
+
## 3. Vue:点击创建、展示和清理
|
|
182
74
|
|
|
183
|
-
|
|
75
|
+
完整文件随包提供为 `examples/vue-client.vue`,可复制为 AuthLinkPanel.vue。父组件在登录后传入当前 session.csrf;跨域时额外传 backendOrigin。退出或切换账号/session 时卸载旧组件,重新挂载新实例;CSRF 刷新可更新 csrf prop。
|
|
184
76
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
import {
|
|
191
|
-
import { client } from './authlink-client';
|
|
77
|
+
```vue
|
|
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';
|
|
192
83
|
import '@auth-link/web/styles.css';
|
|
193
84
|
|
|
194
|
-
//
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
85
|
+
// csrf comes from your website's current authenticated session.
|
|
86
|
+
const props = defineProps<{ csrf: string; backendOrigin?: string }>();
|
|
87
|
+
const ticket = shallowRef<Ticket | null>(null);
|
|
88
|
+
const busy = ref(false), message = ref('');
|
|
89
|
+
const lifetime = new AbortController();
|
|
90
|
+
let creationKey: string | null = null;
|
|
91
|
+
let creationMode: Mode | null = null;
|
|
92
|
+
const client = markRaw(createAuthLinkClient({
|
|
93
|
+
baseUrl: props.backendOrigin ? `${props.backendOrigin}/api/authlink` : '/api/authlink',
|
|
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; });
|
|
120
|
+
</script>
|
|
209
121
|
|
|
122
|
+
<template>
|
|
123
|
+
<button :disabled="busy || !!ticket" @click="start('bind')">Bind phone</button>
|
|
124
|
+
<button :disabled="busy || !!ticket" @click="start('confirm')">Confirm</button>
|
|
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>
|
|
132
|
+
</template>
|
|
133
|
+
```
|
|
210
134
|
|
|
211
|
-
|
|
135
|
+
示例使用 shallowRef 保存 ticket、markRaw 保存 client。创建失败保留同一用途的原幂等键;成功后才清空。创建只发生在点击事件中,mounted/watch 不创建请求。确认、拒绝、过期或用户关闭时清空 ticket,卸载组件;网络错误保留弹窗,使用弹窗的重试按钮继续当前请求。需要真实账号状态时重新查询你的网站状态接口。
|
|
212
136
|
|
|
213
|
-
|
|
137
|
+
## 4. React
|
|
214
138
|
|
|
215
|
-
|
|
139
|
+
使用上面的初始化方式创建稳定 client,再传给此组件;mode 为 bind 或 confirm。退出或切换账号/session 时卸载旧实例。
|
|
216
140
|
|
|
217
141
|
```tsx
|
|
218
142
|
"use client";
|
|
219
|
-
import { useRef, useState } from 'react';
|
|
143
|
+
import { useEffect, useRef, useState } from 'react';
|
|
220
144
|
import { requestKey } from '@auth-link/web';
|
|
221
|
-
import type { Mode, Ticket } from '@auth-link/web';
|
|
145
|
+
import type { AuthLinkClient, Mode, Ticket } from '@auth-link/web';
|
|
222
146
|
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/react';
|
|
223
|
-
|
|
147
|
+
// Import '@auth-link/web/styles.css' once in your application entry.
|
|
224
148
|
|
|
225
|
-
export function AuthLinkPanel({ mode }: { mode: Mode }) {
|
|
149
|
+
export function AuthLinkPanel({ client, mode }: { client: AuthLinkClient; mode: Mode }) {
|
|
226
150
|
const [ticket, setTicket] = useState<Ticket | null>(null);
|
|
227
151
|
const [busy, setBusy] = useState(false);
|
|
228
152
|
const [message, setMessage] = useState('');
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
function showConfirmed(requestId: string) {
|
|
240
|
-
finish(`手机确认成功(请求 ${requestId})`);
|
|
241
|
-
}
|
|
242
|
-
const start = async () => {
|
|
243
|
-
if (creating.current || ticket) return;
|
|
244
|
-
creating.current = true;
|
|
245
|
-
setBusy(true);
|
|
246
|
-
setMessage('');
|
|
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;
|
|
247
162
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
+
}
|
|
254
173
|
const Component = mode === 'bind' ? AuthLinkBind : AuthLinkConfirm;
|
|
255
174
|
return <>
|
|
256
|
-
{!ticket && <button
|
|
257
|
-
{ticket && <Component ticket={ticket} client={client}
|
|
258
|
-
onConfirmed={result => result.purpose
|
|
259
|
-
onClose={() => finish('
|
|
260
|
-
|
|
261
|
-
onExpired={() => finish('请求已过期,请重新发起')}
|
|
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')}
|
|
262
180
|
onError={error => setMessage(error.message)} />}
|
|
263
181
|
<p role="status">{message}</p>
|
|
264
182
|
</>;
|
|
265
183
|
}
|
|
266
184
|
```
|
|
267
185
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
创建失败时示例保留同一个幂等键;再次点击会重试同一次创建。创建成功后清空幂等键,新的用户操作才创建新请求。关闭、成功、拒绝和过期都会清空 ticket;网络错误保留弹窗,供其重试当前请求。
|
|
271
|
-
|
|
272
|
-
确认组件默认只显示动态数字;`showQr` 可额外显示扫码入口。绑定组件始终显示二维码。`onRefresh` 可提供重新创建请求的函数;仅过期后显示该按钮。组件自身的“重试”只恢复当前请求,不创建第二个请求。
|
|
273
|
-
|
|
274
|
-
未指定 `locale` 时使用简体中文;不支持的语言代码也回退到简体中文。组件根元素设置对应的 `lang`,语言切换不会重新创建鉴权请求。
|
|
275
|
-
|
|
276
|
-
React 入口保留 `"use client"`,可用于 Next.js 客户端组件。导入包和 SSR 渲染不会创建请求。将 client 与 ticket 保持为稳定对象,例如模块级 client 和 React state 中的 ticket。卸载/切换 ticket 会取消旧轮询;StrictMode 不会创建额外请求。
|
|
277
|
-
|
|
278
|
-
## Vue
|
|
279
|
-
|
|
280
|
-
```vue
|
|
281
|
-
<script setup lang="ts">
|
|
282
|
-
import { ref, shallowRef } from 'vue';
|
|
283
|
-
import { requestKey } from '@auth-link/web';
|
|
284
|
-
import type { Confirmation, Mode, Ticket } from '@auth-link/web';
|
|
285
|
-
import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
|
|
286
|
-
import { client } from './authlink-client';
|
|
287
|
-
const props = defineProps<{ mode: Mode }>();
|
|
288
|
-
const ticket = shallowRef<Ticket | null>(null);
|
|
289
|
-
const busy = ref(false);
|
|
290
|
-
const message = ref('');
|
|
291
|
-
let key: string | null = null;
|
|
292
|
-
function finish(text: string) { ticket.value = null; message.value = text; }
|
|
293
|
-
function confirmed(result: Confirmation) {
|
|
294
|
-
finish(`${result.purpose === 'bind' ? '已绑定' : '已确认'}(请求 ${result.requestId})`);
|
|
295
|
-
}
|
|
296
|
-
async function start() {
|
|
297
|
-
if (busy.value) return;
|
|
298
|
-
busy.value = true;
|
|
299
|
-
key ??= requestKey();
|
|
300
|
-
try {
|
|
301
|
-
ticket.value = await client.create(props.mode, { idempotencyKey: key });
|
|
302
|
-
key = null;
|
|
303
|
-
} catch { message.value = '请求未完成,可重试同一次创建。'; }
|
|
304
|
-
finally { busy.value = false; }
|
|
305
|
-
}
|
|
306
|
-
</script>
|
|
307
|
-
|
|
308
|
-
<template>
|
|
309
|
-
<button v-if="!ticket" type="button" :disabled="busy" @click="start">开始验证</button>
|
|
310
|
-
<AuthLinkBind v-else-if="mode === 'bind'" :ticket="ticket" :client="client"
|
|
311
|
-
@confirmed="confirmed" @close="finish('已关闭')" @rejected="finish('手机已拒绝本次请求')"
|
|
312
|
-
@expired="finish('请求已过期,请重新发起')" @error="message = '网络异常,请重试。'" />
|
|
313
|
-
<AuthLinkConfirm v-else :ticket="ticket" :client="client"
|
|
314
|
-
@confirmed="confirmed" @close="finish('已关闭')" @rejected="finish('手机已拒绝本次请求')"
|
|
315
|
-
@expired="finish('请求已过期,请重新发起')" @error="message = '网络异常,请重试。'" />
|
|
316
|
-
<p role="status">{{ message }}</p>
|
|
317
|
-
</template>
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
拒绝时返回 `{ status: 'rejected', code: 'REJECTED', requestId, purpose }`。`REJECTED` 是 SDK 业务状态码,不是 HTTP 状态码或授权凭证。组件自动关闭,不再显示“已拒绝 / 重新发起”面板,也不触发 `onClose`;提示和重新发起由接入网站在回调中决定。React / Vue 请在 `onRejected` / `@rejected` 中清空 ticket、卸载组件。无 UI 的 hook 仍保留 `snapshot.status === 'rejected'`,供自定义界面使用。
|
|
321
|
-
|
|
322
|
-
此示例在过期后清空 ticket,用户可再次点击按钮发起请求。如果需要在过期弹窗内提供重新发起按钮,可自行传入 `onRefresh`,在回调中创建新请求并更新 ticket。`useAuthLink(() => options)` composable 与 React 的 `useAuthLink(options)` hook 可用于自定义界面,返回 `snapshot` 和 `retry`;Vue 的 snapshot 是浅 ref。
|
|
323
|
-
|
|
324
|
-
## 选择弹窗样式
|
|
325
|
-
|
|
326
|
-
初始化时通过 `variant` 选择样式,**不传时默认 A**。
|
|
327
|
-
|
|
328
|
-
| 参数值 | 电脑端 | 手机端 |
|
|
329
|
-
| --- | --- | --- |
|
|
330
|
-
| `A`(默认) | 极简居中卡片、细线数字更新进度条 | 居中卡片 |
|
|
331
|
-
| `B` | 二维码和核对码左右分栏 | 自动纵向排列 |
|
|
332
|
-
| `C` | 紧凑卡片、底部取消入口 | 底部弹层,适配安全区域 |
|
|
186
|
+
## 5. 原生 JavaScript / 直接调用
|
|
333
187
|
|
|
334
188
|
```ts
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
// modal.setVariant('C'); // 只更新显示,不重建请求、不重启查询
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
```tsx
|
|
341
|
-
<AuthLinkBind ticket={ticket} client={client} variant="C" />
|
|
342
|
-
```
|
|
189
|
+
import { openAuthLinkConfirm, requestKey } from '@auth-link/web';
|
|
190
|
+
import '@auth-link/web/styles.css';
|
|
343
191
|
|
|
344
|
-
|
|
345
|
-
|
|
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');
|
|
346
204
|
```
|
|
347
205
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
三套样式使用相同的鉴权、过期和回调逻辑。React / Vue 可响应式切换 `variant`,不会重新发起请求;无效的运行时值回退到 A。B / C 的底部“取消”和右上角关闭行为一致,仅停止前端观察,不会替用户拒绝服务端请求。确认成功仍自动关闭。关闭图标保留 44px 点击区域和键盘焦点提示。
|
|
351
|
-
|
|
352
|
-
## 组件参数与事件
|
|
206
|
+
绑定改用 `client.create('bind', ...)` 和 `openAuthLinkBind(...)`。确认或拒绝会先自动关闭再触发对应回调,不触发 onClose。关闭只停止前端观察;页面或组件退出时必须调用 destroy。每个实例对同一请求只通知一次成功;回调用于界面更新,受保护操作仍请求你的网站业务 API。
|
|
353
207
|
|
|
354
|
-
|
|
208
|
+
## 6. 前端需要的接口约定
|
|
355
209
|
|
|
356
|
-
|
|
|
210
|
+
| 网站接口 | 前端发送 | 前端读取 |
|
|
357
211
|
| --- | --- | --- |
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
| `locale` | `Locale`,见下表 | 界面语言,默认 `zh-CN` |
|
|
361
|
-
| `variant` | `DialogVariant` | `A`(默认)、`B` 或 `C`;只改变布局 |
|
|
362
|
-
| `showQr` | `boolean` | 确认组件是否额外显示二维码;绑定组件始终显示 |
|
|
363
|
-
| `intervalMs` | `number` | 结果轮询间隔,默认 1500 毫秒 |
|
|
364
|
-
| `onRefresh` | `() => void` | 过期后,由网站重新创建请求并更新 ticket |
|
|
365
|
-
|
|
366
|
-
| React 回调 / Vue 事件 | 参数 | 触发时机 |
|
|
367
|
-
| --- | --- | --- |
|
|
368
|
-
| `onConfirmed` / `confirmed` | `Confirmation` |应用后台完成结果验证和消费后 |
|
|
369
|
-
| `onRejected` / `rejected` | `Rejection` | 手机拒绝请求,弹窗自动关闭 |
|
|
370
|
-
| `onExpired` / `expired` | 无 | 请求已过期 |
|
|
371
|
-
| `onError` / `error` | `Error` | 网络错误、响应校验失败或成功回调执行失败等 |
|
|
372
|
-
|
|
373
|
-
业务处理以 `requestId` / `verificationId` 做持久化幂等。前端成功事件用于展示状态,不能作为后台接口放行凭据。
|
|
212
|
+
| `POST /api/authlink/requests` | `{purpose:'bind'}` 或 confirm;`Idempotency-Key` | `Ticket` |
|
|
213
|
+
| `POST /api/authlink/requests/{requestId}/result` | `{}`;当前登录凭据 | `Result`,由 SDK 自动轮询 |
|
|
374
214
|
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
| 入口 | 用途 |
|
|
378
|
-
| --- | --- |
|
|
379
|
-
| `@auth-link/web` | `createAuthLinkClient`、`requestKey`、公开类型及无框架工具 |
|
|
380
|
-
| `@auth-link/web/react` | React 组件和 `useAuthLink` |
|
|
381
|
-
| `@auth-link/web/vue` | Vue 组件和 `useAuthLink` |
|
|
382
|
-
| `@auth-link/web/styles.css` | 组件样式,需显式导入 |
|
|
215
|
+
Cookie 模式两者均发送当前 CSRF 头。Ticket 包含 requestId、purpose、qr、pairingCode、expiresAt;confirm 还需要 displayCode。直接导入包内 Ticket / Result / Confirmation 类型,不自行生成 QR 内容或动态数字。只有 status=confirmed 触发成功事件;new/pending/approved/consumed 是等待状态,rejected/expired 分别触发对应事件。
|
|
383
216
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
默认前缀 `/api/authlink`;下面两个接口由接入方实现,**不是直接把浏览器请求转发到租户密钥 API**。
|
|
387
|
-
|
|
388
|
-
### `POST /requests`
|
|
389
|
-
|
|
390
|
-
浏览器按所选模式携带网站登录凭据:Cookie 模式发送 session cookie 和配置的 CSRF 头,Bearer 模式发送 `Authorization`。创建还发送稳定的 `Idempotency-Key`,body:
|
|
391
|
-
|
|
392
|
-
```json
|
|
393
|
-
{"purpose":"bind"}
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
后台从经过验证的 session 或 Bearer token 取得用户 ID,创建并持久保存 state、创建/消费幂等键、用户/session 关联,然后调用 AuthLink 服务端创建请求。返回其公开 ticket:
|
|
217
|
+
若你的网站创建接口需要文章 ID、版本等额外参数,SDK create() 只发送 purpose,因此使用自己的 fetch 创建,然后校验 ticket 并交给同一组件。下面的 post、session、backendOrigin 均来自你自己的前端状态;幂等键应保存到重试状态,失败时不要重新生成。
|
|
397
218
|
|
|
398
219
|
```ts
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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.
|
|
411
233
|
```
|
|
412
234
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
同样验证登录身份(Cookie 模式还需 CSRF)和请求归属。后台调用 AuthLink 状态接口,将 `displayCode` 原样返回,不能缓存跨过 `validUntil`。新代码由后台实时获取,不是前端自行生成。
|
|
416
|
-
|
|
417
|
-
尚未完成时返回 `{requestId,status,displayCode?}`;状态可为 new / pending / approved / consumed / rejected / expired。`approved` 不能触发成功事件。
|
|
235
|
+
## 7. 参数、事件与样式
|
|
418
236
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 |
|
|
433
255
|
|
|
434
|
-
|
|
256
|
+
A 为默认居中卡片;B 为桌面分栏、窄屏纵排;C 为紧凑卡片、手机底部弹层。切换 locale/variant 不重新创建请求。原生 dialog.showModal() 浏览器支持是必需的。
|
|
435
257
|
|
|
436
|
-
-
|
|
437
|
-
- 倒计时使用服务端时间锚点和单调时钟;本机系统时钟错误不直接导致倒计时漂移。网络延迟仍有影响,跨步时可能短暂显示“正在更新数字”,稳定后再核对。
|
|
438
|
-
- 旧数字一过本步期限就隐藏,即使网络请求仍未返回。整个请求过期、拒绝或失败会隐藏数字和二维码。
|
|
439
|
-
- 组件按请求检查成功结果、用途、双重认证策略和有效期。取消、卸载和切换请求后,旧响应不能触发成功回调。
|
|
440
|
-
- 每个组件实例对同一请求只发一次成功事件;刷新或重新挂载不替代网站持久化幂等。
|
|
441
|
-
- 包不接收网站业务授权内容,不提供 apiKey 配置,不执行支付/授权等业务操作。
|
|
442
|
-
- 二维码在浏览器内生成,忽略后台 qrSvg,不使用 innerHTML;不上传给第三方图片服务。应用 CSP 需允许 `img-src 'self' data:`。
|
|
258
|
+
CSS 需单独导入。弹窗挂到 document.body,主题变量放在 :root 或 .authlink-dialog:`--authlink-foreground`、`--authlink-background`、`--authlink-accent`、`--authlink-muted`。Vue 的 class 支持字符串、数组和对象,并与 className 合并。SSR 不展示弹窗或创建请求。
|
|
443
259
|
|
|
444
|
-
|
|
260
|
+
语言:`zh-CN` (default), `zh-TW`, `en`, `ja`, `bn`, `es`, `fil`, `hi`, `id`, `ms`, `pt`, `th`, `vi`。未指定或不支持时回退简体中文,可导入 supportedLocales 获取列表。
|
|
445
261
|
|
|
446
|
-
|
|
262
|
+
## 8. 传输配置与排错
|
|
447
263
|
|
|
448
|
-
|
|
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 |
|
|
449
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 秒,关闭/卸载会取消观察。
|
|
450
277
|
|
|
451
|
-
|
|
452
|
-
| --- | --- |
|
|
453
|
-
| `en` | English |
|
|
454
|
-
| `zh-CN` | 简体中文 (default) |
|
|
455
|
-
| `zh-TW` | 繁體中文 |
|
|
456
|
-
| `ja` | 日本語 |
|
|
457
|
-
| `bn` | বাংলা |
|
|
458
|
-
| `es` | Español |
|
|
459
|
-
| `fil` | Filipino |
|
|
460
|
-
| `hi` | हिन्दी |
|
|
461
|
-
| `id` | Bahasa Indonesia |
|
|
462
|
-
| `ms` | Bahasa Melayu |
|
|
463
|
-
| `pt` | Português |
|
|
464
|
-
| `th` | ไทย |
|
|
465
|
-
| `vi` | Tiếng Việt |
|
|
466
|
-
|
|
467
|
-
可以从根入口导入 `supportedLocales` 获取支持列表。文档默认英文,另有中文与日文版本。
|
|
468
|
-
|
|
469
|
-
## 兼容性
|
|
470
|
-
|
|
471
|
-
React 18.2+ / 19、Vue 3.3+、TypeScript、ESM。React 和 Vue 是可选 peer,只安装所用框架。构建工具环境需 Node 22.13+。
|
|
278
|
+
数字过期会隐藏,不在前端生成或缓存新数字;二维码在浏览器本地生成。若页面启用 CSP,图片来源需要允许 data:。确认回调、localStorage 或浏览器自报状态都不作为业务授权依据。
|
|
472
279
|
|
|
473
280
|
## 许可证
|
|
474
281
|
|
|
475
|
-
MIT
|
|
282
|
+
MIT。完整文本见 LICENSE,第三方归属见 THIRD_PARTY_NOTICES.md。
|