Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions README-keycloak.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,27 @@ be sure that you have the following set in your .env file:
start the app without the profile, you can still start Keycloak later by
specifying the profile.)

`DISABLE_APISIX_USER_MIDDLEWARE=True` is the default in `env/backend.env`
(without APISIX actually running, nothing verifies the `X-Userinfo` header
Django would otherwise trust, so this is disabled unless you opt in). Add
`DISABLE_APISIX_USER_MIDDLEWARE=False` to your `backend.local.env` file so
Django will trust the header APISIX sets after a real Keycloak login.

When you run `docker compose up`, the Keycloak and APISIX containers should start up.
APISIX is on port 8065, Keycloak on port 8066. Now you should be able to log in at
`https://open.odl.local:8065/login` with one of the users mentioned above, or
just click "Log in" from the home page at http://open.odl.local:8062. Try
logging out and back in a couple times to make sure it works.

Keycloak is enabled by default. If you do NOT want to use the Keycloak and APISIX instances,
follow these steps:
Not using Keycloak/APISIX is the default (`DISABLE_APISIX_USER_MIDDLEWARE=True`,
`COMPOSE_PROFILES=backend,frontend`) -- no extra steps needed. If you've
already switched into Keycloak/APISIX mode above and want to switch back:

1. Change the value of `MITOL_API_BASE_URL` to `http://api.open.odl.local:8063`
in your `shared.local.env` file.
2. Add `DISABLE_APISIX_USER_MIDDLEWARE=True` to your `backend.local.env` file
3. Set `COMPOSE_PROFILES=backend,frontend` in your .env file
2. Set `COMPOSE_PROFILES=backend,frontend` in your .env file
3. Remove (or set to `True`) `DISABLE_APISIX_USER_MIDDLEWARE` in your
`backend.local.env` file

### Changing email and password

Expand Down
10 changes: 10 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Release Notes
=============

Version 0.81.4
--------------

- gate learner analytics on its own feature flag (#4018)
- Require B2B data consent on the contract dashboard (#4006)
- Stop the first autosave navigating out of the editor (#4016)
- fix(webhooks): stop leaking the real OCW_WEBHOOK_KEY into logs/Sentry (#3988)
- fix(auth): disable ApisixUserMiddleware by default in local dev/codespaces (#4002)
- fix(b2b): show learner email and icon avatar when name is blank (#3992)

Version 0.81.3
--------------

Expand Down
7 changes: 7 additions & 0 deletions env/backend.env
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ TIKA_SERVER_ENDPOINT=http://tika:9998/
TIKA_CLIENT_ONLY=True

# APISIX/Keycloak settings
# The default local dev profile (COMPOSE_PROFILES=backend,frontend) doesn't
# run APISIX/Keycloak, so nothing is actually verifying the X-Userinfo header
# ApisixUserMiddleware trusts -- a request straight to Django could forge it
# to log in as anyone. Disable the middleware by default; opting into real
# Keycloak/APISIX testing (COMPOSE_PROFILES=...,keycloak,apisix) needs to set
# this back to False. See README-keycloak.md.
DISABLE_APISIX_USER_MIDDLEWARE=True
APISIX_LOGOUT_URL=http://api.open.odl.local:8065/logout/
APISIX_SESSION_SECRET_KEY=supertopsecret1234
KC_SPI_THEME_WELCOME_THEME=scim
Expand Down
5 changes: 5 additions & 0 deletions env/codespaces.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
# Codespaces doesn't run APISIX/Keycloak, and its ports are forwardable
# (network-reachable) by design -- so nothing verifies the X-Userinfo header
# ApisixUserMiddleware would otherwise trust. Disable it here for the same
# reason as env/backend.env.
DISABLE_APISIX_USER_MIDDLEWARE=True
MITOL_SUPPORT_EMAIL=support@localhost
POSTHOG_TIMEOUT_MS=1500
MAILGUN_KEY=test
Expand Down
2 changes: 1 addition & 1 deletion frontends/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
},
"dependencies": {
"@mitodl/mit-learn-api-axios": "2026.8.17",
"@mitodl/mitxonline-api-axios": "2026.9.23",
"@mitodl/mitxonline-api-axios": "2026.9.29",
"@tanstack/react-query": "^5.66.0",
"axios": "^1.12.2",
"tiny-invariant": "^1.3.3"
Expand Down
17 changes: 17 additions & 0 deletions frontends/api/src/mitxonline/hooks/organizations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"
import { b2bApi } from "../../clients"
import {
B2bApiB2bAttachCreateRequest,
B2bApiB2bDataConsentCreateRequest,
B2bApiB2bManagerOrganizationsContractsCodesBulkAssignCreateRequest,
B2bApiB2bManagerOrganizationsContractsCodesReassignUpdateRequest,
B2bApiB2bManagerOrganizationsContractsCodesRemindCreateRequest,
B2bApiB2bManagerOrganizationsContractsCodesRevokeDestroyRequest,
B2bApiB2bManagerOrganizationsContractsCodesSendTestEmailCreateRequest,
} from "@mitodl/mitxonline-api-axios/v2"
import { managerOrganizationQueries, managerOrganizationKeys } from "./queries"
import { mitxUserQueries } from "../user"
import type { MutationHookOptions } from "../../../mutations/mutationMeta"

const useB2BAttachMutation = (
Expand All @@ -28,6 +30,20 @@ const useB2BAttachMutation = (
})
}

const useDataConsentMutation = ({ meta }: MutationHookOptions = {}) => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (opts: B2bApiB2bDataConsentCreateRequest) =>
b2bApi.b2bDataConsentCreate(opts),
// Returned so the mutation stays pending until users/me has the new value.
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: mitxUserQueries.me().queryKey,
}),
meta,
})
}

