@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.
Files changed (90) hide show
  1. package/.prettierignore +4 -0
  2. package/.prettierrc.json +6 -0
  3. package/AGENTS.md +519 -0
  4. package/CLAUDE.md +512 -0
  5. package/README.md +734 -0
  6. 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
  7. 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
  8. 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
  9. 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
  10. package/eslint.config.js +30 -0
  11. package/examples/vanilla/index.html +33 -0
  12. package/examples/vanilla/main.js +247 -0
  13. package/examples/vanilla/package.json +15 -0
  14. package/examples/vanilla/public/runtime-config.js +10 -0
  15. package/examples/vanilla/style.css +99 -0
  16. package/package.json +31 -0
  17. package/packages/core/package.json +30 -0
  18. package/packages/core/src/client.ts +1132 -0
  19. package/packages/core/src/customer-support-client.ts +825 -0
  20. package/packages/core/src/customer-support-types.ts +123 -0
  21. package/packages/core/src/index.ts +79 -0
  22. package/packages/core/src/notification-center-client.ts +462 -0
  23. package/packages/core/src/notification-center-types.ts +154 -0
  24. package/packages/core/src/protocol.ts +237 -0
  25. package/packages/core/src/types.ts +170 -0
  26. package/packages/core/tests/client.test.ts +760 -0
  27. package/packages/core/tests/customer-support-client.test.ts +576 -0
  28. package/packages/core/tests/fake-websocket.ts +55 -0
  29. package/packages/core/tests/notification-center-client.test.ts +467 -0
  30. package/packages/core/tests/protocol.test.ts +124 -0
  31. package/packages/core/tsconfig.json +7 -0
  32. package/packages/core/tsup.config.ts +11 -0
  33. package/packages/web-components/package.json +35 -0
  34. package/packages/web-components/scripts/verify-runtime-build.mjs +111 -0
  35. package/packages/web-components/src/account-context.ts +1540 -0
  36. package/packages/web-components/src/application-switcher-transport.ts +243 -0
  37. package/packages/web-components/src/application-switcher.ts +1103 -0
  38. package/packages/web-components/src/chat.ts +1754 -0
  39. package/packages/web-components/src/customer-support-chat.ts +862 -0
  40. package/packages/web-components/src/customer-support-transport.ts +269 -0
  41. package/packages/web-components/src/index.ts +256 -0
  42. package/packages/web-components/src/notification-center.ts +1066 -0
  43. package/packages/web-components/src/notification-popover.ts +615 -0
  44. package/packages/web-components/src/organization-multi-employee-selector.ts +1564 -0
  45. package/packages/web-components/src/organization-onboarding-transport.ts +54 -0
  46. package/packages/web-components/src/organization-onboarding.ts +1216 -0
  47. package/packages/web-components/src/organization-selector.ts +534 -0
  48. package/packages/web-components/src/organization-single-employee-selector.ts +114 -0
  49. package/packages/web-components/src/prototype-icons.ts +242 -0
  50. package/packages/web-components/src/safe-image-url.ts +29 -0
  51. package/packages/web-components/src/safe-markdown.ts +192 -0
  52. package/packages/web-components/src/uniplat-base-account-context-transport.ts +625 -0
  53. package/packages/web-components/src/uniplat-base-application-switcher-transport.ts +418 -0
  54. package/packages/web-components/src/uniplat-base-application-ticket.ts +254 -0
  55. package/packages/web-components/src/uniplat-base-identity-ticket.ts +202 -0
  56. package/packages/web-components/src/uniplat-base-notification-center-transport.ts +378 -0
  57. package/packages/web-components/src/uniplat-base-organization-multi-employee-selector-transport.ts +394 -0
  58. package/packages/web-components/src/uniplat-base-organization-onboarding-transport.ts +183 -0
  59. package/packages/web-components/src/uniplat-base-organization-single-employee-selector-transport.ts +14 -0
  60. package/packages/web-components/src/user-login-transport.ts +563 -0
  61. package/packages/web-components/src/user-login.ts +2027 -0
  62. package/packages/web-components/src/user-menu.ts +880 -0
  63. package/packages/web-components/tests/account-context.test.ts +798 -0
  64. package/packages/web-components/tests/application-switcher.test.ts +562 -0
  65. package/packages/web-components/tests/chat.test.ts +1130 -0
  66. package/packages/web-components/tests/customer-support-chat.test.ts +428 -0
  67. package/packages/web-components/tests/customer-support-transport.test.ts +79 -0
  68. package/packages/web-components/tests/notification-center-transport.test.ts +271 -0
  69. package/packages/web-components/tests/notification-center.test.ts +429 -0
  70. package/packages/web-components/tests/notification-popover.test.ts +283 -0
  71. package/packages/web-components/tests/organization-multi-employee-selector-transport.test.ts +332 -0
  72. package/packages/web-components/tests/organization-multi-employee-selector.test.ts +225 -0
  73. package/packages/web-components/tests/organization-onboarding.test.ts +260 -0
  74. package/packages/web-components/tests/organization-selector.test.ts +127 -0
  75. package/packages/web-components/tests/organization-single-employee-selector.test.ts +145 -0
  76. package/packages/web-components/tests/uniplat-base-account-context-transport.test.ts +496 -0
  77. package/packages/web-components/tests/uniplat-base-application-switcher-transport.test.ts +598 -0
  78. package/packages/web-components/tests/uniplat-base-application-ticket.test.ts +233 -0
  79. package/packages/web-components/tests/uniplat-base-identity-ticket.test.ts +226 -0
  80. package/packages/web-components/tests/uniplat-base-organization-onboarding-transport.test.ts +227 -0
  81. package/packages/web-components/tests/user-login-transport.test.ts +443 -0
  82. package/packages/web-components/tests/user-login.test.ts +626 -0
  83. package/packages/web-components/tests/user-menu.test.ts +271 -0
  84. package/packages/web-components/tsconfig.json +8 -0
  85. package/packages/web-components/tsup.config.ts +12 -0
  86. package/packages/web-components/tsup.runtime.config.ts +37 -0
  87. package/pnpm-workspace.yaml +6 -0
  88. package/tsconfig.base.json +21 -0
  89. package/vitest.config.ts +20 -0
  90. 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
+ }