@csntgao/uni-base 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/.prettierignore +4 -0
- package/.prettierrc.json +6 -0
- package/AGENTS.md +519 -0
- package/CLAUDE.md +512 -0
- package/README.md +734 -0
- package/docs//346/225/260/345/255/227/345/221/230/345/267/245/345/256/214/346/225/264/346/212/275/345/261/211/346/216/245/345/205/245/350/257/264/346/230/216.md +110 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/215/225/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +79 -0
- package/docs//347/273/204/347/273/207/345/221/230/345/267/245/345/244/232/351/200/211/347/273/204/344/273/266/346/216/245/345/205/245/350/257/264/346/230/216.md +93 -0
- package/docs//350/264/246/346/210/267/344/270/216/347/231/273/345/275/225/347/273/204/344/273/266/345/212/250/346/200/201/346/216/245/345/205/245/350/257/264/346/230/216.md +201 -0
- package/eslint.config.js +30 -0
- package/examples/vanilla/index.html +33 -0
- package/examples/vanilla/main.js +247 -0
- package/examples/vanilla/package.json +15 -0
- package/examples/vanilla/public/runtime-config.js +10 -0
- package/examples/vanilla/style.css +99 -0
- package/package.json +31 -0
- package/packages/core/package.json +30 -0
- package/packages/core/src/client.ts +1132 -0
- package/packages/core/src/customer-support-client.ts +825 -0
- package/packages/core/src/customer-support-types.ts +123 -0
- package/packages/core/src/index.ts +79 -0
- package/packages/core/src/notification-center-client.ts +462 -0
- package/packages/core/src/notification-center-types.ts +154 -0
- package/packages/core/src/protocol.ts +237 -0
- package/packages/core/src/types.ts +170 -0
- package/packages/core/tests/client.test.ts +760 -0
- package/packages/core/tests/customer-support-client.test.ts +576 -0
- package/packages/core/tests/fake-websocket.ts +55 -0
- package/packages/core/tests/notification-center-client.test.ts +467 -0
- package/packages/core/tests/protocol.test.ts +124 -0
- package/packages/core/tsconfig.json +7 -0
- package/packages/core/tsup.config.ts +11 -0
- package/packages/web-components/package.json +35 -0
- package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
- package/packages/web-components/src/account-context.ts +1540 -0
- package/packages/web-components/src/application-switcher-transport.ts +243 -0
- package/packages/web-components/src/application-switcher.ts +1103 -0
- package/packages/web-components/src/chat.ts +1754 -0
- package/packages/web-components/src/customer-support-chat.ts +862 -0
- package/packages/web-components/src/customer-support-transport.ts +269 -0
- package/packages/web-components/src/index.ts +256 -0
- package/packages/web-components/src/notification-center.ts +1066 -0
- package/packages/web-components/src/notification-popover.ts +615 -0
- package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
- package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
- package/packages/web-components/src/organization-onboarding.ts +1216 -0
- package/packages/web-components/src/organization-selector.ts +534 -0
- package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
- package/packages/web-components/src/prototype-icons.ts +242 -0
- package/packages/web-components/src/safe-image-url.ts +29 -0
- package/packages/web-components/src/safe-markdown.ts +192 -0
- package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
- package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
- package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
- package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
- package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
- package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
- package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
- package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
- package/packages/web-components/src/user-login-transport.ts +563 -0
- package/packages/web-components/src/user-login.ts +2027 -0
- package/packages/web-components/src/user-menu.ts +880 -0
- package/packages/web-components/tests/account-context.test.ts +798 -0
- package/packages/web-components/tests/application-switcher.test.ts +562 -0
- package/packages/web-components/tests/chat.test.ts +1130 -0
- package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
- package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
- package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
- package/packages/web-components/tests/notification-center.test.ts +429 -0
- package/packages/web-components/tests/notification-popover.test.ts +283 -0
- package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
- package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
- package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
- package/packages/web-components/tests/organization-selector.test.ts +127 -0
- package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
- package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
- package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
- package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
- package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
- package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
- package/packages/web-components/tests/user-login-transport.test.ts +443 -0
- package/packages/web-components/tests/user-login.test.ts +626 -0
- package/packages/web-components/tests/user-menu.test.ts +271 -0
- package/packages/web-components/tsconfig.json +8 -0
- package/packages/web-components/tsup.config.ts +12 -0
- package/packages/web-components/tsup.runtime.config.ts +37 -0
- package/pnpm-workspace.yaml +6 -0
- package/tsconfig.base.json +21 -0
- package/vitest.config.ts +20 -0
- package//344/272/244/346/216/245/346/226/207/346/241/243.md +254 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/** @deprecated The 40.1 login prototype does not use an image captcha. */
|
|
2
|
+
export interface UserLoginCaptcha {
|
|
3
|
+
seed: string
|
|
4
|
+
imageUrl: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface UserLoginSmsRequest {
|
|
8
|
+
mobile: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface UserLoginRequest {
|
|
12
|
+
mobile: string
|
|
13
|
+
smsCode: string
|
|
14
|
+
/** Host-configured application name. Optional only for legacy direct transport calls. */
|
|
15
|
+
applicationName?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UserLoginPasswordRequest {
|
|
19
|
+
mobile: string
|
|
20
|
+
password: string
|
|
21
|
+
applicationName: string
|
|
22
|
+
rememberSession: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UserLoginQrChallengeRequest {
|
|
26
|
+
applicationName: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UserLoginQrChallenge {
|
|
30
|
+
challengeId: string
|
|
31
|
+
imageUrl: string
|
|
32
|
+
/** Server-advertised polling interval. The component falls back to its configured interval. */
|
|
33
|
+
pollIntervalMs?: number
|
|
34
|
+
expiresInSeconds?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type UserLoginQrStatus = 'pending' | 'scanned' | 'confirmed' | 'expired' | 'rejected'
|
|
38
|
+
|
|
39
|
+
export interface UserLoginQrPollResult {
|
|
40
|
+
status: UserLoginQrStatus
|
|
41
|
+
result?: UserLoginSuccess
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UserLoginSuccess {
|
|
45
|
+
applicationName: string
|
|
46
|
+
/** True only when this login request created the account. */
|
|
47
|
+
isNewUser?: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type UserLoginTransportErrorCode =
|
|
51
|
+
| 'INVALID_ARGUMENT'
|
|
52
|
+
| 'SMS_CODE_INVALID_OR_EXPIRED'
|
|
53
|
+
| 'SMS_SEND_TOO_FREQUENT'
|
|
54
|
+
| 'SMS_LOGIN_FAILED'
|
|
55
|
+
| 'PASSWORD_INVALID'
|
|
56
|
+
| 'ACCOUNT_LOCKED'
|
|
57
|
+
| 'ACCOUNT_DISABLED'
|
|
58
|
+
| 'ORG_ACCESS_DENIED'
|
|
59
|
+
| 'QR_CHALLENGE_INVALID'
|
|
60
|
+
| 'QR_CHALLENGE_EXPIRED'
|
|
61
|
+
| 'QR_LOGIN_REJECTED'
|
|
62
|
+
| 'QR_LOGIN_UNAVAILABLE'
|
|
63
|
+
| 'UNKNOWN'
|
|
64
|
+
|
|
65
|
+
export class UserLoginTransportError extends Error {
|
|
66
|
+
readonly code: UserLoginTransportErrorCode
|
|
67
|
+
readonly displayMessage?: string
|
|
68
|
+
|
|
69
|
+
constructor(code: UserLoginTransportErrorCode, displayMessage?: string) {
|
|
70
|
+
super('User login transport request failed')
|
|
71
|
+
this.name = 'UserLoginTransportError'
|
|
72
|
+
this.code = code
|
|
73
|
+
this.displayMessage =
|
|
74
|
+
code === 'ORG_ACCESS_DENIED'
|
|
75
|
+
? normalizeOrganizationAccessDeniedMessage(displayMessage)
|
|
76
|
+
: undefined
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface UserLoginTransport {
|
|
81
|
+
/** @deprecated Retained for source compatibility; the component does not call it. */
|
|
82
|
+
createCaptcha?(): UserLoginCaptcha | Promise<UserLoginCaptcha>
|
|
83
|
+
sendSmsCode(request: UserLoginSmsRequest): Promise<void>
|
|
84
|
+
login(request: UserLoginRequest): Promise<UserLoginSuccess>
|
|
85
|
+
loginWithPassword?(request: UserLoginPasswordRequest): Promise<UserLoginSuccess>
|
|
86
|
+
createQrChallenge?(request: UserLoginQrChallengeRequest): Promise<UserLoginQrChallenge>
|
|
87
|
+
pollQrChallenge?(challengeId: string, applicationName?: string): Promise<UserLoginQrPollResult>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface UniplatBaseUserLoginMetadata {
|
|
91
|
+
readonly [key: string]: unknown
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Optional browser/device context recorded with an application login.
|
|
96
|
+
*
|
|
97
|
+
* A Host-provided `deviceId` takes priority over the SDK's deterministic
|
|
98
|
+
* browser-fingerprint identifier. The SDK never persists this information.
|
|
99
|
+
*/
|
|
100
|
+
export interface UniplatBaseUserLoginDeviceInfo {
|
|
101
|
+
deviceType?: string
|
|
102
|
+
deviceName?: string
|
|
103
|
+
deviceId?: string
|
|
104
|
+
deviceBrand?: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface UniplatBaseUserLoginTransportOptions {
|
|
108
|
+
apiBaseUrl: string
|
|
109
|
+
/** @deprecated Pass applicationName to the User Login component instead. */
|
|
110
|
+
applicationName?: string
|
|
111
|
+
/** Optional Host-provided device context for UniplatBase login event records. */
|
|
112
|
+
deviceInfo?: UniplatBaseUserLoginDeviceInfo
|
|
113
|
+
setAccessToken(accessToken: string, metadata: UniplatBaseUserLoginMetadata): void | Promise<void>
|
|
114
|
+
fetch?: typeof fetch
|
|
115
|
+
/** @deprecated The 40.1 login prototype does not use an image captcha. */
|
|
116
|
+
seedFactory?: () => string | number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const SMS_PATH = 'general/project/uniplat_base/service/anonymous/system.user/sendVerifyCode'
|
|
120
|
+
const LOGIN_PATH =
|
|
121
|
+
'general/project/uniplat_base/service/anonymous/application.identity/login-application'
|
|
122
|
+
|
|
123
|
+
export function createUniplatBaseUserLoginTransport(
|
|
124
|
+
options: UniplatBaseUserLoginTransportOptions,
|
|
125
|
+
): UserLoginTransport {
|
|
126
|
+
if (!options || typeof options.setAccessToken !== 'function') {
|
|
127
|
+
throw new TypeError('UniplatBase user login transport requires setAccessToken()')
|
|
128
|
+
}
|
|
129
|
+
const apiBaseUrl = validateApiBaseUrl(options.apiBaseUrl)
|
|
130
|
+
const legacyApplicationName = normalizeOptionalText(options.applicationName)
|
|
131
|
+
const loginDeviceInfo = resolveLoginDeviceInfo(options.deviceInfo)
|
|
132
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch
|
|
133
|
+
const smsEndpoint = `${apiBaseUrl}/${SMS_PATH}`
|
|
134
|
+
const loginEndpoint = `${apiBaseUrl}/${LOGIN_PATH}`
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
async sendSmsCode(request) {
|
|
138
|
+
const mobile = requireMobile(request.mobile)
|
|
139
|
+
await requestData(fetchImplementation, smsEndpoint, {
|
|
140
|
+
mobile,
|
|
141
|
+
})
|
|
142
|
+
},
|
|
143
|
+
async login(request) {
|
|
144
|
+
const mobile = requireMobile(request.mobile)
|
|
145
|
+
const smsCode = normalizeRequiredText(request.smsCode, 'SMS code')
|
|
146
|
+
const applicationName = normalizeRequiredText(
|
|
147
|
+
request.applicationName || legacyApplicationName,
|
|
148
|
+
'Application name',
|
|
149
|
+
)
|
|
150
|
+
const deviceInfo = await loginDeviceInfo
|
|
151
|
+
const data = await requestData(fetchImplementation, loginEndpoint, {
|
|
152
|
+
auth_method: 'sms',
|
|
153
|
+
mobile,
|
|
154
|
+
verify_code: smsCode,
|
|
155
|
+
application_name: applicationName,
|
|
156
|
+
client_id: applicationName,
|
|
157
|
+
...deviceInfo,
|
|
158
|
+
})
|
|
159
|
+
return persistLogin(data, applicationName, options.setAccessToken)
|
|
160
|
+
},
|
|
161
|
+
async loginWithPassword(request) {
|
|
162
|
+
const mobile = requireMobile(request.mobile)
|
|
163
|
+
let password = requirePassword(request.password)
|
|
164
|
+
const applicationName = normalizeRequiredText(
|
|
165
|
+
request.applicationName || legacyApplicationName,
|
|
166
|
+
'Application name',
|
|
167
|
+
)
|
|
168
|
+
try {
|
|
169
|
+
const deviceInfo = await loginDeviceInfo
|
|
170
|
+
const data = await requestData(fetchImplementation, loginEndpoint, {
|
|
171
|
+
auth_method: 'password',
|
|
172
|
+
mobile,
|
|
173
|
+
password,
|
|
174
|
+
application_name: applicationName,
|
|
175
|
+
client_id: applicationName,
|
|
176
|
+
remember_session: request.rememberSession === true,
|
|
177
|
+
...deviceInfo,
|
|
178
|
+
})
|
|
179
|
+
return await persistLogin(data, applicationName, options.setAccessToken)
|
|
180
|
+
} finally {
|
|
181
|
+
password = ''
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
async createQrChallenge(request) {
|
|
185
|
+
const applicationName = normalizeRequiredText(
|
|
186
|
+
request.applicationName || legacyApplicationName,
|
|
187
|
+
'Application name',
|
|
188
|
+
)
|
|
189
|
+
const deviceInfo = await loginDeviceInfo
|
|
190
|
+
const data = await requestData(fetchImplementation, loginEndpoint, {
|
|
191
|
+
auth_method: 'qr',
|
|
192
|
+
action: 'create',
|
|
193
|
+
application_name: applicationName,
|
|
194
|
+
client_id: applicationName,
|
|
195
|
+
...deviceInfo,
|
|
196
|
+
})
|
|
197
|
+
const challengeId = normalizeQrChallengeId(data.challenge_id)
|
|
198
|
+
const imageUrl = normalizeQrImageUrl(data.image_data_url)
|
|
199
|
+
const pollIntervalMs = optionalInteger(data.poll_interval_ms, 500, 10_000)
|
|
200
|
+
const expiresInSeconds = optionalInteger(data.expires_in, 1, 600)
|
|
201
|
+
if (!challengeId || !imageUrl || imageUrl.includes(challengeId)) {
|
|
202
|
+
throw new UserLoginTransportError('UNKNOWN')
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
challengeId,
|
|
206
|
+
imageUrl,
|
|
207
|
+
...(pollIntervalMs === undefined ? {} : { pollIntervalMs }),
|
|
208
|
+
...(expiresInSeconds === undefined ? {} : { expiresInSeconds }),
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
async pollQrChallenge(challengeId, requestedApplicationName) {
|
|
212
|
+
const normalizedChallengeId = normalizeQrChallengeId(challengeId)
|
|
213
|
+
const applicationName = normalizeRequiredText(
|
|
214
|
+
requestedApplicationName || legacyApplicationName,
|
|
215
|
+
'Application name',
|
|
216
|
+
)
|
|
217
|
+
if (!normalizedChallengeId) throw new UserLoginTransportError('QR_CHALLENGE_INVALID')
|
|
218
|
+
let data: Record<string, unknown>
|
|
219
|
+
try {
|
|
220
|
+
const deviceInfo = await loginDeviceInfo
|
|
221
|
+
data = await requestData(fetchImplementation, loginEndpoint, {
|
|
222
|
+
auth_method: 'qr',
|
|
223
|
+
action: 'poll',
|
|
224
|
+
application_name: applicationName,
|
|
225
|
+
client_id: applicationName,
|
|
226
|
+
challenge_id: normalizedChallengeId,
|
|
227
|
+
...deviceInfo,
|
|
228
|
+
})
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof UserLoginTransportError && error.code === 'QR_CHALLENGE_EXPIRED') {
|
|
231
|
+
return { status: 'expired' }
|
|
232
|
+
}
|
|
233
|
+
if (error instanceof UserLoginTransportError && error.code === 'QR_LOGIN_REJECTED') {
|
|
234
|
+
return { status: 'rejected' }
|
|
235
|
+
}
|
|
236
|
+
throw error
|
|
237
|
+
}
|
|
238
|
+
const status = normalizeQrStatus(data.status)
|
|
239
|
+
if (!status) throw new UserLoginTransportError('UNKNOWN')
|
|
240
|
+
if (status !== 'confirmed') return { status }
|
|
241
|
+
return {
|
|
242
|
+
status,
|
|
243
|
+
result: await persistLogin(data, applicationName, options.setAccessToken),
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resolveLoginDeviceInfo(
|
|
250
|
+
configured: UniplatBaseUserLoginDeviceInfo | undefined,
|
|
251
|
+
): Promise<Record<string, string>> {
|
|
252
|
+
return resolveLoginDeviceInfoAsync(configured)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function resolveLoginDeviceInfoAsync(
|
|
256
|
+
configured: UniplatBaseUserLoginDeviceInfo | undefined,
|
|
257
|
+
): Promise<Record<string, string>> {
|
|
258
|
+
const defaults = defaultBrowserDeviceInfo()
|
|
259
|
+
const deviceType = normalizeOptionalLoginDeviceText(
|
|
260
|
+
configured?.deviceType ?? defaults.device_type,
|
|
261
|
+
'device_type',
|
|
262
|
+
50,
|
|
263
|
+
)
|
|
264
|
+
const deviceName = normalizeOptionalLoginDeviceText(
|
|
265
|
+
configured?.deviceName ?? defaults.device_name,
|
|
266
|
+
'device_name',
|
|
267
|
+
200,
|
|
268
|
+
)
|
|
269
|
+
const deviceId = normalizeOptionalLoginDeviceText(configured?.deviceId, 'device_id', 200)
|
|
270
|
+
const deviceBrand = normalizeOptionalLoginDeviceText(
|
|
271
|
+
configured?.deviceBrand ?? defaults.device_brand,
|
|
272
|
+
'device_brand',
|
|
273
|
+
500,
|
|
274
|
+
)
|
|
275
|
+
return {
|
|
276
|
+
...(deviceType ? { device_type: deviceType } : {}),
|
|
277
|
+
...(deviceName ? { device_name: deviceName } : {}),
|
|
278
|
+
device_id: deviceId ?? (await createBrowserFingerprintId()),
|
|
279
|
+
...(deviceBrand ? { device_brand: deviceBrand } : {}),
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function defaultBrowserDeviceInfo(): Record<string, string> {
|
|
284
|
+
if (typeof navigator === 'undefined') {
|
|
285
|
+
return { device_type: 'web', device_name: 'Browser', device_brand: 'Web' }
|
|
286
|
+
}
|
|
287
|
+
const userAgent = navigator.userAgent || ''
|
|
288
|
+
const userAgentData = (navigator as NavigatorWithUserAgentData).userAgentData
|
|
289
|
+
const platform = userAgentData?.platform || navigator.platform || ''
|
|
290
|
+
const brands = userAgentData?.brands
|
|
291
|
+
?.map(({ brand, version }) => [brand, version].filter(Boolean).join(' '))
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.join(', ')
|
|
294
|
+
return {
|
|
295
|
+
device_type: userAgentData?.mobile ? 'mobile_web' : 'web',
|
|
296
|
+
device_name: [browserName(userAgent), platform].filter(Boolean).join(' on '),
|
|
297
|
+
device_brand: brands || navigator.vendor || platform || 'Web',
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
interface NavigatorWithUserAgentData extends Navigator {
|
|
302
|
+
userAgentData?: {
|
|
303
|
+
mobile?: boolean
|
|
304
|
+
platform?: string
|
|
305
|
+
brands?: readonly { brand?: string; version?: string }[]
|
|
306
|
+
}
|
|
307
|
+
deviceMemory?: number
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function createBrowserFingerprintId(): Promise<string> {
|
|
311
|
+
const fingerprint = browserFingerprintSource()
|
|
312
|
+
const subtle = globalThis.crypto?.subtle
|
|
313
|
+
if (subtle && typeof TextEncoder !== 'undefined') {
|
|
314
|
+
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(fingerprint))
|
|
315
|
+
return `web-${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}`
|
|
316
|
+
}
|
|
317
|
+
return `web-${stableFingerprintHash(fingerprint)}`
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function browserFingerprintSource(): string {
|
|
321
|
+
if (typeof navigator === 'undefined') return 'uniplat:web:unknown-browser'
|
|
322
|
+
const userAgentData = (navigator as NavigatorWithUserAgentData).userAgentData
|
|
323
|
+
const screenInfo = typeof screen === 'undefined' ? undefined : screen
|
|
324
|
+
const screenSize = screenInfo
|
|
325
|
+
? [screenInfo.width, screenInfo.height].sort((left, right) => left - right).join('x')
|
|
326
|
+
: ''
|
|
327
|
+
return JSON.stringify({
|
|
328
|
+
userAgent: navigator.userAgent || '',
|
|
329
|
+
platform: userAgentData?.platform || navigator.platform || '',
|
|
330
|
+
vendor: navigator.vendor || '',
|
|
331
|
+
languages: navigator.languages?.join(',') || navigator.language || '',
|
|
332
|
+
mobile: userAgentData?.mobile === true,
|
|
333
|
+
brands: userAgentData?.brands ?? [],
|
|
334
|
+
screen: screenSize,
|
|
335
|
+
colorDepth: screenInfo?.colorDepth ?? 0,
|
|
336
|
+
pixelDepth: screenInfo?.pixelDepth ?? 0,
|
|
337
|
+
touchPoints: navigator.maxTouchPoints ?? 0,
|
|
338
|
+
hardwareConcurrency: navigator.hardwareConcurrency ?? 0,
|
|
339
|
+
deviceMemory: (navigator as NavigatorWithUserAgentData).deviceMemory ?? 0,
|
|
340
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function stableFingerprintHash(value: string): string {
|
|
345
|
+
let hash = 2_166_136_261
|
|
346
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
347
|
+
hash = Math.imul(hash ^ value.charCodeAt(index), 16_777_619)
|
|
348
|
+
}
|
|
349
|
+
return (hash >>> 0).toString(16).padStart(8, '0')
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function browserName(userAgent: string): string {
|
|
353
|
+
if (/Edg\//.test(userAgent)) return 'Microsoft Edge'
|
|
354
|
+
if (/Firefox\//.test(userAgent)) return 'Firefox'
|
|
355
|
+
if (/Chrome\//.test(userAgent) || /Chromium\//.test(userAgent)) return 'Chrome'
|
|
356
|
+
if (/Safari\//.test(userAgent)) return 'Safari'
|
|
357
|
+
return 'Browser'
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function normalizeOptionalLoginDeviceText(
|
|
361
|
+
rawValue: string | undefined,
|
|
362
|
+
field: string,
|
|
363
|
+
maxLength: number,
|
|
364
|
+
): string | undefined {
|
|
365
|
+
if (rawValue === undefined) return undefined
|
|
366
|
+
const value = normalizeOptionalText(rawValue)
|
|
367
|
+
if (value.length > maxLength) {
|
|
368
|
+
throw new TypeError(`${field} must contain at most ${maxLength} characters`)
|
|
369
|
+
}
|
|
370
|
+
return value || undefined
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function persistLogin(
|
|
374
|
+
data: Record<string, unknown>,
|
|
375
|
+
expectedApplicationName: string,
|
|
376
|
+
setAccessToken: UniplatBaseUserLoginTransportOptions['setAccessToken'],
|
|
377
|
+
): Promise<UserLoginSuccess> {
|
|
378
|
+
let accessToken = text(data.token).trim()
|
|
379
|
+
try {
|
|
380
|
+
const responseApplicationName = text(data.application_name).trim()
|
|
381
|
+
if (!accessToken || responseApplicationName !== expectedApplicationName) {
|
|
382
|
+
throw new UserLoginTransportError('UNKNOWN')
|
|
383
|
+
}
|
|
384
|
+
await setAccessToken(accessToken, withoutCredentials(data))
|
|
385
|
+
const isNewUser = optionalBoolean(data.is_new_user)
|
|
386
|
+
return {
|
|
387
|
+
applicationName: responseApplicationName,
|
|
388
|
+
...(isNewUser === undefined ? {} : { isNewUser }),
|
|
389
|
+
}
|
|
390
|
+
} finally {
|
|
391
|
+
accessToken = ''
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function requestData(
|
|
396
|
+
fetchImplementation: typeof fetch | undefined,
|
|
397
|
+
endpoint: string,
|
|
398
|
+
body: Record<string, unknown>,
|
|
399
|
+
): Promise<Record<string, unknown>> {
|
|
400
|
+
if (!fetchImplementation) throw new UserLoginTransportError('UNKNOWN')
|
|
401
|
+
const response = await fetchImplementation(endpoint, {
|
|
402
|
+
method: 'POST',
|
|
403
|
+
credentials: 'include',
|
|
404
|
+
headers: {
|
|
405
|
+
accept: 'application/json',
|
|
406
|
+
'content-type': 'application/json',
|
|
407
|
+
},
|
|
408
|
+
body: JSON.stringify(body),
|
|
409
|
+
})
|
|
410
|
+
if (!response.ok) throw new UserLoginTransportError('UNKNOWN')
|
|
411
|
+
|
|
412
|
+
const envelope = asRecord(await response.json())
|
|
413
|
+
if (number(envelope.rescode) !== 0) {
|
|
414
|
+
const code = normalizeTransportErrorCode(envelope.error_code)
|
|
415
|
+
throw new UserLoginTransportError(code, code === 'ORG_ACCESS_DENIED' ? text(envelope.msg) : '')
|
|
416
|
+
}
|
|
417
|
+
return asRecord(envelope.data)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function validateApiBaseUrl(rawValue: string): string {
|
|
421
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
422
|
+
if (!value) throw new TypeError('UniplatBase API base URL is required')
|
|
423
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
424
|
+
let url: URL
|
|
425
|
+
try {
|
|
426
|
+
url = new URL(value, documentBase)
|
|
427
|
+
} catch {
|
|
428
|
+
throw new TypeError('UniplatBase API base URL is invalid')
|
|
429
|
+
}
|
|
430
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
431
|
+
throw new TypeError('UniplatBase API base URL must use HTTP(S) without user credentials')
|
|
432
|
+
}
|
|
433
|
+
if (url.search || url.hash) {
|
|
434
|
+
throw new TypeError('UniplatBase API base URL must not contain query or hash')
|
|
435
|
+
}
|
|
436
|
+
return url.href.replace(/\/+$/, '')
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function requireMobile(rawValue: string): string {
|
|
440
|
+
const mobile = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
441
|
+
if (!/^1[3-9]\d{9}$/.test(mobile)) throw new Error('Mobile number is invalid')
|
|
442
|
+
return mobile
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function requirePassword(rawValue: string): string {
|
|
446
|
+
const password = typeof rawValue === 'string' ? rawValue : ''
|
|
447
|
+
if (!password || password.length > 1024) throw new TypeError('Password is required')
|
|
448
|
+
return password
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function normalizeRequiredText(rawValue: unknown, label: string): string {
|
|
452
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
453
|
+
if (!value) throw new TypeError(`${label} is required`)
|
|
454
|
+
return value
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function normalizeOptionalText(rawValue: unknown): string {
|
|
458
|
+
return typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function withoutCredentials(data: Record<string, unknown>): UniplatBaseUserLoginMetadata {
|
|
462
|
+
const metadata = { ...data }
|
|
463
|
+
delete metadata.token
|
|
464
|
+
delete metadata.jwt
|
|
465
|
+
delete metadata.access_token
|
|
466
|
+
return metadata
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
470
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
471
|
+
? (value as Record<string, unknown>)
|
|
472
|
+
: {}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function text(value: unknown): string {
|
|
476
|
+
return typeof value === 'string' ? value : ''
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function number(value: unknown): number {
|
|
480
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function optionalBoolean(value: unknown): boolean | undefined {
|
|
484
|
+
return typeof value === 'boolean' ? value : undefined
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function optionalInteger(value: unknown, minimum: number, maximum: number): number | undefined {
|
|
488
|
+
return typeof value === 'number' &&
|
|
489
|
+
Number.isInteger(value) &&
|
|
490
|
+
value >= minimum &&
|
|
491
|
+
value <= maximum
|
|
492
|
+
? value
|
|
493
|
+
: undefined
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function normalizeQrChallengeId(value: unknown): string {
|
|
497
|
+
const challengeId = text(value).trim()
|
|
498
|
+
return /^[A-Za-z0-9_-]{32,128}$/.test(challengeId) ? challengeId : ''
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function normalizeQrImageUrl(value: unknown): string {
|
|
502
|
+
const imageUrl = text(value).trim()
|
|
503
|
+
if (/^data:image\/(?:png|jpeg|gif|webp|svg\+xml)[;,]/i.test(imageUrl)) return imageUrl
|
|
504
|
+
let url: URL
|
|
505
|
+
try {
|
|
506
|
+
url = new URL(imageUrl)
|
|
507
|
+
} catch {
|
|
508
|
+
return ''
|
|
509
|
+
}
|
|
510
|
+
if (
|
|
511
|
+
!['http:', 'https:'].includes(url.protocol) ||
|
|
512
|
+
url.username ||
|
|
513
|
+
url.password ||
|
|
514
|
+
url.search ||
|
|
515
|
+
url.hash
|
|
516
|
+
) {
|
|
517
|
+
return ''
|
|
518
|
+
}
|
|
519
|
+
return url.href
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function normalizeQrStatus(value: unknown): UserLoginQrStatus | undefined {
|
|
523
|
+
return ['pending', 'scanned', 'confirmed', 'expired', 'rejected'].includes(text(value))
|
|
524
|
+
? (value as UserLoginQrStatus)
|
|
525
|
+
: undefined
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function normalizeTransportErrorCode(value: unknown): UserLoginTransportErrorCode {
|
|
529
|
+
if (
|
|
530
|
+
[
|
|
531
|
+
'INVALID_ARGUMENT',
|
|
532
|
+
'SMS_CODE_INVALID_OR_EXPIRED',
|
|
533
|
+
'SMS_SEND_TOO_FREQUENT',
|
|
534
|
+
'SMS_LOGIN_FAILED',
|
|
535
|
+
'PASSWORD_INVALID',
|
|
536
|
+
'ACCOUNT_LOCKED',
|
|
537
|
+
'ACCOUNT_DISABLED',
|
|
538
|
+
'ORG_ACCESS_DENIED',
|
|
539
|
+
'QR_CHALLENGE_INVALID',
|
|
540
|
+
'QR_CHALLENGE_EXPIRED',
|
|
541
|
+
'QR_LOGIN_REJECTED',
|
|
542
|
+
'QR_LOGIN_UNAVAILABLE',
|
|
543
|
+
].includes(text(value))
|
|
544
|
+
) {
|
|
545
|
+
return value as UserLoginTransportErrorCode
|
|
546
|
+
}
|
|
547
|
+
return 'UNKNOWN'
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function normalizeOrganizationAccessDeniedMessage(value: unknown): string | undefined {
|
|
551
|
+
const message = text(value).trim()
|
|
552
|
+
const prefix = '当前用户没有可用的'
|
|
553
|
+
const suffix = '应用实例'
|
|
554
|
+
const content = message.endsWith('。') || message.endsWith('!') ? message.slice(0, -1) : message
|
|
555
|
+
if (!content.startsWith(prefix) || !content.endsWith(suffix)) return undefined
|
|
556
|
+
const applicationTitle = content.slice(prefix.length, -suffix.length)
|
|
557
|
+
if (!applicationTitle || applicationTitle.length > 64) return undefined
|
|
558
|
+
const containsUnsafeCharacter = [...applicationTitle].some((character) => {
|
|
559
|
+
const code = character.charCodeAt(0)
|
|
560
|
+
return code <= 31 || code === 127 || character === '<' || character === '>'
|
|
561
|
+
})
|
|
562
|
+
return containsUnsafeCharacter ? undefined : message
|
|
563
|
+
}
|