/**
* Bulk-assign available enrollment codes to a list of email addresses. Codes are
* auto-allocated by the backend (one per record); the response reports which
Expand Down Expand Up @@ -140,6 +156,7 @@ const useSendTestEmail = ({ meta }: MutationHookOptions = {}) =>
export {
managerOrganizationQueries,
useB2BAttachMutation,
useDataConsentMutation,
useBulkAssignSeats,
useReassignCode,
useRemindCode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import { faker } from "@faker-js/faker/locale/en"
import type {
BulkAssignError,
BulkAssignResult,
ContractPage,
ManagerEnrollmentCode,
PaginatedManagerEnrollmentCodeList,
UserContractPage,
} from "@mitodl/mitxonline-api-axios/v2"
import { makePaginatedFactory } from "ol-test-utilities"

const contract = (overrides: Partial<ContractPage> = {}): ContractPage => ({
const contract = (
overrides: Partial<UserContractPage> = {},
): UserContractPage => ({
id: faker.number.int(),
contract_end: faker.date.future().toISOString(),
contract_start: faker.date.past().toISOString(),
Expand All @@ -20,6 +22,7 @@ const contract = (overrides: Partial<ContractPage> = {}): ContractPage => ({
welcome_message: faker.lorem.sentence(),
welcome_message_extra: `<p>${faker.lorem.paragraph()}</p>`,
programs: [],
consented_to_data_sharing: null,
...overrides,
variant_options: overrides.variant_options ?? [],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { faker } from "@faker-js/faker/locale/en"
import { OrganizationPage } from "@mitodl/mitxonline-api-axios/v2"
import { UserOrganizationPage } from "@mitodl/mitxonline-api-axios/v2"
import { mergeOverrides } from "ol-test-utilities"

const organization = (
overrides: Partial<OrganizationPage>,
): OrganizationPage => {
overrides: Partial<UserOrganizationPage>,
): UserOrganizationPage => {
const merged = mergeOverrides(
{
id: faker.number.int(),
Expand Down
2 changes: 2 additions & 0 deletions frontends/api/src/mitxonline/test-utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const programEnrollments = {
const b2b = {
courseEnrollment: (readableId?: string) =>
`${getApiBaseUrl()}/api/v0/b2b/enroll/${readableId}/`,
dataConsent: (contractId: number) =>
`${getApiBaseUrl()}/api/v0/b2b/data_consent/${contractId}/`,
}

const programs = {
Expand Down
2 changes: 1 addition & 1 deletion frontends/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@mitodl/arithmix": "^0.2.5",
"@mitodl/course-search-utils": "^3.8.1",
"@mitodl/hacksnack": "^0.1.2",
"@mitodl/mitxonline-api-axios": "2026.9.23",
"@mitodl/mitxonline-api-axios": "2026.9.29",
"@mitodl/smoot-design": "6.38.0",
"@mui/base": "5.0.0-beta.70",
"@mui/material": "^6.4.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
import { useFeatureFlagEnabled } from "posthog-js/react"
import { allowConsoleErrors } from "ol-test-utilities"
import { ForbiddenError } from "@/common/errors"
import { FeatureFlags } from "@/common/feature_flags"
import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded"
import { contractAnalyticsView } from "@/common/urls"
import ContractLearnersPage from "./ContractLearnersPage"

jest.mock("posthog-js/react", () => ({
Expand Down Expand Up @@ -48,6 +50,23 @@
return { org, contract, orgSlug: org.slug.replace(/^org-/, "") }
}

/**
* A managed org with no analytics org ID, so the page renders its chrome
* without firing any analytics request — enough for the back link.
*/
const setupUnqueryable = () => {
const contract = mitxFactories.contracts.contract()
const org = mitxFactories.organizations.organization({
contracts: [contract],
sso_organization_id: null,
})
setMockResponse.get(
mitxUrls.organization.managerOrganizationsList(),
paginate([org]),
)
return { org, contract, orgSlug: org.slug.replace(/^org-/, "") }
}

