@iblai/data-layer 1.34.0 → 1.35.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 (30) hide show
  1. package/dist/index.d.ts +211 -53
  2. package/dist/index.esm.js +352 -105
  3. package/dist/index.esm.js.map +1 -1
  4. package/dist/index.js +352 -105
  5. package/dist/index.js.map +1 -1
  6. package/dist/package.json +1 -1
  7. package/dist/src/config.d.ts +8 -0
  8. package/dist/src/constants.d.ts +17 -1
  9. package/dist/src/features/course-metadata/__tests__/api-slice.test.d.ts +1 -0
  10. package/dist/src/features/course-metadata/api-slice.d.ts +162 -108
  11. package/dist/src/features/retirement/__tests__/api-slice.test.d.ts +1 -0
  12. package/dist/src/features/search/types.d.ts +2 -0
  13. package/dist/src/features/studio-authoring/__tests__/course-key.test.d.ts +1 -0
  14. package/dist/src/features/studio-authoring/api-slice.d.ts +12 -4
  15. package/dist/src/features/studio-authoring/constants.d.ts +4 -0
  16. package/dist/src/features/studio-authoring/course-key.d.ts +16 -0
  17. package/dist/src/features/studio-authoring/types.d.ts +12 -2
  18. package/dist/src/features/tenant/__tests__/api-slice.test.d.ts +1 -0
  19. package/dist/src/features/tenant/api-slice.d.ts +66 -21
  20. package/dist/src/features/tenant/constants.d.ts +1 -1
  21. package/dist/src/features/user/__tests__/roles-mapping.test.d.ts +1 -0
  22. package/dist/src/features/user/__tests__/user-metadata.test.d.ts +1 -0
  23. package/dist/src/features/user/api-slice.d.ts +15 -13
  24. package/dist/src/features/user/constants.d.ts +11 -1
  25. package/dist/src/features/user/roles-mapping.d.ts +9 -0
  26. package/dist/src/features/user/types.d.ts +26 -11
  27. package/dist/src/features/utils.d.ts +12 -0
  28. package/dist/src/utils/index.d.ts +4 -1
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -39049,10 +39049,26 @@ const TENANT_IDENTIFIERS = {
39049
39049
  exports.SERVICES = void 0;
39050
39050
  (function (SERVICES) {
39051
39051
  SERVICES["LEGACY_LMS"] = "LEGACY_LMS";
39052
+ /**
39053
+ * The Open edX LMS. With `Config.edxProxyEnabled` the call goes through DM's
39054
+ * `/v1/lms/{edx_path}` proxy with the DM token (DM mints the edX JWT
39055
+ * server-side); otherwise it goes to the LMS host with the edX JWT. The
39056
+ * proxy admits stock edX paths only: the `/api/ibl/...` family is refused,
39057
+ * and any course or org a path names must belong to the token's platform.
39058
+ */
39052
39059
  SERVICES["LMS"] = "LMS";
39053
39060
  SERVICES["DM"] = "DM";
39054
39061
  SERVICES["AXD"] = "AXD";
39062
+ /** Open edX Studio (CMS); proxied through `/v1/studio/{edx_path}` like `LMS`. */
39055
39063
  SERVICES["STUDIO"] = "STUDIO";
39064
+ /**
39065
+ * The LMS host with the edX JWT, whatever `Config.edxProxyEnabled` says. Only
39066
+ * for calls the proxy cannot serve: the login/token bootstrap and the
39067
+ * `/api/ibl/...` paths DM has no replacement for yet.
39068
+ */
39069
+ SERVICES["LMS_DIRECT"] = "LMS_DIRECT";
39070
+ /** The Studio host with its session cookie, CSRF token and edX JWT; see `LMS_DIRECT`. */
39071
+ SERVICES["STUDIO_DIRECT"] = "STUDIO_DIRECT";
39056
39072
  })(exports.SERVICES || (exports.SERVICES = {}));
39057
39073
  /**
39058
39074
  * Client errors a retry cannot fix.
@@ -39157,6 +39173,14 @@ Config.axdUrl = 'https://base.manager.iblai.app';
39157
39173
  Config.mentorIframeUrl = 'https://mentor.iblai.tech';
39158
39174
  Config.studioUrl = 'https://studio.iblai.app';
39159
39175
  Config.httpErrorHandlers = {};
39176
+ /**
39177
+ * Route `SERVICES.LMS` / `SERVICES.STUDIO` through DM's edX proxy
39178
+ * (`{dmUrl}/v1/lms`, `{dmUrl}/v1/studio`) with the DM token instead of
39179
+ * calling the edX hosts with the edX JWT. Needs DM 4.444.0+ with its proxy
39180
+ * rules synced, so it is off by default and turned on per deployment via
39181
+ * `initializeDataLayer(..., { edxProxy: true })`.
39182
+ */
39183
+ Config.edxProxyEnabled = false;
39160
39184
  /**
39161
39185
  * Optional override for how expired auth tokens are re-minted after a 401.
39162
39186
  * Resolve `true` once fresh tokens are in storage. Left unset, the data
@@ -39217,17 +39241,19 @@ const iblFakeBaseQuery = react.retry(fakeBaseQueryWithLogging, {
39217
39241
  },
39218
39242
  });
39219
39243
 
39244
+ // The login / token bootstrap runs before the SPA holds a DM token (and mints
39245
+ // it), so these stay on the LMS host whatever `Config.edxProxyEnabled` says.
39220
39246
  const AUTH_ENDPOINTS = {
39221
39247
  GET_MFE_CONTEXT: {
39222
- service: exports.SERVICES.LMS,
39248
+ service: exports.SERVICES.LMS_DIRECT,
39223
39249
  path: () => `/api/mfe_context`,
39224
39250
  },
39225
39251
  APPLE_EXCHANGE_TOKEN: {
39226
- service: exports.SERVICES.LMS,
39252
+ service: exports.SERVICES.LMS_DIRECT,
39227
39253
  path: () => `/oauth2/exchange_access_token/apple-id/`,
39228
39254
  },
39229
39255
  GET_APP_TOKENS: {
39230
- service: exports.SERVICES.LMS,
39256
+ service: exports.SERVICES.LMS_DIRECT,
39231
39257
  path: () => '/api/ibl/manager/consolidated-token/proxy/',
39232
39258
  },
39233
39259
  REFRESH_JWT_TOKEN: {
@@ -39344,6 +39370,15 @@ const refreshAuthTokenOnce = async () => {
39344
39370
  return refreshInFlight;
39345
39371
  };
39346
39372
 
39373
+ /**
39374
+ * True when `service` is `LMS` or `STUDIO` and `Config.edxProxyEnabled` sends
39375
+ * it through DM's edX proxy with the DM token.
39376
+ */
39377
+ const isEdxProxied = (service) => Config.edxProxyEnabled && (service === exports.SERVICES.LMS || service === exports.SERVICES.STUDIO);
39378
+ /** The LMS host on its own origin, authenticated with the edX JWT. */
39379
+ const isDirectLms = (service) => service === exports.SERVICES.LMS_DIRECT || (service === exports.SERVICES.LMS && !Config.edxProxyEnabled);
39380
+ /** The Studio host on its own origin: session cookie + CSRF token + edX JWT. */
39381
+ const isDirectStudio = (service) => service === exports.SERVICES.STUDIO_DIRECT || (service === exports.SERVICES.STUDIO && !Config.edxProxyEnabled);
39347
39382
  /**
39348
39383
  * Get the service URL based on the provided service type.
39349
39384
  */
@@ -39352,15 +39387,44 @@ const getServiceUrl = (service) => {
39352
39387
  case exports.SERVICES.LEGACY_LMS:
39353
39388
  return Config.legacyLmsUrl;
39354
39389
  case exports.SERVICES.LMS:
39390
+ return Config.edxProxyEnabled ? `${Config.dmUrl}/v1/lms` : Config.lmsUrl;
39391
+ case exports.SERVICES.LMS_DIRECT:
39355
39392
  return Config.lmsUrl;
39356
39393
  case exports.SERVICES.DM:
39357
39394
  return Config.dmUrl;
39358
39395
  case exports.SERVICES.STUDIO:
39396
+ return Config.edxProxyEnabled ? `${Config.dmUrl}/v1/studio` : Config.studioUrl;
39397
+ case exports.SERVICES.STUDIO_DIRECT:
39359
39398
  return Config.studioUrl;
39360
39399
  default:
39361
39400
  return Config.dmUrl;
39362
39401
  }
39363
39402
  };
