@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,418 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApplicationEntry,
|
|
3
|
+
ApplicationSwitcherTransport,
|
|
4
|
+
OrganizationEntry,
|
|
5
|
+
OrganizationListResult,
|
|
6
|
+
} from './application-switcher-transport'
|
|
7
|
+
import { resolveUniplatBaseApplicationEntryUrl } from './uniplat-base-application-ticket'
|
|
8
|
+
import { createIdentityTicketLaunchResult } from './uniplat-base-identity-ticket'
|
|
9
|
+
|
|
10
|
+
export interface UniplatBaseApplicationSwitcherTransportOptions {
|
|
11
|
+
serviceBaseUrl: string
|
|
12
|
+
/** @deprecated Application Switcher ticket navigation does not replace the current Host JWT. */
|
|
13
|
+
setAccessToken?(accessToken: string): void | Promise<void>
|
|
14
|
+
fetch?: typeof fetch
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UniplatBaseIdentityTarget {
|
|
18
|
+
id: string
|
|
19
|
+
xid: string
|
|
20
|
+
applicationName: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UniplatBaseApplicationTarget extends UniplatBaseIdentityTarget {
|
|
24
|
+
organizationId: string
|
|
25
|
+
applicationScope: 'open' | 'organization'
|
|
26
|
+
launchTarget:
|
|
27
|
+
{ kind: 'local' } | { kind: 'external'; targetSystem: string; targetApplicationName: string }
|
|
28
|
+
entry: ApplicationEntry
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface UniplatBaseApplicationSnapshot {
|
|
32
|
+
currentOrganizationId: string
|
|
33
|
+
applications: readonly UniplatBaseApplicationTarget[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LIST_PATH = 'application.identity/list_available_applications'
|
|
37
|
+
const ORGANIZATIONS_PATH = 'application.identity/list_available_organizations'
|
|
38
|
+
const ISSUE_TICKET_PATH = 'application.identity/issue_ticket'
|
|
39
|
+
|
|
40
|
+
export function createUniplatBaseApplicationSwitcherTransport(
|
|
41
|
+
options: UniplatBaseApplicationSwitcherTransportOptions,
|
|
42
|
+
): ApplicationSwitcherTransport {
|
|
43
|
+
if (!options) throw new TypeError('UniplatBase transport options are required')
|
|
44
|
+
const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
|
|
45
|
+
const listEndpoint = `${serviceBaseUrl}/${LIST_PATH}`
|
|
46
|
+
const organizationsEndpoint = `${serviceBaseUrl}/${ORGANIZATIONS_PATH}`
|
|
47
|
+
const issueTicketEndpoint = `${serviceBaseUrl}/${ISSUE_TICKET_PATH}`
|
|
48
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch
|
|
49
|
+
let pendingApplicationAccessToken = ''
|
|
50
|
+
let pendingApplications: Promise<UniplatBaseApplicationSnapshot> | null = null
|
|
51
|
+
let pendingOrganizationAccessToken = ''
|
|
52
|
+
let pendingOrganizations: Promise<readonly OrganizationEntry[]> | null = null
|
|
53
|
+
let selectedOrganizationId = ''
|
|
54
|
+
let selectedCredentialMarker = ''
|
|
55
|
+
|
|
56
|
+
const loadApplications = (accessToken: string): Promise<UniplatBaseApplicationSnapshot> => {
|
|
57
|
+
const token = requireAccessToken(accessToken)
|
|
58
|
+
if (pendingApplications && pendingApplicationAccessToken === token) return pendingApplications
|
|
59
|
+
|
|
60
|
+
pendingApplicationAccessToken = token
|
|
61
|
+
const request = requestData(fetchImplementation, listEndpoint, token, {}).then(
|
|
62
|
+
parseApplicationsSnapshot,
|
|
63
|
+
)
|
|
64
|
+
pendingApplications = request
|
|
65
|
+
void request.then(clearPendingApplications, clearPendingApplications)
|
|
66
|
+
return request
|
|
67
|
+
|
|
68
|
+
function clearPendingApplications(): void {
|
|
69
|
+
if (pendingApplications === request) {
|
|
70
|
+
pendingApplications = null
|
|
71
|
+
pendingApplicationAccessToken = ''
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const loadOrganizations = (accessToken: string): Promise<readonly OrganizationEntry[]> => {
|
|
77
|
+
const token = requireAccessToken(accessToken)
|
|
78
|
+
if (pendingOrganizations && pendingOrganizationAccessToken === token)
|
|
79
|
+
return pendingOrganizations
|
|
80
|
+
|
|
81
|
+
pendingOrganizationAccessToken = token
|
|
82
|
+
const request = requestData(fetchImplementation, organizationsEndpoint, token, {}).then(
|
|
83
|
+
parseOrganizations,
|
|
84
|
+
)
|
|
85
|
+
pendingOrganizations = request
|
|
86
|
+
void request.then(clearPendingOrganizations, clearPendingOrganizations)
|
|
87
|
+
return request
|
|
88
|
+
|
|
89
|
+
function clearPendingOrganizations(): void {
|
|
90
|
+
if (pendingOrganizations === request) {
|
|
91
|
+
pendingOrganizations = null
|
|
92
|
+
pendingOrganizationAccessToken = ''
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const createApplicationTicket = async (
|
|
98
|
+
accessToken: string,
|
|
99
|
+
target: UniplatBaseApplicationTarget,
|
|
100
|
+
) => {
|
|
101
|
+
const token = requireAccessToken(accessToken)
|
|
102
|
+
const entryUrl = resolveUniplatBaseApplicationEntryUrl(target.entry.url)
|
|
103
|
+
const data = await requestData(fetchImplementation, issueTicketEndpoint, token, {})
|
|
104
|
+
return createIdentityTicketLaunchResult(
|
|
105
|
+
entryUrl,
|
|
106
|
+
data.ticket,
|
|
107
|
+
data.expires_in,
|
|
108
|
+
data.source_system,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const resolveSelectedOrganizationId = (
|
|
113
|
+
snapshot: UniplatBaseApplicationSnapshot,
|
|
114
|
+
accessToken: string,
|
|
115
|
+
): string => {
|
|
116
|
+
const credentialMarker = markCredentialRevision(accessToken)
|
|
117
|
+
if (credentialMarker !== selectedCredentialMarker) {
|
|
118
|
+
selectedCredentialMarker = credentialMarker
|
|
119
|
+
selectedOrganizationId =
|
|
120
|
+
snapshot.currentOrganizationId === '0' ? '' : snapshot.currentOrganizationId
|
|
121
|
+
return selectedOrganizationId
|
|
122
|
+
}
|
|
123
|
+
if (selectedOrganizationId) return selectedOrganizationId
|
|
124
|
+
// oid=0 means the open-application identity is not bound to an organization.
|
|
125
|
+
// Do not silently select the first available organization: that would expose
|
|
126
|
+
// organization-scoped applications before the user chooses an organization.
|
|
127
|
+
selectedOrganizationId =
|
|
128
|
+
snapshot.currentOrganizationId === '0' ? '' : snapshot.currentOrganizationId
|
|
129
|
+
return selectedOrganizationId
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
async listApplications(accessToken) {
|
|
134
|
+
const snapshot = await loadApplications(accessToken)
|
|
135
|
+
const organizationId = resolveSelectedOrganizationId(snapshot, accessToken)
|
|
136
|
+
return snapshot.applications
|
|
137
|
+
.filter(
|
|
138
|
+
(target) =>
|
|
139
|
+
target.organizationId === organizationId || target.applicationScope === 'open',
|
|
140
|
+
)
|
|
141
|
+
.map((target) => ({ ...target.entry }))
|
|
142
|
+
},
|
|
143
|
+
async listOrganizations(accessToken): Promise<OrganizationListResult> {
|
|
144
|
+
const [snapshot, organizations] = await Promise.all([
|
|
145
|
+
loadApplications(accessToken),
|
|
146
|
+
loadOrganizations(accessToken),
|
|
147
|
+
])
|
|
148
|
+
const currentOrganizationId = resolveSelectedOrganizationId(snapshot, accessToken)
|
|
149
|
+
return {
|
|
150
|
+
organizations: organizations.map((organization) => ({ ...organization })),
|
|
151
|
+
currentOrganizationId,
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
async switchOrganization(accessToken, organizationId) {
|
|
155
|
+
const token = requireAccessToken(accessToken)
|
|
156
|
+
const organizations = await loadOrganizations(accessToken)
|
|
157
|
+
const normalizedOrganizationId = organizationId.trim()
|
|
158
|
+
const available = organizations.some((candidate) => candidate.id === normalizedOrganizationId)
|
|
159
|
+
if (!available) throw new Error('Organization is unavailable')
|
|
160
|
+
selectedCredentialMarker = markCredentialRevision(token)
|
|
161
|
+
selectedOrganizationId = normalizedOrganizationId
|
|
162
|
+
},
|
|
163
|
+
async switchApplication(accessToken, applicationId) {
|
|
164
|
+
const snapshot = await loadApplications(accessToken)
|
|
165
|
+
const organizationId = resolveSelectedOrganizationId(snapshot, accessToken)
|
|
166
|
+
const selectedTarget = snapshot.applications.find(
|
|
167
|
+
(candidate) =>
|
|
168
|
+
candidate.id === applicationId.trim() &&
|
|
169
|
+
(candidate.organizationId === organizationId || candidate.applicationScope === 'open'),
|
|
170
|
+
)
|
|
171
|
+
if (!selectedTarget) throw new Error('Application is unavailable')
|
|
172
|
+
return createApplicationTicket(accessToken, selectedTarget)
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function markCredentialRevision(accessToken: string): string {
|
|
178
|
+
let first = 0x811c9dc5
|
|
179
|
+
let second = 0x9e3779b9
|
|
180
|
+
for (let index = 0; index < accessToken.length; index += 1) {
|
|
181
|
+
const code = accessToken.charCodeAt(index)
|
|
182
|
+
first = Math.imul(first ^ code, 0x01000193)
|
|
183
|
+
second = Math.imul(second ^ (code + index), 0x85ebca6b)
|
|
184
|
+
}
|
|
185
|
+
return `${accessToken.length}:${(first >>> 0).toString(16)}:${(second >>> 0).toString(16)}`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function requestData(
|
|
189
|
+
fetchImplementation: typeof fetch | undefined,
|
|
190
|
+
endpoint: string,
|
|
191
|
+
accessToken: string,
|
|
192
|
+
body: Record<string, unknown>,
|
|
193
|
+
): Promise<Record<string, unknown>> {
|
|
194
|
+
if (!fetchImplementation) throw new Error('Fetch is unavailable')
|
|
195
|
+
const response = await fetchImplementation(endpoint, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
credentials: 'include',
|
|
198
|
+
headers: {
|
|
199
|
+
accept: 'application/json',
|
|
200
|
+
authorization: `Bearer ${accessToken}`,
|
|
201
|
+
'content-type': 'application/json',
|
|
202
|
+
},
|
|
203
|
+
body: JSON.stringify(body),
|
|
204
|
+
})
|
|
205
|
+
if (!response.ok) throw new Error('UniplatBase application request failed')
|
|
206
|
+
|
|
207
|
+
const envelope = asRecord(await response.json())
|
|
208
|
+
if (number(envelope.rescode) !== 0) throw new Error('UniplatBase application request failed')
|
|
209
|
+
const data = asRecord(envelope.data)
|
|
210
|
+
if (Object.keys(data).length === 0) throw new Error('UniplatBase application response is invalid')
|
|
211
|
+
return data
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseApplicationsSnapshot(
|
|
215
|
+
applicationsData: Record<string, unknown>,
|
|
216
|
+
): UniplatBaseApplicationSnapshot {
|
|
217
|
+
const currentIdentity = asRecord(applicationsData.current_identity)
|
|
218
|
+
const currentOrganizationId = identifier(currentIdentity.oid)
|
|
219
|
+
const currentApplicationName = text(currentIdentity.application_name).trim()
|
|
220
|
+
if (
|
|
221
|
+
!applicationIdentifier(currentIdentity.xid) ||
|
|
222
|
+
currentOrganizationId === '' ||
|
|
223
|
+
!currentApplicationName
|
|
224
|
+
) {
|
|
225
|
+
throw new Error('UniplatBase current identity is invalid')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const availableApplications = applicationsData.available_applications
|
|
229
|
+
if (!Array.isArray(availableApplications)) {
|
|
230
|
+
throw new Error('UniplatBase application response is invalid')
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const applicationEntryKeys = new Set<string>()
|
|
234
|
+
const parsedApplications: Array<{
|
|
235
|
+
target: UniplatBaseApplicationTarget
|
|
236
|
+
baseId: string
|
|
237
|
+
entryName: string
|
|
238
|
+
}> = []
|
|
239
|
+
for (const candidate of availableApplications) {
|
|
240
|
+
const record = asRecord(candidate)
|
|
241
|
+
const xid = applicationIdentifier(record.xid)
|
|
242
|
+
const applicationName = text(record.application_name).trim()
|
|
243
|
+
const organizationId = identifier(record.oid)
|
|
244
|
+
const applicationScope = applicationScopeValue(record.application_scope, organizationId)
|
|
245
|
+
const name = text(record.application_title).trim()
|
|
246
|
+
const url = text(record.entry_url).trim()
|
|
247
|
+
const entryName = text(record.entry_name).trim()
|
|
248
|
+
if (!xid || !applicationName || organizationId === '' || !name || !url) {
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// An application identity can expose multiple entries. In particular,
|
|
253
|
+
// OfficialWebsite has the shared open identity (xid=0) for its main,
|
|
254
|
+
// personal and organizational entries. Deduplicating only by identity
|
|
255
|
+
// would silently collapse those entries into one tile.
|
|
256
|
+
const identityKey = `${applicationName}:${xid}`
|
|
257
|
+
const entryKey = JSON.stringify([applicationName, xid, entryName, url])
|
|
258
|
+
if (applicationEntryKeys.has(entryKey)) continue
|
|
259
|
+
const entry: ApplicationEntry = { id: '', name, url }
|
|
260
|
+
const launchTarget = parseApplicationLaunchTarget(record)
|
|
261
|
+
const iconUrl = text(record.icon_url).trim()
|
|
262
|
+
const description = text(record.description).trim()
|
|
263
|
+
if (iconUrl) entry.iconUrl = iconUrl
|
|
264
|
+
if (description) entry.description = description
|
|
265
|
+
if (record.open_mode === 'blank') entry.openMode = 'blank'
|
|
266
|
+
parsedApplications.push({
|
|
267
|
+
target: {
|
|
268
|
+
id: '',
|
|
269
|
+
xid,
|
|
270
|
+
applicationName,
|
|
271
|
+
organizationId,
|
|
272
|
+
applicationScope,
|
|
273
|
+
launchTarget,
|
|
274
|
+
entry,
|
|
275
|
+
},
|
|
276
|
+
baseId: xid === '0' ? identityKey : xid,
|
|
277
|
+
entryName,
|
|
278
|
+
})
|
|
279
|
+
applicationEntryKeys.add(entryKey)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const baseIdCounts = new Map<string, number>()
|
|
283
|
+
for (const { baseId } of parsedApplications) {
|
|
284
|
+
baseIdCounts.set(baseId, (baseIdCounts.get(baseId) ?? 0) + 1)
|
|
285
|
+
}
|
|
286
|
+
const usedApplicationIds = new Set<string>()
|
|
287
|
+
const applications: UniplatBaseApplicationTarget[] = []
|
|
288
|
+
for (const [index, parsed] of parsedApplications.entries()) {
|
|
289
|
+
let id = parsed.baseId
|
|
290
|
+
if ((baseIdCounts.get(id) ?? 0) > 1) {
|
|
291
|
+
const normalizedEntryName = parsed.entryName
|
|
292
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
293
|
+
.replace(/^-+|-+$/g, '')
|
|
294
|
+
.slice(0, 48)
|
|
295
|
+
const baseEntryId = `${id}:${normalizedEntryName || `entry-${index + 1}`}`
|
|
296
|
+
id = baseEntryId
|
|
297
|
+
let collision = 2
|
|
298
|
+
while (usedApplicationIds.has(id)) {
|
|
299
|
+
id = `${baseEntryId}-${collision}`
|
|
300
|
+
collision += 1
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
parsed.target.id = id
|
|
304
|
+
parsed.target.entry.id = id
|
|
305
|
+
usedApplicationIds.add(id)
|
|
306
|
+
applications.push(parsed.target)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { currentOrganizationId, applications }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parseApplicationLaunchTarget(
|
|
313
|
+
application: Record<string, unknown>,
|
|
314
|
+
): UniplatBaseApplicationTarget['launchTarget'] {
|
|
315
|
+
if (!Object.prototype.hasOwnProperty.call(application, 'launch_target')) {
|
|
316
|
+
return { kind: 'local' }
|
|
317
|
+
}
|
|
318
|
+
const launchTarget = asRecord(application.launch_target)
|
|
319
|
+
const keys = Object.keys(launchTarget).sort()
|
|
320
|
+
const expectedKeys = ['application_name', 'target_system', 'type']
|
|
321
|
+
const targetSystem = text(launchTarget.target_system).trim()
|
|
322
|
+
const targetApplicationName = text(launchTarget.application_name).trim()
|
|
323
|
+
if (
|
|
324
|
+
keys.length !== expectedKeys.length ||
|
|
325
|
+
keys.some((key, index) => key !== expectedKeys[index]) ||
|
|
326
|
+
launchTarget.type !== 'external' ||
|
|
327
|
+
!targetSystem ||
|
|
328
|
+
targetSystem.length > 128 ||
|
|
329
|
+
!targetApplicationName ||
|
|
330
|
+
targetApplicationName.length > 255
|
|
331
|
+
) {
|
|
332
|
+
throw new Error('UniplatBase external application target is invalid')
|
|
333
|
+
}
|
|
334
|
+
return { kind: 'external', targetSystem, targetApplicationName }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function parseOrganizations(
|
|
338
|
+
organizationsData: Record<string, unknown>,
|
|
339
|
+
): readonly OrganizationEntry[] {
|
|
340
|
+
if (!Array.isArray(organizationsData.organizations)) {
|
|
341
|
+
throw new Error('UniplatBase organization response is invalid')
|
|
342
|
+
}
|
|
343
|
+
const organizationIds = new Set<string>()
|
|
344
|
+
const organizations: OrganizationEntry[] = []
|
|
345
|
+
for (const candidate of organizationsData.organizations) {
|
|
346
|
+
const record = asRecord(candidate)
|
|
347
|
+
const id = identifier(record.oid)
|
|
348
|
+
if (id === '' || organizationIds.has(id)) {
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
const name = text(record.team_name).trim() || `组织 ${id}`
|
|
352
|
+
organizations.push({ id, name })
|
|
353
|
+
organizationIds.add(id)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return organizations
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function validateServiceBaseUrl(rawValue: string): string {
|
|
360
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
361
|
+
if (!value) throw new TypeError('UniplatBase serviceBaseUrl is required')
|
|
362
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
363
|
+
let url: URL
|
|
364
|
+
try {
|
|
365
|
+
url = new URL(value, documentBase)
|
|
366
|
+
} catch {
|
|
367
|
+
throw new TypeError('UniplatBase serviceBaseUrl is invalid')
|
|
368
|
+
}
|
|
369
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
370
|
+
throw new TypeError('UniplatBase serviceBaseUrl must use HTTP(S) without user credentials')
|
|
371
|
+
}
|
|
372
|
+
if (url.search || url.hash) {
|
|
373
|
+
throw new TypeError('UniplatBase serviceBaseUrl must not contain query or hash')
|
|
374
|
+
}
|
|
375
|
+
return url.href.replace(/\/+$/, '')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function requireAccessToken(accessToken: string): string {
|
|
379
|
+
const token = accessToken.trim()
|
|
380
|
+
if (!token) throw new Error('Access token is required')
|
|
381
|
+
return token
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function applicationIdentifier(value: unknown): string {
|
|
385
|
+
const normalized = identifier(value)
|
|
386
|
+
// xid=0 is the valid global instance identity for an open application.
|
|
387
|
+
if (!normalized || !/^\d+$/.test(normalized)) return ''
|
|
388
|
+
return normalized
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function identifier(value: unknown): string {
|
|
392
|
+
const normalized =
|
|
393
|
+
typeof value === 'string'
|
|
394
|
+
? value.trim()
|
|
395
|
+
: typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
396
|
+
? String(value)
|
|
397
|
+
: ''
|
|
398
|
+
return /^\d+$/.test(normalized) ? normalized : ''
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function applicationScopeValue(value: unknown, organizationId: string): 'open' | 'organization' {
|
|
402
|
+
if (value === 'open' || value === 'organization') return value
|
|
403
|
+
return organizationId === '0' ? 'open' : 'organization'
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
407
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
408
|
+
? (value as Record<string, unknown>)
|
|
409
|
+
: {}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function text(value: unknown): string {
|
|
413
|
+
return typeof value === 'string' ? value : ''
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function number(value: unknown): number {
|
|
417
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
|
|
418
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { ApplicationLaunchResult } from './application-switcher-transport'
|
|
2
|
+
|
|
3
|
+
export const UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY = 'uniplat_application_ticket'
|
|
4
|
+
export const UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY = 'source_system'
|
|
5
|
+
export const UNIPLAT_APPLICATION_TICKET_APPLICATION_NAME_FRAGMENT_KEY = 'application_name'
|
|
6
|
+
|
|
7
|
+
export interface UniplatBaseApplicationTicketExchangeOptions {
|
|
8
|
+
serviceBaseUrl: string
|
|
9
|
+
applicationName: string
|
|
10
|
+
setAccessToken(accessToken: string): void | Promise<void>
|
|
11
|
+
fetch?: typeof fetch
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const EXCHANGE_PATH = 'anonymous/application.identity/exchange_application_ticket'
|
|
15
|
+
const MIN_TICKET_LENGTH = 22
|
|
16
|
+
const MAX_TICKET_LENGTH = 512
|
|
17
|
+
const MAX_TICKET_LIFETIME_SECONDS = 60
|
|
18
|
+
|
|
19
|
+
export async function exchangeUniplatBaseApplicationTicket(
|
|
20
|
+
options: UniplatBaseApplicationTicketExchangeOptions,
|
|
21
|
+
): Promise<boolean> {
|
|
22
|
+
if (!options || typeof options.setAccessToken !== 'function') {
|
|
23
|
+
throw new TypeError('Application ticket exchange requires setAccessToken()')
|
|
24
|
+
}
|
|
25
|
+
const serviceBaseUrl = validateServiceBaseUrl(options.serviceBaseUrl)
|
|
26
|
+
const applicationName = requireText(options.applicationName, 'Application name')
|
|
27
|
+
if (typeof window === 'undefined') return false
|
|
28
|
+
|
|
29
|
+
const fragment = readTicketFragment(window.location.hash)
|
|
30
|
+
let ticket = fragment.ticket
|
|
31
|
+
if (!ticket) return false
|
|
32
|
+
clearTicketFragment()
|
|
33
|
+
ticket = requireTicket(ticket)
|
|
34
|
+
const sourceSystem = fragment.sourceSystem
|
|
35
|
+
|
|
36
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch
|
|
37
|
+
const endpoint = `${serviceBaseUrl}/${EXCHANGE_PATH}`
|
|
38
|
+
let accessToken = ''
|
|
39
|
+
let publicErrorMessage = ''
|
|
40
|
+
try {
|
|
41
|
+
if (!fetchImplementation) throw new Error('Fetch is unavailable')
|
|
42
|
+
const body: Record<string, string> = { ticket }
|
|
43
|
+
if (fragment.hasSourceSystem) {
|
|
44
|
+
body.source_system = sourceSystem
|
|
45
|
+
body.application_name = applicationName
|
|
46
|
+
}
|
|
47
|
+
const response = await fetchImplementation(endpoint, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
credentials: 'omit',
|
|
50
|
+
headers: {
|
|
51
|
+
accept: 'application/json',
|
|
52
|
+
'content-type': 'application/json',
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
})
|
|
56
|
+
if (!response.ok) throw new Error('Application ticket exchange failed')
|
|
57
|
+
const envelope = asRecord(await response.json())
|
|
58
|
+
if (number(envelope.rescode) !== 0) {
|
|
59
|
+
publicErrorMessage = federatedPublicErrorMessage(envelope)
|
|
60
|
+
throw new Error('Application ticket exchange failed')
|
|
61
|
+
}
|
|
62
|
+
const data = asRecord(envelope.data)
|
|
63
|
+
accessToken = text(data.token).trim()
|
|
64
|
+
const responseApplicationName = text(data.application_name).trim()
|
|
65
|
+
if (!accessToken || responseApplicationName !== applicationName) {
|
|
66
|
+
throw new Error('Application ticket exchange failed')
|
|
67
|
+
}
|
|
68
|
+
await options.setAccessToken(accessToken)
|
|
69
|
+
return true
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error(publicErrorMessage || 'Application ticket exchange failed')
|
|
72
|
+
} finally {
|
|
73
|
+
accessToken = ''
|
|
74
|
+
ticket = ''
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function createApplicationTicketLaunchResult(
|
|
79
|
+
entryUrl: string,
|
|
80
|
+
rawTicket: unknown,
|
|
81
|
+
rawExpiresIn: unknown,
|
|
82
|
+
): ApplicationLaunchResult {
|
|
83
|
+
const ticket = requireTicket(rawTicket)
|
|
84
|
+
validateTicketLifetime(rawExpiresIn)
|
|
85
|
+
|
|
86
|
+
const url = new URL(resolveUniplatBaseApplicationEntryUrl(entryUrl))
|
|
87
|
+
url.hash = new URLSearchParams([[UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY, ticket]]).toString()
|
|
88
|
+
return { url: url.href }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createExternalApplicationTicketLaunchResult(
|
|
92
|
+
rawRedirectUrl: unknown,
|
|
93
|
+
rawTicket: unknown,
|
|
94
|
+
rawExpiresIn: unknown,
|
|
95
|
+
targetApplicationName: string,
|
|
96
|
+
): ApplicationLaunchResult {
|
|
97
|
+
const ticket = requireTicket(rawTicket)
|
|
98
|
+
validateTicketLifetime(rawExpiresIn)
|
|
99
|
+
const expectedApplicationName = requireText(targetApplicationName, 'Target application name')
|
|
100
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
101
|
+
let url: URL
|
|
102
|
+
try {
|
|
103
|
+
url = new URL(text(rawRedirectUrl).trim(), documentBase)
|
|
104
|
+
} catch {
|
|
105
|
+
throw new Error('External application redirect URL is invalid')
|
|
106
|
+
}
|
|
107
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
108
|
+
throw new Error('External application redirect URL is invalid')
|
|
109
|
+
}
|
|
110
|
+
const params = new URLSearchParams(url.hash.slice(1))
|
|
111
|
+
const expectedKeys = [
|
|
112
|
+
UNIPLAT_APPLICATION_TICKET_APPLICATION_NAME_FRAGMENT_KEY,
|
|
113
|
+
UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY,
|
|
114
|
+
UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY,
|
|
115
|
+
]
|
|
116
|
+
const actualKeys = [...params.keys()].sort()
|
|
117
|
+
if (
|
|
118
|
+
actualKeys.length !== expectedKeys.length ||
|
|
119
|
+
actualKeys.some((key, index) => key !== expectedKeys[index]) ||
|
|
120
|
+
params.get(UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY) !== ticket ||
|
|
121
|
+
!params.get(UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY)?.trim() ||
|
|
122
|
+
params.get(UNIPLAT_APPLICATION_TICKET_APPLICATION_NAME_FRAGMENT_KEY) !== expectedApplicationName
|
|
123
|
+
) {
|
|
124
|
+
throw new Error('External application redirect URL is invalid')
|
|
125
|
+
}
|
|
126
|
+
return { url: url.href }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolves a launcher entry before a ticket is requested, so the URL sent to
|
|
131
|
+
* UniplatBase is exactly the URL that will subsequently receive the ticket.
|
|
132
|
+
*/
|
|
133
|
+
export function resolveUniplatBaseApplicationEntryUrl(entryUrl: string): string {
|
|
134
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
135
|
+
let url: URL
|
|
136
|
+
try {
|
|
137
|
+
url = new URL(entryUrl, documentBase)
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error('Application entry URL is invalid')
|
|
140
|
+
}
|
|
141
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) {
|
|
142
|
+
throw new Error('Application entry URL is invalid')
|
|
143
|
+
}
|
|
144
|
+
return url.href
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readTicketFragment(hash: string): {
|
|
148
|
+
ticket: string
|
|
149
|
+
sourceSystem: string
|
|
150
|
+
hasSourceSystem: boolean
|
|
151
|
+
} {
|
|
152
|
+
const fragment = hash.startsWith('#') ? hash.slice(1) : hash
|
|
153
|
+
const params = new URLSearchParams(fragment)
|
|
154
|
+
return {
|
|
155
|
+
ticket: params.get(UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY)?.trim() ?? '',
|
|
156
|
+
sourceSystem: params.get(UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY) ?? '',
|
|
157
|
+
hasSourceSystem: params.has(UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function federatedPublicErrorMessage(envelope: Record<string, unknown>): string {
|
|
162
|
+
const code = text(envelope.error_code)
|
|
163
|
+
const allowedCodes = new Set([
|
|
164
|
+
'FEDERATED_SYSTEM_UNAVAILABLE',
|
|
165
|
+
'FEDERATED_TICKET_INVALID',
|
|
166
|
+
'FEDERATED_SOURCE_UNAVAILABLE',
|
|
167
|
+
'FEDERATED_IDENTITY_NOT_READY',
|
|
168
|
+
'FEDERATED_APPLICATION_ACCESS_DENIED',
|
|
169
|
+
'FEDERATED_LOGIN_FAILED',
|
|
170
|
+
])
|
|
171
|
+
const message = text(envelope.msg)
|
|
172
|
+
if (
|
|
173
|
+
!allowedCodes.has(code) ||
|
|
174
|
+
!message ||
|
|
175
|
+
message.length > 200 ||
|
|
176
|
+
[...message].some((character) => {
|
|
177
|
+
const codePoint = character.charCodeAt(0)
|
|
178
|
+
return codePoint <= 31 || codePoint === 127
|
|
179
|
+
})
|
|
180
|
+
) {
|
|
181
|
+
return ''
|
|
182
|
+
}
|
|
183
|
+
return message
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function clearTicketFragment(): void {
|
|
187
|
+
const fragment = window.location.hash.startsWith('#')
|
|
188
|
+
? window.location.hash.slice(1)
|
|
189
|
+
: window.location.hash
|
|
190
|
+
const params = new URLSearchParams(fragment)
|
|
191
|
+
params.delete(UNIPLAT_APPLICATION_TICKET_FRAGMENT_KEY)
|
|
192
|
+
params.delete(UNIPLAT_APPLICATION_TICKET_SOURCE_SYSTEM_FRAGMENT_KEY)
|
|
193
|
+
const remainingFragment = params.toString()
|
|
194
|
+
const cleanUrl = `${window.location.pathname}${window.location.search}${remainingFragment ? `#${remainingFragment}` : ''}`
|
|
195
|
+
window.history.replaceState(window.history.state, '', cleanUrl)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function requireTicket(rawValue: unknown): string {
|
|
199
|
+
const ticket = text(rawValue).trim()
|
|
200
|
+
if (
|
|
201
|
+
ticket.length < MIN_TICKET_LENGTH ||
|
|
202
|
+
ticket.length > MAX_TICKET_LENGTH ||
|
|
203
|
+
!/^[A-Za-z0-9_-]+$/.test(ticket)
|
|
204
|
+
) {
|
|
205
|
+
throw new Error('Application ticket response is invalid')
|
|
206
|
+
}
|
|
207
|
+
return ticket
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function validateTicketLifetime(rawExpiresIn: unknown): void {
|
|
211
|
+
const expiresIn = number(rawExpiresIn)
|
|
212
|
+
if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > MAX_TICKET_LIFETIME_SECONDS) {
|
|
213
|
+
throw new Error('Application ticket response is invalid')
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function validateServiceBaseUrl(rawValue: string): string {
|
|
218
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : ''
|
|
219
|
+
if (!value) throw new TypeError('UniplatBase serviceBaseUrl is required')
|
|
220
|
+
const documentBase = typeof document === 'undefined' ? 'https://localhost/' : document.baseURI
|
|
221
|
+
let url: URL
|
|
222
|
+
try {
|
|
223
|
+
url = new URL(value, documentBase)
|
|
224
|
+
} catch {
|
|
225
|
+
throw new TypeError('UniplatBase serviceBaseUrl is invalid')
|
|
226
|
+
}
|
|
227
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
|
|
228
|
+
throw new TypeError('UniplatBase serviceBaseUrl must use HTTP(S) without user credentials')
|
|
229
|
+
}
|
|
230
|
+
if (url.search || url.hash) {
|
|
231
|
+
throw new TypeError('UniplatBase serviceBaseUrl must not contain query or hash')
|
|
232
|
+
}
|
|
233
|
+
return url.href.replace(/\/+$/, '')
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function requireText(rawValue: unknown, label: string): string {
|
|
237
|
+
const value = text(rawValue).trim()
|
|
238
|
+
if (!value) throw new TypeError(`${label} is required`)
|
|
239
|
+
return value
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
243
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
244
|
+
? (value as Record<string, unknown>)
|
|
245
|
+
: {}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function text(value: unknown): string {
|
|
249
|
+
return typeof value === 'string' ? value : ''
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function number(value: unknown): number {
|
|
253
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : Number.NaN
|
|
254
|
+
}
|