@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,625 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AccountContextOrganization,
|
|
3
|
+
AccountContextEmployeeInfo,
|
|
4
|
+
AccountContextProjection,
|
|
5
|
+
AccountContextUser,
|
|
6
|
+
} from './account-context'
|
|
7
|
+
import { normalizeSafeImageUrl } from './safe-image-url'
|
|
8
|
+
import {
|
|
9
|
+
createApplicationTicketLaunchResult,
|
|
10
|
+
resolveUniplatBaseApplicationEntryUrl,
|
|
11
|
+
} from './uniplat-base-application-ticket'
|
|
12
|
+
import type { ApplicationLaunchResult } from './application-switcher-transport'
|
|
13
|
+
|
|
14
|
+
export type AccountContextApplicationScope = 'open' | 'organization'
|
|
15
|
+
|
|
16
|
+
export type UniplatBaseAccountContextTransportErrorCode =
|
|
17
|
+
| 'AUTH_REQUIRED'
|
|
18
|
+
| 'INVALID_RESPONSE'
|
|
19
|
+
| 'ORGANIZATION_UNAVAILABLE'
|
|
20
|
+
| 'ORGANIZATION_SWITCH_UNSUPPORTED'
|
|
21
|
+
| 'REQUEST_FAILED'
|
|
22
|
+
| 'TOKEN_PERSIST_FAILED'
|
|
23
|
+
|
|
24
|
+
export interface UniplatBaseAccountContextIdentity {
|
|
25
|
+
xid: string
|
|
26
|
+
oid: string
|
|
27
|
+
eid: string
|
|
28
|
+
mid: string
|
|
29
|
+
applicationName: string
|
|
30
|
+
applicationScope: AccountContextApplicationScope
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UniplatBaseAccountContextProjection extends AccountContextProjection {
|
|
34
|
+
applicationName: string
|
|
35
|
+
applicationScope: AccountContextApplicationScope
|
|
36
|
+
user: AccountContextUser
|
|
37
|
+
currentIdentity: UniplatBaseAccountContextIdentity
|
|
38
|
+
currentOrganization: AccountContextOrganization | null
|
|
39
|
+
currentOrganizationId: string | null
|
|
40
|
+
organizations: readonly AccountContextOrganization[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UniplatBaseAccountContextTransport {
|
|
44
|
+
load(applicationName: string): Promise<UniplatBaseAccountContextProjection>
|
|
45
|
+
loadCurrentEmployeeInfo(): Promise<AccountContextEmployeeInfo>
|
|
46
|
+
switchOrganization(
|
|
47
|
+
applicationName: string,
|
|
48
|
+
organizationId: string,
|
|
49
|
+
): Promise<UniplatBaseAccountContextProjection>
|
|
50
|
+
launchAccountSettings(applicationName?: string): Promise<ApplicationLaunchResult>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface UniplatBaseAccountContextTransportOptions {
|
|
54
|
+
serviceBaseUrl: string
|
|
55
|
+
getAccessToken(): string | Promise<string>
|
|
56
|
+
setAccessToken(
|
|
57
|
+
accessToken: string,
|
|
58
|
+
metadata: UniplatBaseAccountContextProjection,
|
|
59
|
+
): void | Promise<void>
|
|
60
|
+
fetch?: typeof fetch
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ApplicationTarget {
|
|
64
|
+
xid: string
|
|
65
|
+
oid: string
|
|
66
|
+
applicationName: string
|
|
67
|
+
applicationScope: AccountContextApplicationScope
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface LoadedContext {
|
|
71
|
+
projection: UniplatBaseAccountContextProjection
|
|
72
|
+
targets: readonly ApplicationTarget[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const LIST_PATH = 'application.identity/list_available_applications'
|
|
76
|
+
const ORGANIZATIONS_PATH = 'application.identity/list_available_organizations'
|
|
77
|
+
const SWITCH_PATH = 'application.identity/switch_current_application'
|
|
78
|
+
const CURRENT_EMPLOYEE_INFO_PATH = 'system.employee_profile/get_current_employee_info'
|
|
79
|
+
const PERSONAL_CENTER_ENTRY_URL = '/personal'
|
|
80
|
+
|
|
81
|
+
export class UniplatBaseAccountContextTransportError extends Error {
|
|
82
|
+
readonly code: UniplatBaseAccountContextTransportErrorCode
|
|
83
|
+
|
|
84
|
+
constructor(code: UniplatBaseAccountContextTransportErrorCode, message: string) {
|
|
85
|
+
super(message)
|
|
86
|
+
this.name = 'UniplatBaseAccountContextTransportError'
|
|
87
|
+
this.code = code
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createUniplatBaseAccountContextTransport(
|
|
92
|
+
options: UniplatBaseAccountContextTransportOptions,
|
|
93
|
+
): UniplatBaseAccountContextTransport {
|
|
94
|
+
if (!options || typeof options.getAccessToken !== 'function') {
|
|
95
|
+
throw new TypeError('UniplatBase account context transport requires getAccessToken()')
|
|
96
|
+
}
|
|
97
|
+
if (typeof options.setAccessToken !== 'function') {
|
|
98
|
+
throw new TypeError('UniplatBase account context transport requires setAccessToken()')
|
|
99
|
+
}
|
|
100
|
+
const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
|
|
101
|
+
const listEndpoint = `${serviceBaseUrl}/${LIST_PATH}`
|
|
102
|
+
const organizationsEndpoint = `${serviceBaseUrl}/${ORGANIZATIONS_PATH}`
|
|
103
|
+
const switchEndpoint = `${serviceBaseUrl}/${SWITCH_PATH}`
|
|
104
|
+
const currentEmployeeInfoEndpoint = `${serviceBaseUrl}/${CURRENT_EMPLOYEE_INFO_PATH}`
|
|
105
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch
|
|
106
|
+
|
|
107
|
+
const loadContext = async (
|
|
108
|
+
applicationName: string,
|
|
109
|
+
accessToken: string,
|
|
110
|
+
): Promise<LoadedContext> => {
|
|
111
|
+
const [applicationsData, organizationsData] = await Promise.all([
|
|
112
|
+
requestData(fetchImplementation, listEndpoint, accessToken, {}, false),
|
|
113
|
+
requestData(
|
|
114
|
+
fetchImplementation,
|
|
115
|
+
organizationsEndpoint,
|
|
116
|
+
accessToken,
|
|
117
|
+
{ application_name: applicationName },
|
|
118
|
+
false,
|
|
119
|
+
),
|
|
120
|
+
])
|
|
121
|
+
return parseLoadedContext(applicationsData, organizationsData, applicationName, serviceBaseUrl)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
async load(applicationName) {
|
|
126
|
+
const normalizedApplicationName = requiredText(applicationName, 'applicationName', 255)
|
|
127
|
+
const accessToken = await resolveAccessToken(options.getAccessToken)
|
|
128
|
+
return (await loadContext(normalizedApplicationName, accessToken)).projection
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async loadCurrentEmployeeInfo() {
|
|
132
|
+
const accessToken = await resolveAccessToken(options.getAccessToken)
|
|
133
|
+
const data = await requestData(
|
|
134
|
+
fetchImplementation,
|
|
135
|
+
currentEmployeeInfoEndpoint,
|
|
136
|
+
accessToken,
|
|
137
|
+
{},
|
|
138
|
+
false,
|
|
139
|
+
)
|
|
140
|
+
return parseCurrentEmployeeInfo(data)
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async switchOrganization(applicationName, organizationId) {
|
|
144
|
+
const normalizedApplicationName = requiredText(applicationName, 'applicationName', 255)
|
|
145
|
+
const normalizedOrganizationId = positiveIdentifier(organizationId)
|
|
146
|
+
if (!normalizedOrganizationId) {
|
|
147
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
148
|
+
'ORGANIZATION_UNAVAILABLE',
|
|
149
|
+
'Organization is unavailable',
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const accessToken = await resolveAccessToken(options.getAccessToken)
|
|
154
|
+
const current = await loadContext(normalizedApplicationName, accessToken)
|
|
155
|
+
if (
|
|
156
|
+
!current.projection.organizations.some(
|
|
157
|
+
(organization) => organization.id === normalizedOrganizationId,
|
|
158
|
+
)
|
|
159
|
+
) {
|
|
160
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
161
|
+
'ORGANIZATION_UNAVAILABLE',
|
|
162
|
+
'Organization is unavailable',
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const body = switchRequestBody(current, normalizedOrganizationId)
|
|
167
|
+
const switchedData = await requestData(
|
|
168
|
+
fetchImplementation,
|
|
169
|
+
switchEndpoint,
|
|
170
|
+
accessToken,
|
|
171
|
+
body,
|
|
172
|
+
true,
|
|
173
|
+
)
|
|
174
|
+
const nextAccessToken = accessTokenFromResponse(switchedData)
|
|
175
|
+
const projection = (await loadContext(normalizedApplicationName, nextAccessToken)).projection
|
|
176
|
+
if (projection.currentOrganizationId !== normalizedOrganizationId) {
|
|
177
|
+
throw invalidResponse()
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await options.setAccessToken(nextAccessToken, projection)
|
|
181
|
+
} catch {
|
|
182
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
183
|
+
'TOKEN_PERSIST_FAILED',
|
|
184
|
+
'Account context token could not be saved',
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
return projection
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async launchAccountSettings(applicationName) {
|
|
191
|
+
const accessToken = await resolveAccessToken(options.getAccessToken)
|
|
192
|
+
const targetApplicationName = requiredText(applicationName, 'applicationName', 255)
|
|
193
|
+
const entryUrl = resolveUniplatBaseApplicationEntryUrl(PERSONAL_CENTER_ENTRY_URL)
|
|
194
|
+
try {
|
|
195
|
+
const data = await requestData(
|
|
196
|
+
fetchImplementation,
|
|
197
|
+
switchEndpoint,
|
|
198
|
+
accessToken,
|
|
199
|
+
{
|
|
200
|
+
application_name: targetApplicationName,
|
|
201
|
+
response_type: 'ticket',
|
|
202
|
+
entry_url: entryUrl,
|
|
203
|
+
},
|
|
204
|
+
false,
|
|
205
|
+
)
|
|
206
|
+
return createApplicationTicketLaunchResult(entryUrl, data.ticket, data.expires_in)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof UniplatBaseAccountContextTransportError) throw error
|
|
209
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
210
|
+
'REQUEST_FAILED',
|
|
211
|
+
'Account settings request failed',
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function switchRequestBody(
|
|
219
|
+
context: LoadedContext,
|
|
220
|
+
organizationId: string,
|
|
221
|
+
): Record<string, unknown> {
|
|
222
|
+
const { applicationName, applicationScope } = context.projection
|
|
223
|
+
if (applicationScope === 'open') {
|
|
224
|
+
const target = context.targets.find(
|
|
225
|
+
(candidate) =>
|
|
226
|
+
candidate.applicationName === applicationName && candidate.applicationScope === 'open',
|
|
227
|
+
)
|
|
228
|
+
if (!target) throw invalidResponse()
|
|
229
|
+
return {
|
|
230
|
+
application_name: applicationName,
|
|
231
|
+
xid: target.xid,
|
|
232
|
+
organization_oid: organizationId,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const target = context.targets.find(
|
|
237
|
+
(candidate) =>
|
|
238
|
+
candidate.applicationName === applicationName &&
|
|
239
|
+
candidate.applicationScope === 'organization' &&
|
|
240
|
+
candidate.oid === organizationId,
|
|
241
|
+
)
|
|
242
|
+
if (!target) {
|
|
243
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
244
|
+
'ORGANIZATION_SWITCH_UNSUPPORTED',
|
|
245
|
+
'Organization switch is unavailable for this application',
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
return { application_name: applicationName, xid: target.xid }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function resolveAccessToken(
|
|
252
|
+
getAccessToken: UniplatBaseAccountContextTransportOptions['getAccessToken'],
|
|
253
|
+
): Promise<string> {
|
|
254
|
+
let accessToken: string
|
|
255
|
+
try {
|
|
256
|
+
accessToken = await getAccessToken()
|
|
257
|
+
} catch {
|
|
258
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
259
|
+
'AUTH_REQUIRED',
|
|
260
|
+
'Account context authentication is required',
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
const normalized = typeof accessToken === 'string' ? accessToken.trim() : ''
|
|
264
|
+
if (!normalized) {
|
|
265
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
266
|
+
'AUTH_REQUIRED',
|
|
267
|
+
'Account context authentication is required',
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
return normalized
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function requestData(
|
|
274
|
+
fetchImplementation: typeof fetch | undefined,
|
|
275
|
+
endpoint: string,
|
|
276
|
+
accessToken: string,
|
|
277
|
+
body: Record<string, unknown>,
|
|
278
|
+
containsToken: boolean,
|
|
279
|
+
): Promise<Record<string, unknown>> {
|
|
280
|
+
if (!fetchImplementation) {
|
|
281
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
282
|
+
'REQUEST_FAILED',
|
|
283
|
+
'Account context request failed',
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
let response: Response
|
|
287
|
+
let payload: unknown
|
|
288
|
+
try {
|
|
289
|
+
response = await fetchImplementation(endpoint, {
|
|
290
|
+
method: 'POST',
|
|
291
|
+
credentials: 'include',
|
|
292
|
+
headers: {
|
|
293
|
+
accept: 'application/json',
|
|
294
|
+
authorization: `Bearer ${accessToken}`,
|
|
295
|
+
'content-type': 'application/json',
|
|
296
|
+
},
|
|
297
|
+
body: JSON.stringify(body),
|
|
298
|
+
})
|
|
299
|
+
payload = await response.json()
|
|
300
|
+
} catch {
|
|
301
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
302
|
+
'REQUEST_FAILED',
|
|
303
|
+
'Account context request failed',
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
const envelope = record(payload)
|
|
307
|
+
if (!response.ok || integer(envelope.rescode) !== 0) {
|
|
308
|
+
throw new UniplatBaseAccountContextTransportError(
|
|
309
|
+
'REQUEST_FAILED',
|
|
310
|
+
'Account context request failed',
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
const data = record(envelope.data)
|
|
314
|
+
if (Object.keys(data).length === 0 || (containsToken && !text(data.token).trim())) {
|
|
315
|
+
throw invalidResponse()
|
|
316
|
+
}
|
|
317
|
+
return data
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseLoadedContext(
|
|
321
|
+
applicationsData: Record<string, unknown>,
|
|
322
|
+
organizationsData: Record<string, unknown>,
|
|
323
|
+
applicationName: string,
|
|
324
|
+
serviceBaseUrl: string,
|
|
325
|
+
): LoadedContext {
|
|
326
|
+
try {
|
|
327
|
+
const projection = parseProjection(
|
|
328
|
+
applicationsData,
|
|
329
|
+
organizationsData,
|
|
330
|
+
applicationName,
|
|
331
|
+
serviceBaseUrl,
|
|
332
|
+
)
|
|
333
|
+
if (!Array.isArray(applicationsData.available_applications)) throw invalidResponse()
|
|
334
|
+
const targets = applicationsData.available_applications.map(parseApplicationTarget)
|
|
335
|
+
if (targets.length === 0) throw invalidResponse()
|
|
336
|
+
return { projection, targets: Object.freeze(targets) }
|
|
337
|
+
} catch (error) {
|
|
338
|
+
if (error instanceof UniplatBaseAccountContextTransportError) throw error
|
|
339
|
+
throw invalidResponse()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseProjection(
|
|
344
|
+
applicationsData: Record<string, unknown>,
|
|
345
|
+
organizationsData: Record<string, unknown>,
|
|
346
|
+
expectedApplicationName: string,
|
|
347
|
+
serviceBaseUrl: string,
|
|
348
|
+
): UniplatBaseAccountContextProjection {
|
|
349
|
+
try {
|
|
350
|
+
const applicationName = requiredText(
|
|
351
|
+
organizationsData.application_name,
|
|
352
|
+
'application_name',
|
|
353
|
+
255,
|
|
354
|
+
)
|
|
355
|
+
const applicationScope = parseApplicationScope(organizationsData.application_scope)
|
|
356
|
+
if (applicationName !== expectedApplicationName) throw invalidResponse()
|
|
357
|
+
const currentIdentity = parseCurrentIdentity(applicationsData.current_identity)
|
|
358
|
+
if (
|
|
359
|
+
currentIdentity.applicationName !== applicationName ||
|
|
360
|
+
currentIdentity.applicationScope !== applicationScope
|
|
361
|
+
) {
|
|
362
|
+
throw invalidResponse()
|
|
363
|
+
}
|
|
364
|
+
if (
|
|
365
|
+
!Array.isArray(organizationsData.organizations) ||
|
|
366
|
+
!Array.isArray(applicationsData.available_applications)
|
|
367
|
+
) {
|
|
368
|
+
throw invalidResponse()
|
|
369
|
+
}
|
|
370
|
+
applicationsData.available_applications.forEach(parseApplicationTarget)
|
|
371
|
+
const organizations = Object.freeze(
|
|
372
|
+
organizationsData.organizations.map((organization) =>
|
|
373
|
+
parseOrganization(organization, serviceBaseUrl),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
const organizationIds = new Set(organizations.map((organization) => organization.id))
|
|
377
|
+
if (organizationIds.size !== organizations.length) throw invalidResponse()
|
|
378
|
+
const currentOrganizationId = currentIdentity.oid === '0' ? null : currentIdentity.oid
|
|
379
|
+
const currentOrganization = currentOrganizationId
|
|
380
|
+
? (organizations.find((organization) => organization.id === currentOrganizationId) ?? null)
|
|
381
|
+
: null
|
|
382
|
+
if (currentOrganizationId && !currentOrganization) throw invalidResponse()
|
|
383
|
+
const user = parseUser(organizationsData, currentOrganization)
|
|
384
|
+
return Object.freeze({
|
|
385
|
+
applicationName,
|
|
386
|
+
applicationScope,
|
|
387
|
+
user,
|
|
388
|
+
currentIdentity,
|
|
389
|
+
currentOrganization,
|
|
390
|
+
currentOrganizationId,
|
|
391
|
+
organizations,
|
|
392
|
+
})
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (error instanceof UniplatBaseAccountContextTransportError) throw error
|
|
395
|
+
throw invalidResponse()
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function parseUser(
|
|
400
|
+
data: Record<string, unknown>,
|
|
401
|
+
currentOrganization: AccountContextOrganization | null,
|
|
402
|
+
): AccountContextUser {
|
|
403
|
+
const displayName =
|
|
404
|
+
text(data.display_name).trim().slice(0, 80) ||
|
|
405
|
+
currentOrganization?.employeeDisplayName ||
|
|
406
|
+
'用户'
|
|
407
|
+
const maskedMobile = text(data.mobile_masked).trim()
|
|
408
|
+
const result: AccountContextUser = { displayName }
|
|
409
|
+
if (maskedMobile && !/^1[3-9]\d{9}$/.test(maskedMobile)) result.maskedMobile = maskedMobile
|
|
410
|
+
try {
|
|
411
|
+
const avatarUrl = normalizeSafeImageUrl(data.avatar_url, 'account-context avatar URL')
|
|
412
|
+
if (avatarUrl) result.avatarUrl = avatarUrl
|
|
413
|
+
} catch {
|
|
414
|
+
// User avatars are cosmetic. A legacy file locator or a malformed URL
|
|
415
|
+
// must not prevent account and organization context from loading.
|
|
416
|
+
}
|
|
417
|
+
return Object.freeze(result)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function parseCurrentIdentity(value: unknown): UniplatBaseAccountContextIdentity {
|
|
421
|
+
const identity = record(value)
|
|
422
|
+
const applicationScope = parseApplicationScope(identity.application_scope)
|
|
423
|
+
const xid = nonNegativeIdentifier(identity.xid)
|
|
424
|
+
const oid = nonNegativeIdentifier(identity.oid)
|
|
425
|
+
const eid = nonNegativeIdentifier(identity.eid)
|
|
426
|
+
const mid = nonNegativeIdentifier(identity.mid)
|
|
427
|
+
if (
|
|
428
|
+
xid === '' ||
|
|
429
|
+
(applicationScope === 'organization' && xid === '0') ||
|
|
430
|
+
oid === '' ||
|
|
431
|
+
eid === '' ||
|
|
432
|
+
mid === ''
|
|
433
|
+
) {
|
|
434
|
+
throw invalidResponse()
|
|
435
|
+
}
|
|
436
|
+
return Object.freeze({
|
|
437
|
+
xid,
|
|
438
|
+
oid,
|
|
439
|
+
eid,
|
|
440
|
+
mid,
|
|
441
|
+
applicationName: requiredText(identity.application_name, 'application_name', 255),
|
|
442
|
+
applicationScope,
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function parseApplicationTarget(value: unknown): ApplicationTarget {
|
|
447
|
+
const target = record(value)
|
|
448
|
+
const applicationScope = parseApplicationScope(target.application_scope)
|
|
449
|
+
const xid = nonNegativeIdentifier(target.xid)
|
|
450
|
+
const oid = nonNegativeIdentifier(target.oid)
|
|
451
|
+
if (xid === '' || (applicationScope === 'organization' && xid === '0') || oid === '') {
|
|
452
|
+
throw invalidResponse()
|
|
453
|
+
}
|
|
454
|
+
return Object.freeze({
|
|
455
|
+
xid,
|
|
456
|
+
oid,
|
|
457
|
+
applicationName: requiredText(target.application_name, 'application_name', 255),
|
|
458
|
+
applicationScope,
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function parseOrganization(value: unknown, serviceBaseUrl: string): AccountContextOrganization {
|
|
463
|
+
const organization = record(value)
|
|
464
|
+
const id = positiveIdentifier(organization.oid)
|
|
465
|
+
const employeeId = positiveIdentifier(organization.eid)
|
|
466
|
+
if (!id || !employeeId) throw invalidResponse()
|
|
467
|
+
const type = text(organization.organization_type).trim()
|
|
468
|
+
if (type !== 'enterprise' && type !== 'team') throw invalidResponse()
|
|
469
|
+
const name = text(organization.team_name).trim() || `组织 ${id}`
|
|
470
|
+
const companyName = text(organization.customer_name).trim()
|
|
471
|
+
const employeeDisplayName = text(organization.employee_display_name).trim()
|
|
472
|
+
const employeeAvatarUrl = employeeDisplayName
|
|
473
|
+
? normalizeEmployeeAvatarUrl(organization.employee_avatar_url, employeeId, serviceBaseUrl)
|
|
474
|
+
: ''
|
|
475
|
+
const role = roleLabel(text(organization.role).trim())
|
|
476
|
+
const memberCount = nonNegativeInteger(organization.member_count)
|
|
477
|
+
const result: AccountContextOrganization = {
|
|
478
|
+
id,
|
|
479
|
+
name,
|
|
480
|
+
type,
|
|
481
|
+
role,
|
|
482
|
+
memberCount,
|
|
483
|
+
}
|
|
484
|
+
if (employeeDisplayName) result.employeeDisplayName = employeeDisplayName
|
|
485
|
+
if (employeeAvatarUrl) result.employeeAvatarUrl = employeeAvatarUrl
|
|
486
|
+
if (companyName) result.companyName = companyName
|
|
487
|
+
if (type === 'enterprise') {
|
|
488
|
+
result.verificationStatus = parseVerificationStatus(organization.verification_status)
|
|
489
|
+
}
|
|
490
|
+
return Object.freeze(result)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function parseCurrentEmployeeInfo(value: Record<string, unknown>): AccountContextEmployeeInfo {
|
|
494
|
+
const primaryDepartment = record(value.primary_department)
|
|
495
|
+
const departmentName = text(primaryDepartment.name).trim()
|
|
496
|
+
return Object.freeze({ departmentName: departmentName || null })
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function normalizeEmployeeAvatarUrl(
|
|
500
|
+
value: unknown,
|
|
501
|
+
employeeId: string,
|
|
502
|
+
serviceBaseUrl: string,
|
|
503
|
+
): string {
|
|
504
|
+
const rawValue = text(value).trim()
|
|
505
|
+
if (!rawValue) return ''
|
|
506
|
+
const managedPath = rawValue.match(
|
|
507
|
+
/^(\d+)\/\.root\/\.avatars\/(\d+)\/avatar-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.(?:jpg|png|webp)$/,
|
|
508
|
+
)
|
|
509
|
+
if (!managedPath) {
|
|
510
|
+
return normalizeSafeImageUrl(rawValue, 'account-context employee avatar URL')
|
|
511
|
+
}
|
|
512
|
+
const xid = positiveIdentifier(managedPath[1])
|
|
513
|
+
const pathEmployeeId = positiveIdentifier(managedPath[2])
|
|
514
|
+
const fileId = managedPath[3]
|
|
515
|
+
if (!xid || pathEmployeeId !== employeeId || !fileId) throw invalidResponse()
|
|
516
|
+
const avatarUrl = new URL(`${serviceBaseUrl}/anonymous/system.employee_profile/get_avatar`)
|
|
517
|
+
avatarUrl.searchParams.set('xid', xid)
|
|
518
|
+
avatarUrl.searchParams.set('eid', employeeId)
|
|
519
|
+
avatarUrl.searchParams.set('file_id', fileId)
|
|
520
|
+
return normalizeSafeImageUrl(avatarUrl.href, 'account-context employee avatar URL')
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function parseApplicationScope(value: unknown): AccountContextApplicationScope {
|
|
524
|
+
if (value === 'open' || value === 'organization') return value
|
|
525
|
+
throw invalidResponse()
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function parseVerificationStatus(
|
|
529
|
+
value: unknown,
|
|
530
|
+
): 'unverified' | 'pending' | 'rejected' | 'verified' | 'expired' {
|
|
531
|
+
if (['unverified', 'pending', 'rejected', 'verified', 'expired'].includes(String(value))) {
|
|
532
|
+
return value as 'unverified' | 'pending' | 'rejected' | 'verified' | 'expired'
|
|
533
|
+
}
|
|
534
|
+
throw invalidResponse()
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function roleLabel(value: string): string {
|
|
538
|
+
if (value === 'owner') return '所有者'
|
|
539
|
+
if (value === 'admin') return '管理员'
|
|
540
|
+
if (value === 'member') return '成员'
|
|
541
|
+
return ''
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function accessTokenFromResponse(data: Record<string, unknown>): string {
|
|
545
|
+
const accessToken = text(data.token).trim()
|
|
546
|
+
if (!accessToken) throw invalidResponse()
|
|
547
|
+
return accessToken
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function validateServiceBaseUrl(rawValue: string): string {
|
|
551
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
552
|
+
if (!value) throw new TypeError('UniplatBase serviceBaseUrl is required')
|
|
553
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
554
|
+
let url: URL
|
|
555
|
+
try {
|
|
556
|
+
url = new URL(value, documentBase)
|
|
557
|
+
} catch {
|
|
558
|
+
throw new TypeError('UniplatBase serviceBaseUrl is invalid')
|
|
559
|
+
}
|
|
560
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
561
|
+
throw new TypeError('UniplatBase serviceBaseUrl must use HTTP(S) without user credentials')
|
|
562
|
+
}
|
|
563
|
+
if (url.search || url.hash) {
|
|
564
|
+
throw new TypeError('UniplatBase serviceBaseUrl must not contain query or hash')
|
|
565
|
+
}
|
|
566
|
+
return url.href.replace(/\/+$/, '')
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function requiredText(value: unknown, label: string, maxLength: number): string {
|
|
570
|
+
const normalized = text(value).trim()
|
|
571
|
+
if (!normalized || normalized.length > maxLength) {
|
|
572
|
+
throw new TypeError(`${label} is invalid`)
|
|
573
|
+
}
|
|
574
|
+
return normalized
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function positiveIdentifier(value: unknown): string {
|
|
578
|
+
const normalized = nonNegativeIdentifier(value)
|
|
579
|
+
return normalized && normalized !== '0' ? normalized : ''
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function nonNegativeIdentifier(value: unknown): string {
|
|
583
|
+
if (typeof value === 'number') {
|
|
584
|
+
return Number.isSafeInteger(value) && value >= 0 ? String(value) : ''
|
|
585
|
+
}
|
|
586
|
+
if (typeof value !== 'string') return ''
|
|
587
|
+
const normalized = value.trim()
|
|
588
|
+
return /^\d+$/.test(normalized) ? normalized.replace(/^0+(?=\d)/, '') : ''
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function nonNegativeInteger(value: unknown): number {
|
|
592
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value
|
|
593
|
+
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
|
594
|
+
const parsed = Number(value)
|
|
595
|
+
if (Number.isSafeInteger(parsed)) return parsed
|
|
596
|
+
}
|
|
597
|
+
throw invalidResponse()
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function record(value: unknown): Record<string, unknown> {
|
|
601
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
602
|
+
? (value as Record<string, unknown>)
|
|
603
|
+
: {}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function text(value: unknown): string {
|
|
607
|
+
return typeof value === 'string'
|
|
608
|
+
? value
|
|
609
|
+
: typeof value === 'number' && Number.isFinite(value)
|
|
610
|
+
? String(value)
|
|
611
|
+
: ''
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function integer(value: unknown): number | null {
|
|
615
|
+
if (typeof value === 'number' && Number.isSafeInteger(value)) return value
|
|
616
|
+
if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) return Number(value)
|
|
617
|
+
return null
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function invalidResponse(): UniplatBaseAccountContextTransportError {
|
|
621
|
+
return new UniplatBaseAccountContextTransportError(
|
|
622
|
+
'INVALID_RESPONSE',
|
|
623
|
+
'Account context response is invalid',
|
|
624
|
+
)
|
|
625
|
+
}
|