39403
+ /**
39404
+ * The signed-in user's username from storage (`userData.user_nicename`), or
39405
+ * null. DM's user endpoints take `username=<self>` explicitly where the edX
39406
+ * ones inferred the caller from the JWT, so the endpoints that moved to DM
39407
+ * read it from here when the caller does not pass one.
39408
+ */
39409
+ const getStoredUsername = async () => {
39410
+ var _a;
39411
+ const storage = IblDataLayer.storage;
39412
+ if (!storage) {
39413
+ return null;
39414
+ }
39415
+ const raw = await storage.getItem(STORAGE_KEYS.USER_DATA);
39416
+ if (!raw) {
39417
+ return null;
39418
+ }
39419
+ try {
39420
+ const data = (typeof raw === 'string' ? JSON.parse(raw) : raw);
39421
+ const username = (_a = data === null || data === void 0 ? void 0 : data.user_nicename) !== null && _a !== void 0 ? _a : data === null || data === void 0 ? void 0 : data.username;
39422
+ return typeof username === 'string' && username ? username : null;
39423
+ }
39424
+ catch (_b) {
39425
+ return null;
39426
+ }
39427
+ };
39364
39428
  /**
39365
39429
  * Read a cookie value by name. Returns null outside the browser or when the
39366
39430
  * cookie is absent. Used for Studio's CSRF token (`csrftoken`).
@@ -39381,22 +39445,26 @@ const getHeaders = async (service) => {
39381
39445
  throw new Error('Storage not initialized. Please ensure the data layer is properly initialized before making API calls.');
39382
39446
  }
39383
39447
  let token = null;
39384
- switch (service) {
39385
- case exports.SERVICES.DM:
39386
- token = await IblDataLayer.storage.getItem(STORAGE_KEYS.DM_TOKEN_KEY);
39387
- break;
39388
- case exports.SERVICES.AXD:
39389
- token = await IblDataLayer.storage.getItem(STORAGE_KEYS.AXD_TOKEN_KEY);
39390
- break;
39391
- default:
39392
- token = await IblDataLayer.storage.getItem(STORAGE_KEYS.EDX_TOKEN_KEY);
39448
+ if (service === exports.SERVICES.DM || isEdxProxied(service)) {
39449
+ token = await IblDataLayer.storage.getItem(STORAGE_KEYS.DM_TOKEN_KEY);
39450
+ }
39451
+ else if (service === exports.SERVICES.AXD) {
39452
+ token = await IblDataLayer.storage.getItem(STORAGE_KEYS.AXD_TOKEN_KEY);
39453
+ }
39454
+ else {
39455
+ token = await IblDataLayer.storage.getItem(STORAGE_KEYS.EDX_TOKEN_KEY);
39393
39456
  }
39394
39457
  if (!token || token.trim() === '') {
39395
39458
  return; // No Authorization header when no valid token
39396
39459
  }
39460
+ // A storage that JSON-encodes strings on write hands the token back as
39461
+ // `"token"`; send it bare, or DM refuses it as invalid.
39462
+ if (token.startsWith('"') && token.endsWith('"')) {
39463
+ token = token.slice(1, -1);
39464
+ }
39397
39465
  let prefix;
39398
39466
  // Studio (Open edX CMS) accepts the same edX token as the LMS.
39399
- if (service === exports.SERVICES.LMS || service === exports.SERVICES.STUDIO) {
39467
+ if (isDirectLms(service) || isDirectStudio(service)) {
39400
39468
  if (token.length > 30) {
39401
39469
  prefix = 'JWT';
39402
39470
  }
@@ -39407,7 +39475,7 @@ const getHeaders = async (service) => {
39407
39475
  else {
39408
39476
  prefix = 'Token';
39409
39477
  }
39410
- return token ? { Authorization: `${prefix} ${token}` } : undefined;
39478
+ return { Authorization: `${prefix} ${token}` };
39411
39479
  };
39412
39480
  /**
39413
39481
  * Status codes that should not be retried for queryFn endpoints.
@@ -39542,7 +39610,7 @@ const isErrorObject = (data) => {
39542
39610
  const baseQuery = (service, jsonContentType = true, contentType, skipAuth = false) => query.fetchBaseQuery({
39543
39611
  baseUrl: getServiceUrl(service),
39544
39612
  timeout: 30000, // 30 second timeout
39545
- credentials: service === exports.SERVICES.LEGACY_LMS || service === exports.SERVICES.STUDIO ? 'include' : 'omit',
39613
+ credentials: service === exports.SERVICES.LEGACY_LMS || isDirectStudio(service) ? 'include' : 'omit',
39546
39614
  prepareHeaders: async (headers) => {
39547
39615
  // Only add auth headers if skipAuth is false
39548
39616
  if (!skipAuth) {
@@ -39561,8 +39629,9 @@ const baseQuery = (service, jsonContentType = true, contentType, skipAuth = fals
39561
39629
  headers.set('Content-Type', contentType);
39562
39630
  }
39563
39631
  // Studio (Open edX CMS) endpoints require the Django CSRF token and only
39564
- // return JSON when asked for it explicitly.
39565
- if (service === exports.SERVICES.STUDIO) {
39632
+ // return JSON when asked for it explicitly. Through DM's proxy neither
39633
+ // applies: no cookies travel and DM mints the edX JWT.
39634
+ if (isDirectStudio(service)) {
39566
39635
  const csrfToken = getCookieValue('csrftoken');
39567
39636
  if (csrfToken) {
39568
39637
  headers.set('X-CSRFToken', csrfToken);
@@ -39573,7 +39642,7 @@ const baseQuery = (service, jsonContentType = true, contentType, skipAuth = fals
39573
39642
  headers.set('X-Requested-With', 'XMLHttpRequest');
39574
39643
  }
39575
39644
  // Add CSRF protection headers for LMS service
39576
- if (service === exports.SERVICES.LMS) {
39645
+ if (isDirectLms(service)) {
39577
39646
  // Use mentorIframeUrl for both origin and referer
39578
39647
  // This matches the pattern used in loginCompleteApi.ts
39579
39648
  headers.set('Referer', Config.mentorIframeUrl);
@@ -40548,13 +40617,15 @@ const authApiSlice = react.createApi({
40548
40617
  const { useGetMfeContextQuery, useExchangeAppleTokenMutation, useGetAppTokensMutation, useLazyRefreshJwtTokenQuery, } = authApiSlice;
40549
40618
 
40550
40619
  const TENANTS_ENDPOINTS = {
40620
+ // The DM view the edX `/api/ibl/users/manage/platform/` endpoint used to
40621
+ // proxy; takes `username=<self>`.
40551
40622
  GET_USER_TENANTS: {
40552
- service: exports.SERVICES.LMS,
40553
- path: () => `/api/ibl/users/manage/platform/`,
40623
+ service: exports.SERVICES.DM,
40624
+ path: () => `/api/core/users/platforms/`,
40554
40625
  },
40555
40626
  };
40556
40627
  const TENANTS_QUERY_KEYS = {
40557
- GET_USER_TENANTS: () => ["USER_TENANTS"],
40628
+ GET_USER_TENANTS: () => ['USER_TENANTS'],
40558
40629
  };
40559
40630
 
40560
40631
  const tenantApiSlice = react.createApi({
@@ -40570,11 +40641,18 @@ const tenantApiSlice = react.createApi({
40570
40641
  ...buildEndpointFromDmServiceLegacy(CoreService.coreOrgsMetadataUpdate),
40571
40642
  invalidatesTags: ['TenantMetadata'],
40572
40643
  }),
40644
+ /** The platforms the signed-in user is linked to. `username` defaults to the stored user. */
40573
40645
  getUserTenants: builder.query({
40574
- queryFn: async () => {
40646
+ queryFn: async (args) => {
40647
+ var _a;
40575
40648
  try {
40576
- const authHeaders = await getHeaders(exports.SERVICES.LMS);
40577
- const response = await fetch(`${Config.lmsUrl}${TENANTS_ENDPOINTS.GET_USER_TENANTS.path()}`, {
40649
+ const username = (_a = args === null || args === void 0 ? void 0 : args.username) !== null && _a !== void 0 ? _a : (await getStoredUsername());
40650
+ if (!username) {
40651
+ return { error: { status: 'CUSTOM_ERROR', data: 'No username available' } };
40652
+ }
40653
+ const { service, path } = TENANTS_ENDPOINTS.GET_USER_TENANTS;
40654
+ const authHeaders = await getHeaders(service);
40655
+ const response = await fetch(`${getServiceUrl(service)}${path()}?username=${encodeURIComponent(username)}`, {
40578
40656
  headers: {
40579
40657
  ...authHeaders,
40580
40658
  },
@@ -41058,28 +41136,41 @@ const platformApiReducer = platformApiSlice.reducer;
41058
41136
  const { useUsersGradesPassedQuery, useLazyPlatformUsersQuery, useLazyPlatformUserGroupsQuery, usePlatformUserGroupsQuery, usePlatformInvitationsQuery, usePlatformUsersQuery, useInviteUserMutation, useUpdatePlatformUserRoleWithPoliciesMutation, useCreatePlatformUserGroupMutation, useUpdatePlatformUserGroupMutation, useDeletePlatformUserGroupMutation, useGetPlatformUserGroupDetailsQuery, useGetApplicationStatusQuery, useLazyGetApplicationStatusQuery, useGetApplicationFormQuery, useLazyGetApplicationFormQuery, useCreateApplicationDraftMutation, useUpdateApplicationMutation, useSubmitApplicationMutation, useUploadApplicationAttachmentMutation, useDeleteApplicationAttachmentMutation, useGetApplicationFormsQuery, useLazyGetApplicationFormsQuery, useGetApplicationFormDetailQuery, useLazyGetApplicationFormDetailQuery, useCreateApplicationFormMutation, useUpdateApplicationFormMutation, useGetApplicationsPipelineQuery, useLazyGetApplicationsPipelineQuery, useGetApplicationsPipelineStatsQuery, useListOwnApplicationsQuery, useLazyListOwnApplicationsQuery, useRenewApplicationMutation, useWithdrawApplicationMutation, useGetApplicationAdminDetailQuery, useLazyGetApplicationAdminDetailQuery, useTransitionApplicationMutation, useCreateApplicationWaiverMutation, useDeleteApplicationWaiverMutation, useSubmitApplicationOverrideMutation, useGetApplicationNotesQuery, useCreateApplicationNoteMutation, useUpdateApplicationNoteMutation, useDeleteApplicationNoteMutation, useLazyGetApplicationAttachmentFileQuery, useGetApplicationBlocksQuery, useDeleteApplicationBlockMutation, useGetApplicationPlacementsQuery, useCreateApplicationPlacementsMutation, useUpdateApplicationPlacementMutation, useGetApplicationCourseAssignmentsQuery, useCreateApplicationCourseAssignmentsMutation, useDeleteApplicationCourseAssignmentMutation, useInviteApplicationCandidateMutation, useLinkApplicationCandidateMutation, useUserPlatformsQuery, useLazyUserPlatformsQuery, } = platformApiSlice;
41059
41137
 
41060
41138
  const USERS_ENDPOINTS = {
41139
+ // The merged edX + DM profile, served by DM itself (the same payload the edX
41140
+ // `/api/ibl/users/manage/metadata/` endpoint used to proxy).
41061
41141
  GET_USER_METADATA: {
41062
- service: exports.SERVICES.LMS,
41142
+ service: exports.SERVICES.DM,
41143
+ path: () => `/api/core/users/metadata/proxy/`,
41144
+ },
41145
+ // TODO(iblai-platform#2707 B1): the write still goes to the edX plugin. Its
41146
+ // edX half could be a merge-patch on `/api/user/v1/accounts/<self>`, but the
41147
+ // DM-only fields (title, about, public_metadata, tour/onboarding flags) have
41148
+ // no self-write endpoint on DM yet, and splitting the call would drop them.
41149
+ UPDATE_USER_METADATA: {
41150
+ service: exports.SERVICES.LMS_DIRECT,
41063
41151
  path: () => `/api/ibl/users/manage/metadata/`,
41064
41152
  },
41065
41153
  RESET_PASSWORD: {
41066
41154
  service: exports.SERVICES.LEGACY_LMS,
41067
41155
  path: () => `/ibl/edx/account/api/reset-password-token`,
41068
41156
  },
41157
+ // Platform-admin flag on a user's platform link; DM syncs the matching edX
41158
+ // org role itself.
41069
41159
  UPDATE_USER_ROLES: {
41070
- service: exports.SERVICES.LMS,
41160
+ service: exports.SERVICES.DM,
41161
+ path: () => `/api/core/users/platforms/`,
41162
+ },
41163
+ /** @deprecated The edX role write behind the legacy `{ username, org, role, active }` shape. */
41164
+ UPDATE_USER_ROLES_EDX: {
41165
+ service: exports.SERVICES.LMS_DIRECT,
41071
41166
  path: () => `/api/ibl/users/manage/roles/`,
41072
41167
  },
41073
- // All roles assigned to a user across every scope (global, org, course).
41074
- // The same path is served by the CMS/Studio host and, deprecated, by the LMS.
41075
- // Studio is the default because course-creation roles are only fully
41076
- // resolvable there; `lmsService` is the fallback for deployments without a
41077
- // reachable Studio host. Both hosts share one cache tag, so this stays a
41078
- // single endpoint rather than two.
41168
+ // The caller's course access roles from the edX enrollment API, mapped onto
41169
+ // the keys the plugin listing used (`course-staff`, ...). Org-wide roles live
41170
+ // on the DM side (`/api/core/users/platforms/` → is_admin / is_staff).
41079
41171
  GET_USER_ROLES: {
41080
- service: exports.SERVICES.STUDIO,
41081
- lmsService: exports.SERVICES.LMS,
41082
- path: () => `/api/ibl/users/manage/roles/`,
41172
+ service: exports.SERVICES.LMS,
41173
+ path: () => `/api/enrollment/v1/roles/`,
41083
41174
  },
41084
41175
  UPLOAD_PROFILE_IMAGE: {
41085
41176
  service: exports.SERVICES.LMS,
@@ -41113,11 +41204,13 @@ const USERS_ENDPOINTS = {
41113
41204
  };
41114
41205
  const USERS_QUERY_KEYS = {
41115
41206
  GET_USER_METADATA: () => ['USER_METADATA'],
41207
+ UPDATE_USER_METADATA: () => ['USER_METADATA'],
41116
41208
  GET_USER_PLATFORM_METADATA: () => ['USER_PLATFORM_METADATA'],
41117
41209
  GET_USER_METADATA_EDX: () => ['USER_METADATA_EDX'],
41118
41210
  GET_USER_PROFILE_SUMMARY: () => ['USER_PROFILE_SUMMARY'],
41119
41211
  RESET_PASSWORD: () => ['RESET_PASSWORD'],
41120
41212
  UPDATE_USER_ROLES: () => ['UPDATE_USER_ROLES'],
41213
+ UPDATE_USER_ROLES_EDX: () => ['UPDATE_USER_ROLES'],
41121
41214
  GET_USER_ROLES: () => ['USER_ROLES'],
41122
41215
  UPLOAD_PROFILE_IMAGE: () => ['UPLOAD_PROFILE_IMAGE'],
41123
41216
  EDX_UPDATE_USER_METADATA: () => ['EDX_UPDATE_USER_METADATA'],
@@ -41126,6 +41219,44 @@ const USERS_QUERY_KEYS = {
41126
41219
  };
41127
41220
  const USERS_REDUCER_PATH = 'userApiSlice';
41128
41221
 
41222
+ /**
41223
+ * Open edX `CourseAccessRole` names → the keys the edX plugin's role listing
41224
+ * used, which the role filters and the apps' staff checks are written against.
41225
+ */
41226
+ const EDX_ROLE_KEYS = {
41227
+ staff: 'staff',
41228
+ instructor: 'instructor',
41229
+ limited_staff: 'limited-staff',
41230
+ finance_admin: 'finance-admin',
41231
+ sales_admin: 'sales-admin',
41232
+ beta_testers: 'beta-tester',
41233
+ library_user: 'library-user',
41234
+ ccx_coach: 'ccx-coach',
41235
+ data_researcher: 'data-researcher',
41236
+ course_creator_group: 'course-creator',
41237
+ support: 'support-staff',
41238
+ };
41239
+ const roleKey = (edxRole) => { var _a; return (_a = EDX_ROLE_KEYS[edxRole]) !== null && _a !== void 0 ? _a : edxRole.replace(/_/g, '-'); };
41240
+ /**
41241
+ * Maps the edX enrollment roles API (`GET /api/enrollment/v1/roles/`) onto the
41242
+ * `UserRole[]` shape the plugin listing returned: course rows become
41243
+ * `course-<role>`, org rows `org-<role>`, and the global `is_staff` flag a
41244
+ * `staff` row. The edX API only lists `CourseAccessRole` rows, so org-wide
41245
+ * roles granted on the DM side (platform admin / staff) are not included.
41246
+ */
41247
+ const mapEdxUserRoles = (response) => {
41248
+ var _a;
41249
+ const roles = ((_a = response === null || response === void 0 ? void 0 : response.roles) !== null && _a !== void 0 ? _a : []).map(({ role, org, course_id }) => {
41250
+ const key = roleKey(role);
41251
+ const scoped = course_id ? `course-${key}` : org ? `org-${key}` : key;
41252
+ return { role: scoped, org: org !== null && org !== void 0 ? org : '', course: course_id !== null && course_id !== void 0 ? course_id : '' };
41253
+ });
41254
+ if (response === null || response === void 0 ? void 0 : response.is_staff) {
41255
+ roles.unshift({ role: 'staff', org: '', course: '' });
41256
+ }
41257
+ return roles;
41258
+ };
41259
+
41129
41260
  const userApiSlice = react.createApi({
41130
41261
  reducerPath: USERS_REDUCER_PATH,
41131
41262
  baseQuery: iblFetchBaseQuery,
@@ -41148,8 +41279,8 @@ const userApiSlice = react.createApi({
41148
41279
  }),
41149
41280
  updateUserMetadata: builder.mutation({
41150
41281
  query: (args) => ({
41151
- url: USERS_ENDPOINTS.GET_USER_METADATA.path(),
41152
- service: USERS_ENDPOINTS.GET_USER_METADATA.service,
41282
+ url: USERS_ENDPOINTS.UPDATE_USER_METADATA.path(),
41283
+ service: USERS_ENDPOINTS.UPDATE_USER_METADATA.service,
41153
41284
  body: args,
41154
41285
  method: 'POST',
41155
41286
  params: { username: args.username },
@@ -41158,8 +41289,8 @@ const userApiSlice = react.createApi({
41158
41289
  }),
41159
41290
  updateUserAccount: builder.mutation({
41160
41291
  query: (args) => ({
41161
- url: USERS_ENDPOINTS.GET_USER_METADATA.path(),
41162
- service: USERS_ENDPOINTS.GET_USER_METADATA.service,
41292
+ url: USERS_ENDPOINTS.UPDATE_USER_METADATA.path(),
41293
+ service: USERS_ENDPOINTS.UPDATE_USER_METADATA.service,
41163
41294
  body: args,
41164
41295
  method: 'POST',
41165
41296
  params: { username: args.username },
@@ -41188,45 +41319,45 @@ const userApiSlice = react.createApi({
41188
41319
  };
41189
41320
  },
41190
41321
  }),
41322
+ /**
41323
+ * Make a user a platform admin, or demote them. Takes the DM platform-link
41324
+ * shape (`user_id`, `platform_key`, `is_admin`); DM syncs the edX org role.
41325
+ * The legacy `{ username, org, role, active }` shape still writes the edX
41326
+ * role directly and is deprecated.
41327
+ */
41191
41328
  updateUserRole: builder.mutation({
41192
- query: (args) => ({
41193
- url: USERS_ENDPOINTS.UPDATE_USER_ROLES.path(),
41194
- service: USERS_ENDPOINTS.UPDATE_USER_ROLES.service,
41195
- body: args,
41196
- method: 'POST',
41197
- }),
41329
+ query: (args) => 'user_id' in args
41330
+ ? {
41331
+ url: USERS_ENDPOINTS.UPDATE_USER_ROLES.path(),
41332
+ service: USERS_ENDPOINTS.UPDATE_USER_ROLES.service,
41333
+ body: {
41334
+ user_id: args.user_id,
41335
+ platform_key: args.platform_key,
41336
+ is_admin: args.is_admin,
41337
+ },
41338
+ method: 'POST',
41339
+ }
41340
+ : {
41341
+ url: USERS_ENDPOINTS.UPDATE_USER_ROLES_EDX.path(),
41342
+ service: USERS_ENDPOINTS.UPDATE_USER_ROLES_EDX.service,
41343
+ body: args,
41344
+ method: 'POST',
41345
+ },
41198
41346
  invalidatesTags: [featureTags.PLATFORM_USERS, ...USERS_QUERY_KEYS.GET_USER_ROLES()],
41199
41347
  }),
41200
41348
  /**
41201
- * Every role assigned to a user, across all scopes — global, organization
41202
- * and individual course. A user with no roles returns `[]`.
41203
- *
41204
- * At least one of `username`, `email` or `userId` is required (400
41205
- * otherwise). Non-staff users can only look up their own roles; asking for
41206
- * someone else's returns 404.
41207
- *
41208
- * Defaults to the CMS/Studio host, where course-creation roles are fully
41209
- * resolvable; pass `useLms` to fall back to the deprecated LMS host.
41349
+ * The signed-in user's course access roles (`course-staff`,
41350
+ * `course-instructor`, ...) plus a global `staff` row when they are edX
41351
+ * staff, from the edX enrollment roles API through DM's proxy. A user with
41352
+ * no roles returns `[]`. Org-wide roles are not listed here: DM holds them
41353
+ * on the user's platform link.
41210
41354
  */
41211
41355
  getUserRoles: builder.query({
41212
- query: ({ username, email, userId, useLms }) => {
41213
- const endpoint = USERS_ENDPOINTS.GET_USER_ROLES;
41214
- const params = {};
41215
- if (username) {
41216
- params.username = username;
41217
- }
41218
- if (email) {
41219
- params.email = email;
41220
- }
41221
- if (userId !== undefined) {
41222
- params.user_id = userId;
41223
- }
41224
- return {
41225
- url: endpoint.path(),
41226
- service: useLms ? endpoint.lmsService : endpoint.service,
41227
- params,
41228
- };
41229
- },
41356
+ query: () => ({
41357
+ url: USERS_ENDPOINTS.GET_USER_ROLES.path(),
41358
+ service: USERS_ENDPOINTS.GET_USER_ROLES.service,
41359
+ }),
41360
+ transformResponse: mapEdxUserRoles,
41230
41361
  providesTags: USERS_QUERY_KEYS.GET_USER_ROLES(),
41231
41362
  }),
41232
41363
  getUserMetadataEdx: builder.query({
@@ -44158,30 +44289,61 @@ const coursesApiSlice = react.createApi({
44158
44289
  });
44159
44290
  const { useGetUserEnrolledCoursesQuery, useLazyGetUserEnrolledCoursesQuery, useGetUserAssignedCoursesQuery, useLazyGetUserAssignedCoursesQuery, useGetLearnerCourseOutlineQuery, useLazyGetLearnerCourseOutlineQuery, } = coursesApiSlice;
44160
44291
 
44292
+ /** One request through the base query, unwrapped to its payload. */
44293
+ const fetchData = async (args, api, extraOptions) => {
44294
+ const result = await iblFetchBaseQuery(args, api, extraOptions);
44295
+ if (result.error) {
44296
+ throw result.error;
44297
+ }
44298
+ return result.data;
44299
+ };
44300
+ /**
44301
+ * DM's catalog user endpoints take `username=<self>` where the edX plugin
44302
+ * inferred the caller from the JWT; a caller that passes none gets the stored
44303
+ * user.
44304
+ */
44305
+ const fetchForUser = async (path, params, username, api, extraOptions) => {
44306
+ const user = username !== null && username !== void 0 ? username : (await getStoredUsername());
44307
+ if (!user) {
44308
+ return { error: { status: 400, data: 'No username available' } };
44309
+ }
44310
+ const search = new URLSearchParams({ username: user, ...params });
44311
+ const data = await fetchData({ url: `${path}?${search.toString()}`, service: exports.SERVICES.DM, method: 'GET' }, api, extraOptions);
44312
+ return { data };
44313
+ };
44161
44314
  const courseMetadataApiSlice = react.createApi({
44162
44315
  reducerPath: 'courseMetadataApiSlice',
44163
44316
  baseQuery: iblFetchBaseQuery,
44164
44317
  endpoints: (builder) => ({
44318
+ /**
44319
+ * The edX plugin's `course_metadata` payload, served by DM (to signed-in
44320
+ * and anonymous callers alike).
44321
+ */
44165
44322
  getCourseMetaData: builder.query({
44166
44323
  query: ({ courseKey }) => ({
44167
- url: `/api/ibl/v1/course_metadata?course_key=${encodeURIComponent(courseKey)}`,
44168
- service: exports.SERVICES.LMS,
44324
+ url: `/v1/courses/metadata?course_id=${encodeURIComponent(courseKey)}`,
44325
+ service: exports.SERVICES.DM,
44169
44326
  method: 'GET',
44170
44327
  }),
44328
+ // DM leaves the price null where the plugin said "Free".
44329
+ transformResponse: (course) => {
44330
+ var _a;
44331
+ return ({
44332
+ ...course,
44333
+ course_price: (_a = course.course_price) !== null && _a !== void 0 ? _a : 'Free',
44334
+ });
44335
+ },
44171
44336
  }),
44337
+ /**
44338
+ * The completion outline, from DM. DM answers 404 for a course the user is
44339
+ * not enrolled in, where the edX plugin returned an empty outline.
44340
+ */
44172
44341
  getCourseCompletionOutlines: builder.query({
44173
- query: ({ courseKey }) => ({
44174
- url: `/api/ibl/completion/course_outline/${courseKey}?course_id=${encodeURIComponent(courseKey)}`,
44175
- service: exports.SERVICES.LMS,
44176
- method: 'GET',
44177
- }),
44342
+ queryFn: ({ courseKey, username }, api, extraOptions) => fetchForUser('/api/catalog/enrollment/courses/completion/', { course_id: courseKey, endpoint: 'outline' }, username, api, extraOptions),
44178
44343
  }),
44344
+ /** Enrollment eligibility, from DM. Same fields as the edX plugin plus `is_eligible`. */
44179
44345
  getCourseEligibility: builder.query({
44180
- query: ({ courseKey }) => ({
44181
- url: `/api/ibl/enrollment/enroll_status?course_id=${encodeURIComponent(courseKey)}`,
44182
- service: exports.SERVICES.LMS,
44183
- method: 'GET',
44184
- }),
44346
+ queryFn: ({ courseKey, username }, api, extraOptions) => fetchForUser('/api/catalog/eligibility/courses/check/', { course_id: courseKey }, username, api, extraOptions),
44185
44347
  }),
44186
44348
  createCourseEnrollment: builder.mutation({
44187
44349
  query: (body) => ({
@@ -44224,38 +44386,54 @@ const edxSsoApiSlice = react.createApi({
44224
44386
  });
44225
44387
  const { useGetEdxSSOTokenQuery, useLazyGetEdxSSOTokenQuery } = edxSsoApiSlice;
44226
44388
 
44389
+ // Endpoints DM's edX proxy admits use `SERVICES.STUDIO` (proxied when
44390
+ // `Config.edxProxyEnabled`). The rest are pinned to the Studio host: the
44391
+ // `api/ibl` manage endpoints the proxy refuses, and the session + CSRF views.
44227
44392
  const STUDIO_AUTHORING_ENDPOINTS = {
44393
+ // Course-creator app endpoint, not under `api/ibl` so the proxy reaches it.
44394
+ // Takes `{ org, display_name, number, run }`, answers `{ course_parent_locator }`.
44228
44395
  CREATE_COURSE: {
44229
- path: (contentType) => `/api/ibl/manage/${contentType}/`,
44396
+ path: () => '/api/v1/ibl/course/create',
44230
44397
  service: exports.SERVICES.STUDIO,
44231
44398
  },
44399
+ // Libraries still go through the plugin's manage endpoint.
44400
+ CREATE_LIBRARY: {
44401
+ path: (contentType) => `/api/ibl/manage/${contentType}/`,
44402
+ service: exports.SERVICES.STUDIO_DIRECT,
44403
+ },
44404
+ // TODO(iblai-platform#2707 B5): no DM or proxy replacement for course delete.
44232
44405
  DELETE_COURSE: {
44233
44406
  path: () => '/api/ibl/manage/course/delete',
44234
- service: exports.SERVICES.STUDIO,
44407
+ service: exports.SERVICES.STUDIO_DIRECT,
44235
44408
  },
44409
+ // TODO(iblai-platform#2707 row 13 / B6): the edX half could become a
44410
+ // `contentstore/v1/course_details` PUT through the proxy, but the DM catalog
44411
+ // half (`/api/catalog/metadata/course/`) is DM-admin only, so the combined
44412
+ // plugin endpoint stays until platform admins can write catalog metadata.
44236
44413
  COURSE_SETTINGS: {
44237
44414
  path: (contentType) => `/api/ibl/v1/${contentType}_settings/`,
44238
- service: exports.SERVICES.STUDIO,
44415
+ service: exports.SERVICES.STUDIO_DIRECT,
44239
44416
  },
44417
+ // Session + CSRF views, not JWT-capable: stay on the Studio host.
44240
44418
  CREATE_XBLOCK: {
44241
44419
  path: () => '/xblock/',
44242
- service: exports.SERVICES.STUDIO,
44420
+ service: exports.SERVICES.STUDIO_DIRECT,
44243
44421
  },
44244
44422
  UPDATE_XBLOCK: {
44245
44423
  path: (locator) => `/xblock/${locator}`,
44246
- service: exports.SERVICES.STUDIO,
44424
+ service: exports.SERVICES.STUDIO_DIRECT,
44247
44425
  },
44248
44426
  SCORM_SUBMIT: {
44249
44427
  path: (locator) => `/xblock/${locator}/handler/studio_submit`,
44250
- service: exports.SERVICES.STUDIO,
44428
+ service: exports.SERVICES.STUDIO_DIRECT,
44251
44429
  },
44252
44430
  UPLOAD_ASSET: {
44253
44431
  path: (courseKey) => `/assets/${courseKey}/`,
44254
- service: exports.SERVICES.STUDIO,
44432
+ service: exports.SERVICES.STUDIO_DIRECT,
44255
44433
  },
44256
44434
  ADVANCED_SETTINGS: {
44257
44435
  path: (courseKey) => `/settings/advanced/${courseKey}`,
44258
- service: exports.SERVICES.STUDIO,
44436
+ service: exports.SERVICES.STUDIO_DIRECT,
44259
44437
  },
44260
44438
  VIDEO_UPLOAD: {
44261
44439
  path: (courseKey) => `/api/v1/ibl/course/${courseKey}/s3-upload`,
@@ -44263,23 +44441,88 @@ const STUDIO_AUTHORING_ENDPOINTS = {
44263
44441
  },
44264
44442
  };
44265
44443
 
44444
+ const RUN_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
44266
44445
  /**
44267
- * Studio (Open edX CMS) course-authoring endpoints. These hit the Studio host
44268
- * (`Config.studioUrl`) with cookie-session auth + CSRF token, mirroring how
44269
- * Studio's own frontend talks to it.
44446
+ * A course number derived from the display name: letters, digits and single
44447
+ * hyphens, at most 30 characters, `COURSE` when nothing usable is left.
44448
+ */
44449
+ const courseNumberFromDisplayName = (displayName) => {
44450
+ const number = displayName
44451
+ .trim()
44452
+ .replace(/[^A-Za-z0-9]+/g, '-')
44453
+ .replace(/^-+|-+$/g, '')
44454
+ .slice(0, 30)
44455
+ .replace(/-+$/, '');
44456
+ return number || 'COURSE';
44457
+ };
44458
+ /** A run like `2026_a1B`: the year plus a short random suffix, as Studio's own generator makes them. */
44459
+ const generateCourseRun = (now = new Date()) => {
44460
+ let suffix = '';
44461
+ for (let index = 0; index < 3; index += 1) {
44462
+ suffix += RUN_SUFFIX_ALPHABET[Math.floor(Math.random() * RUN_SUFFIX_ALPHABET.length)];
44463
+ }
44464
+ return `${now.getFullYear()}_${suffix}`;
44465
+ };
44466
+ /**
44467
+ * The course-creator endpoint needs `number` and `run`, which the old plugin
44468
+ * endpoint generated itself; fill them in when the caller only gave `org` and
44469
+ * `display_name`.
44470
+ */
44471
+ const withCourseNumberAndRun = (courseData) => {
44472
+ var _a, _b;
44473
+ return ({
44474
+ ...courseData,
44475
+ number: (_a = courseData.number) !== null && _a !== void 0 ? _a : courseNumberFromDisplayName(courseData.display_name),
44476
+ run: (_b = courseData.run) !== null && _b !== void 0 ? _b : generateCourseRun(),
44477
+ });
44478
+ };
44479
+ /** `block-v1:ORG+NUM+RUN+type@course+block@course` → `course-v1:ORG+NUM+RUN`. */
44480
+ const courseKeyFromLocator = (locator) => {
44481
+ const match = /^block-v1:(.+)\+type@course\+block@course$/.exec(locator);
44482
+ return match ? `course-v1:${match[1]}` : undefined;
44483
+ };
44484
+
44485
+ /**
44486
+ * Studio (Open edX CMS) course-authoring endpoints. Course creation and video
44487
+ * upload reach Studio through DM's edX proxy when `Config.edxProxyEnabled`;
44488
+ * the rest hit the Studio host (`Config.studioUrl`) with cookie-session auth +
44489
+ * CSRF token, mirroring how Studio's own frontend talks to it.
44270
44490
  */
44271
44491
  const studioAuthoringApiSlice = react.createApi({
44272
44492
  reducerPath: 'studioAuthoringApiSlice',
44273
44493
  baseQuery: iblFetchBaseQuery,
44274
44494
  endpoints: (builder) => ({
44275
- /** Create the course (or library) shell. Returns `course_key`. */
44495
+ /**
44496
+ * Create the course (or library) shell. Returns `course_key`.
44497
+ *
44498
+ * A course goes to the course-creator endpoint, which needs `number` and
44499
+ * `run` (generated from the display name when absent) and answers with the
44500
+ * root block locator; `course_key` is derived from it. A library still uses
44501
+ * the plugin's manage endpoint on the Studio host.
44502
+ */
44276
44503
  createStudioCourse: builder.mutation({
44277
- query: ({ courseData, contentType = 'course' }) => ({
44278
- url: STUDIO_AUTHORING_ENDPOINTS.CREATE_COURSE.path(contentType),
44279
- service: STUDIO_AUTHORING_ENDPOINTS.CREATE_COURSE.service,
44280
- method: 'POST',
44281
- body: courseData,
44282
- }),
44504
+ query: ({ courseData, contentType = 'course' }) => contentType === 'course'
44505
+ ? {
44506
+ url: STUDIO_AUTHORING_ENDPOINTS.CREATE_COURSE.path(),
44507
+ service: STUDIO_AUTHORING_ENDPOINTS.CREATE_COURSE.service,
44508
+ method: 'POST',
44509
+ body: withCourseNumberAndRun(courseData),
44510
+ }
44511
+ : {
44512
+ url: STUDIO_AUTHORING_ENDPOINTS.CREATE_LIBRARY.path(contentType),
44513
+ service: STUDIO_AUTHORING_ENDPOINTS.CREATE_LIBRARY.service,
44514
+ method: 'POST',
44515
+ body: courseData,
44516
+ },
44517
+ transformResponse: (response) => {
44518
+ var _a;
44519
+ return ({
44520
+ ...response,
44521
+ course_key: (_a = response.course_key) !== null && _a !== void 0 ? _a : (typeof response.course_parent_locator === 'string'
44522
+ ? courseKeyFromLocator(response.course_parent_locator)
44523
+ : undefined),
44524
+ });
44525
+ },
44283
44526
  }),
44284
44527
  /** Delete a course — used as rollback when a later creation step fails. */
44285
44528
  deleteStudioCourse: builder.mutation({
@@ -47168,9 +47411,11 @@ const edxProctoringApiSlice = react.createApi({
47168
47411
  const { useGetExamInfoQuery, useLazyGetExamInfoQuery, useUpdateExamAttemptMutation, useStartExamMutation, } = edxProctoringApiSlice;
47169
47412
 
47170
47413
  const RETIREMENT_ENDPOINTS = {
47414
+ // Retires the caller (no body). Answers 202: the retirement then runs
47415
+ // asynchronously on DM and edX.
47171
47416
  SELF_RETIRE: {
47172
- service: exports.SERVICES.LMS,
47173
- path: () => `/api/ibl/retirements/self_retire/`,
47417
+ service: exports.SERVICES.DM,
47418
+ path: () => `/api/core/users/delete/`,
47174
47419
  },
47175
47420
  };
47176
47421
 
@@ -49950,7 +50195,8 @@ const skillsMiddleware = [
49950
50195
  userLlmUsageApiSlice.middleware,
49951
50196
  ];
49952
50197
 
49953
- const initializeDataLayer = (dmUrl, lmsUrl, legacyLmsUrl, storageService, httpErrorHandler = {}, studioUrl, refreshAuthToken) => {
50198
+ const initializeDataLayer = (dmUrl, lmsUrl, legacyLmsUrl, storageService, httpErrorHandler = {}, studioUrl, refreshAuthToken, options) => {
50199
+ var _a;
49954
50200
  IblDataLayer.storage = storageService;
49955
50201
  Config.dmUrl = dmUrl;
49956
50202
  Config.lmsUrl = lmsUrl;
@@ -49960,6 +50206,7 @@ const initializeDataLayer = (dmUrl, lmsUrl, legacyLmsUrl, storageService, httpEr
49960
50206
  Config.studioUrl = studioUrl;
49961
50207
  }
49962
50208
  Config.refreshAuthToken = refreshAuthToken;
50209
+ Config.edxProxyEnabled = (_a = options === null || options === void 0 ? void 0 : options.edxProxy) !== null && _a !== void 0 ? _a : false;
49963
50210
  };
49964
50211
 
49965
50212
  exports.AGENT_SKILLS_ENDPOINTS = AGENT_SKILLS_ENDPOINTS;