/**
* The page fires one list query plus one unfiltered total-count query, used
* for the "X of Y enrollments" summary text. Mocking by exact URL keeps the
Expand Down Expand Up @@ -107,7 +126,7 @@
)
})

test("throws ForbiddenError when the analytics flag is off", () => {
test("throws ForbiddenError when the learner analytics flag is off", () => {
mockedUseFeatureFlagEnabled.mockReturnValue(false)
allowConsoleErrors()

Expand All @@ -118,6 +137,55 @@
).toThrow(ForbiddenError)
})

test("the aggregate analytics flag alone does not open this page", () => {
// The two dashboards roll out independently: aggregate analytics must not
// confer access to per-learner data.
mockedUseFeatureFlagEnabled.mockImplementation(
(flag) => flag === FeatureFlags.B2BAnalyticsDashboard,
)
allowConsoleErrors()

expect(() =>
renderWithProviders(
<ContractLearnersPage orgSlug="acme" contractSlug="c1" />,
),
).toThrow(ForbiddenError)
})

test("the learner flag alone does not open this page", () => {
// Learner analytics is nested inside the analytics rollout: the back link
// and framing assume the aggregate page is reachable.
mockedUseFeatureFlagEnabled.mockImplementation(
(flag) => flag === FeatureFlags.B2BLearnerAnalytics,
)
allowConsoleErrors()

expect(() =>
renderWithProviders(
<ContractLearnersPage orgSlug="acme" contractSlug="c1" />,
),
).toThrow(ForbiddenError)
})

test("opens with both analytics flags on, linking back to aggregate analytics", async () => {
mockedUseFeatureFlagEnabled.mockImplementation(
(flag) =>
flag === FeatureFlags.B2BAnalyticsDashboard ||
flag === FeatureFlags.B2BLearnerAnalytics,
)
const { contract, orgSlug } = setupUnqueryable()

renderWithProviders(
<ContractLearnersPage orgSlug={orgSlug} contractSlug={contract.slug} />,
)

const back = await screen.findByRole("link", { name: /Program analytics/ })
expect(back).toHaveAttribute(
"href",
contractAnalyticsView(orgSlug, contract.slug),
)
})

test("denies access when the org is not one the user manages", async () => {
setMockResponse.get(
mitxUrls.organization.managerOrganizationsList(),
Expand Down Expand Up @@ -212,6 +280,67 @@
expect(within(row).getByText("Certificate")).toBeInTheDocument()
})

test("a learner row shows the email alongside the name", async () => {
const { org, contract, orgSlug } = setup()
const contractId = String(contract.id)
setMockResponse.get(
mitxUrls.organization.managerOrganizationsList(),
paginate([org]),
)
mockTotal(contractId, 1)
mockList(contractId, [
analyticsFactories.learnerProgress({
full_name: "Anton Petrov",
email: "anton@example.com",
courserun_title: "Module 5",
}),
])

renderWithProviders(
<ContractLearnersPage orgSlug={orgSlug} contractSlug={contract.slug} />,
)

const name = await screen.findByText("Anton Petrov")
const row = rowOf(name)
expect(within(row).getByText("anton@example.com")).toBeInTheDocument()
expect(within(row).getByText("AP")).toBeInTheDocument()
})

test.each([
{ fullName: null, label: "null" },
{ fullName: "", label: "empty" },
{ fullName: " ", label: "whitespace-only" },
])(
"a learner with a $label name shows only the email and an icon avatar",
async ({ fullName }) => {
const { org, contract, orgSlug } = setup()
const contractId = String(contract.id)
setMockResponse.get(
mitxUrls.organization.managerOrganizationsList(),
paginate([org]),
)
mockTotal(contractId, 1)
mockList(contractId, [
analyticsFactories.learnerProgress({
full_name: fullName,
email: "x7k2m@example.com",
courserun_title: "Module 5",
}),
])

renderWithProviders(
<ContractLearnersPage orgSlug={orgSlug} contractSlug={contract.slug} />,
)

const email = await screen.findByText("x7k2m@example.com")
const row = rowOf(email)
expect(within(row).getByText("Module 5")).toBeInTheDocument()
expect(within(row).queryByText("?")).not.toBeInTheDocument()
expect(within(row).queryByText("Unknown learner")).not.toBeInTheDocument()
expect(row.querySelector("[aria-hidden='true'] svg")).not.toBeNull()
},
)

test("a learner who withheld consent shows No consent given", async () => {
const { org, contract, orgSlug } = setup()
const contractId = String(contract.id)
Expand Down Expand Up @@ -451,7 +580,7 @@
* type-checked code and re-enable by dropping `.skip` once the block they
* cover is restored.
*/
test.skip("the module dropdown lists every course run on the contract", async () => {

Check warning on line 583 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 583 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 583 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 583 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped
const { org, contract, orgSlug } = setup()
const contractId = String(contract.id)
setMockResponse.get(
Expand Down Expand Up @@ -479,7 +608,7 @@
expect(within(listbox).getByText("Module 6")).toBeInTheDocument()
})

test.skip("selecting a module sends courserun_readable_id", async () => {

Check warning on line 611 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 611 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 611 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 611 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped
const { org, contract, orgSlug } = setup()
const contractId = String(contract.id)
setMockResponse.get(
Expand Down Expand Up @@ -622,7 +751,7 @@
* deleting; re-enable by dropping `.skip` once that block and the matching
* one in LearnerRow.tsx are restored.
*/
test.skip("row checkboxes are individually named for screen readers", async () => {

Check warning on line 754 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 754 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 754 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 754 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped
const { org, contract, orgSlug } = setup()
const contractId = String(contract.id)
setMockResponse.get(
Expand Down Expand Up @@ -651,7 +780,7 @@
})
})

test.skip("bulk reminder is disabled until a row is selected", async () => {

Check warning on line 783 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 783 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 783 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped

Check warning on line 783 in frontends/main/src/app-pages/ContractLearnersPage/ContractLearnersPage.test.tsx

View workflow job for this annotation

GitHub Actions / javascript-tests

Tests should not be skipped
const { org, contract, orgSlug } = setup()
const contractId = String(contract.id)
setMockResponse.get(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,13 @@ const ContractLearnersPageInternal: React.FC<ContractLearnersPageProps> = ({

const ContractLearnersPage: React.FC<ContractLearnersPageProps> = (props) => {
const flagsLoaded = useFeatureFlagsLoaded()
const enabled = useFeatureFlagEnabled(FeatureFlags.B2BAnalyticsDashboard)
const analyticsEnabled = useFeatureFlagEnabled(
FeatureFlags.B2BAnalyticsDashboard,
)
const learnerAnalyticsEnabled = useFeatureFlagEnabled(
FeatureFlags.B2BLearnerAnalytics,
)
const enabled = analyticsEnabled && learnerAnalyticsEnabled

if (!flagsLoaded) {
return (
Expand Down
Loading
Loading