V1 Web PWA · Surface walkthrough

Operator · LMS connectors

A per-surface walkthrough of the V1 Web PWA operator surface: layout, states, interactions, data, and cross-references.

walked + e2e-covered
9sections16 minread

On this page

Context. surface operator · domain system · route /operator/lms · auth signed-in · source apps/oshun/web/src/app/operator/lms/page.tsx

Last walked. 2026-06-30 by Codex (managed connector CRUD overlay added and covered through the real admin BFF), against commit HEAD' '2026-06-28 by Codex (OneRoster import diff preview table added and covered through the real admin BFF), against commit HEAD' '2026-06-25 by Codex (generatedAt/count/card/field row-cell anchors added to the live roster and invalid panel), against commit HEAD' '2026-06-25 by Codex (validator disabled/live-region states, proxy gate, and footer tap targets covered), against commit HEAD' '2026-06-22 by Codex (drafted from source), against commit HEAD

Purpose#

Operator view of the tenant LMS connector roster (3.3). It reads GET /v1/admin/lms/connectors (seeded from OSHUN_LMS_CONNECTORS at boot plus admin-managed descriptors), surfaces a prominent invalid-connector panel listing every descriptor skipped at boot with its exact validation issues (ending the silent-skip era), renders the live connector roster (provider, status, source, environment, LTI 1.3 / SCORM), offers a managed connector panel that can create, override, delete, and restore runtime descriptors through the admin BFF, offers a OneRoster import diff form that POSTs a pasted candidate roster payload to POST /v1/admin/lms/oneroster/preview and renders a row-level no-mutation diff table, and offers a dry-run validate form that POSTs a pasted candidate descriptor to POST /v1/admin/lms/connectors/validate without persisting.

Entry points#

  • Linked from /operator/sso via the sibling SSO ghost button in the foot row (/operator/lms links onward to /operator/sso; the back-link is reciprocal across the operator hub).
  • Foot-row links from this page navigate onward — ← Operator hub (/operator), SSO (/operator/sso), and primary Tenants (/operator/tenants).
  • Direct URL (bookmarkable) — metadata.alternates.canonical = '/operator/lms'. Session-gated only via proxy.ts (the path is not in PUBLIC_PATHS / PUBLIC_PREFIXES); the BFF GET itself is admin-gated, so a non-admin session degrades to the empty state via fail-soft bffGet.
  • Not crawlable — metadata.robots = { index: false, follow: false }.

Layout regions#

Async server component (OperatorLmsPage) that awaits bffGet('/v1/admin/lms/connectors') (falls back to { generatedAt: now, connectors: [], invalid: [] }), rendered inside <LWebShell top={<LCustomerNav active="library" />}> with a column at maxWidth: 1080 and padding: '32px 56px 80px' on a [data-responsive-page] wrapper. The wrapper also carries data-operator-lms-page="" and data-operator-lms-generated-at="{iso}" so the route fetch envelope is browser-verifiable.

  • Header: shell header — LWebShell with LCustomerNav active="library" (top slot).
  • Masthead: LMastheadleft="Operator · LMS", right="{N} connector{s}" (pluralized off connectors.length), kicker="Tenant integrations", title = "The LMS roster." (the word "LMS" is italicized via an inner <em>). Its wrapper carries data-operator-lms-masthead="", data-operator-lms-connector-count="{connectors.length}", and data-operator-lms-invalid-count="{invalid.length}".
  • Invalid-connector panel — rendered FIRST and prominently, only when invalid.length > 0. Carries data-lms-invalid-panel="{count}", an data-operator-lms-invalid-panel="", and data-operator-lms-invalid-count="{invalid.length}" on an L.alert-bordered card on L.paper. It shows an uppercase mono heading "{N} connector{s} skipped at boot — fix and redeploy", then one block per invalid descriptor with data-operator-lms-invalid-card="", data-operator-lms-invalid-connector-id, data-operator-lms-invalid-tenant-id (none when absent), and data-operator-lms-invalid-issue-count. Individual issues carry data-operator-lms-invalid-issue="", data-operator-lms-invalid-issue-code, data-operator-lms-invalid-issue-path, and data-operator-lms-invalid-issue-severity, while visible text remains [{severity}] {path}: {message} ({code}).
  • Connector roster (main) — a display: grid of cards (marginTop: 36). The wrapper carries data-operator-lms-roster="" and data-operator-lms-roster-count="{connectors.length}". Each card carries data-operator-lms-card="", connector/tenant/provider/status/environment attributes, data-operator-lms-source="{env|managed}", capability count, LTI 1.3 presence/deployment count, and SCORM presence. The visible fields are also named: data-operator-lms-field="tenant-eyebrow" for "Tenant · {tenantId} · {connectorId}", "display-name" for the serif name, "provider-status" for "{providerKey} · {status} · {environment}", "source" for "env descriptor" or "managed override", "lti13" for the optional issuer / client / deployment line, and "capabilities" for "{cap · cap}" or "no capabilities declared" plus optional " · SCORM". Card border remains L.accent when status === 'active', else L.rule.
  • Empty state — when connectors.length === 0: a dashed-border card reading "No LMS connectors configured. Set OSHUN_LMS_CONNECTORS or create a managed descriptor below to onboard a tenant platform." and carrying data-operator-lms-empty="".
  • Managed connector CRUD<LmsConnectorCrudPanel /> client island (below the connector roster). It carries data-lms-crud-panel="", data-lms-crud-state="{idle|saving|deleting|success|error}", and data-lms-crud-connector-count="{connectors.length}"; shows a managed/env source count via data-lms-crud-source-summary; accepts descriptor JSON, saves through POST /v1/admin/lms/connectors, deletes through DELETE /v1/admin/lms/connectors/:tenantId/:connectorId, and refreshes the server roster after a successful mutation.
  • OneRoster import diff<OneRosterDiffPreviewForm /> client island (below managed connector CRUD). It carries data-oneroster-diff-preview-form="", accepts normalized OneRoster preview JSON, POSTs the parsed object to /v1/admin/lms/oneroster/preview, and renders a row-level table with stable data-oneroster-diff-* anchors for user creates, user updates, enrollment creates, and blocked conflicts.
  • Dry-run validate form<LmsConnectorValidateForm /> client island (below the OneRoster diff preview).
  • Footer action row (marginTop: 36, flex-wrap): ghost ← Operator hub/operator, ghost SSO/operator/sso, primary Tenants/operator/tenants; wrapper carries data-operator-lms-footer="".

States#

  • Loading — N/A in-page beyond RSC: the page is an async server component (await bffGet(...)); HTML streams only after the fetch resolves. No client skeleton/spinner exists in this file. (The validate form has its own in-button "Validating…" busy label — see Interactions.)
  • Emptyconnectors.length === 0 → dashed-border card "No LMS connectors configured. Set OSHUN_LMS_CONNECTORS or create a managed descriptor below to onboard a tenant platform." Reachable when the env JSON is unset and no managed descriptor exists (or a non-admin session degrades the read to the fallback { connectors: [], invalid: [] }).
  • Populated (short) — 1–5 connectors → roster cards as described; the masthead right shows "{N} connectors".
  • Populated (long) — 50+ connectors → plain CSS-grid stacking, no pagination/virtualization in source; all rows render. The invalid panel likewise lists every skipped descriptor with no truncation.
  • Error (recoverable) — N/A for the server read: bffGet returns null on any network failure / non-2xx, which the page coerces to the empty state (no retry affordance). For the validate form, a transient failure shows "Could not validate the descriptor — the service is unavailable." and the operator can press Validate again (the textarea content is preserved). The CRUD panel shows "Could not save the connector descriptor - the service is unavailable." or "Could not delete the connector - the service is unavailable." and preserves the operator-entered JSON/ids. For the OneRoster preview form, a transient failure shows "Could not preview the OneRoster diff - the service is unavailable." and preserves the textarea. No automatic retry.
  • Error (unrecoverable) — N/A: there is no 500/403 error screen in this page. A 403 on the GET silently degrades to the empty state (no information leak, but also no "you lack access" copy). A 403 on the validate POST surfaces inline copy "Validating a connector requires admin:* or tenant-console operator scope." A 403 on CRUD save/delete surfaces inline copy "Managing LMS connectors requires tenant-console admin scope." A 403 on the OneRoster preview POST surfaces inline copy "Previewing OneRoster diffs requires admin:* or tenant-console operator scope." Navigation away always works (foot-row links are plain LBtn href).
  • Offline — N/A handled by this page: there is no offline banner or SW-write queue in source. The server render won't reach the client while offline; the client CRUD/validate POST would reject and fall through to the generic "service is unavailable" copy; the OneRoster preview POST behaves the same way.
  • Gated — the route requires a session (proxy.ts 307→/welcome when unauthenticated). A signed-in non-admin is NOT hard-gated by the page — the admin-scoped BFF read fails-soft to the empty state, so the operator sees the empty card rather than a gate/permission notice. Only the validate and CRUD POST/DELETE paths expose the scope requirement with inline 403 copy.
  • Standalone PWA — N/A specific handling: the page renders in the shared LWebShell chrome; no PWA-specific affordance or safe-area code exists in this file.

Interactions#

Managed connector CRUD (LmsConnectorCrudPanel, client island)#

Source: apps/oshun/web/src/components/lilith/LmsConnectorCrudPanel.tsx. Wrapper carries data-lms-crud-panel=""; eyebrow reads "Manage connector descriptors".

  • Managed connector descriptor JSON (textarea)
    • Function: holds a full LmsConnectorDescriptor JSON object. onChange updates local descriptorJson state. The placeholder shows a Canvas sandbox example.
    • Keyboard: standard textarea editing; full-width with a stable 10-row height.
    • Screen reader: announces via aria-label="Managed connector descriptor JSON".
    • Touch target: full-width textarea (width: 100%, padding: 10), exceeds 44×44.
    • Disabled when: never disabled.
    • Offline behavior: editing is local-only; no network until Save connector.
    • Telemetry: none in source. Test hook: data-testid="lms-crud-input".
  • Save connector (button, button[data-lms-crud-save])
    • Function: parses the textarea as JSON; parse errors show "The connector descriptor is not valid JSON." and make no request. Valid JSON POSTs the parsed object to /v1/admin/lms/connectors; successful responses set the delete fields from the saved connector, show "Connector {state}. Roster refreshed.", and call router.refresh().
    • Keyboard: activated by Enter/Space when focused.
    • Screen reader: announces "Save connector" or "Saving"; carries aria-disabled and aria-busy during pending work.
    • Touch target: native button with minHeight: 44.
    • Disabled when: a save/delete is in flight or the descriptor textarea is empty.
    • Offline behavior: the POST rejects → generic "Could not save the connector descriptor - the service is unavailable." inline error.
    • Telemetry: none in source.
  • Delete tenant / connector fields (inputs)
    • Function: hold the {tenantId, connectorId} used by the delete route. They initialize from the first live connector and update to the connector returned by a successful save.
    • Keyboard: standard text input editing.
    • Screen reader: visible labels are "Tenant" and "Connector"; test hooks are data-testid="lms-crud-delete-tenant" and data-testid="lms-crud-delete-connector".
    • Touch target: minHeight: 44, minWidth: 180.
  • Delete (button, button[data-lms-crud-delete])
    • Function: DELETEs /v1/admin/lms/connectors/:tenantId/:connectorId. Deleting a managed connector removes it; deleting a managed override restores the env descriptor with the same key; deleting an env-only connector tombstones it for the current runtime. Success shows "Connector {state}. Roster refreshed." and calls router.refresh().
    • Keyboard: activated by Enter/Space when focused.
    • Screen reader: announces "Delete" or "Deleting"; carries aria-disabled and aria-busy during pending work.
    • Touch target: native button with minHeight: 44.
    • Disabled when: a save/delete is in flight or either delete field is empty.
    • Offline behavior: the DELETE rejects → generic "Could not delete the connector - the service is unavailable." inline error.
    • Telemetry: none in source.
  • CRUD status (text, conditional)
    • Function: shows pending, success, or error copy with data-lms-crud-status="{saving|deleting|success|error}". 400 maps to "Tenant, connector, and provider fields are required.", 403 maps to "Managing LMS connectors requires tenant-console admin scope.", 404 maps to "That connector is not registered for this tenant.", and 422 maps to "The descriptor is not valid and was not saved."
    • Screen reader: role="status" for non-error states, role="alert" for error, with aria-live="polite" and aria-atomic="true".

OneRoster import diff preview (OneRosterDiffPreviewForm, client island)#

Source: apps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.tsx. Wrapper carries data-oneroster-diff-preview-form=""; eyebrow reads "OneRoster import diff".

  • OneRoster preview JSON (textarea)
    • Function: holds { tenantId, incomingUsers, incomingEnrollments } JSON. onChange updates local payloadJson state. The placeholder shows a normalized user/enrollment example.
    • Keyboard: standard textarea editing; full-width with a stable 9-row height.
    • Screen reader: announces via aria-label="OneRoster preview JSON".
    • Touch target: full-width textarea (width: 100%, padding: 10), exceeds 44×44.
    • Disabled when: never disabled.
    • Offline behavior: editing is local-only; no network until Preview diff.
    • Telemetry: none in source. Test hook: data-testid="oneroster-diff-input".
  • Preview diff (button, button[data-oneroster-diff-submit])
    • Function: on click, parses the textarea as JSON; parse errors show "The OneRoster preview payload is not valid JSON." and make NO request. Valid JSON POSTs the parsed object to /v1/admin/lms/oneroster/preview, which diffs against the committed tenant roster without mutation.
    • Keyboard: activated by Enter/Space when focused.
    • Screen reader: announces "Preview diff" or "Previewing..." while busy; carries aria-disabled and aria-busy during pending work.
    • Touch target: native button with minHeight: 44; covered by the route spec.
    • Disabled when: busy is true OR payloadJson.trim() is empty — native disabled and aria-disabled are set.
    • Offline behavior: the POST rejects → generic "Could not preview the OneRoster diff - the service is unavailable." inline error.
    • Telemetry: none in source.
  • Diff error notice (text, conditional)
    • Function: shows the current error string in L.alert via LEyebrow. Possible strings: invalid JSON, missing required preview fields (400), missing admin scope (403), and unavailable service (other/network).
    • Screen reader: role="alert" with aria-atomic="true".
    • Test hook: data-testid="oneroster-diff-error".
  • Diff result table (region/table, conditional)
    • Function: renders { preview } from the real BFF. The panel carries data-testid="oneroster-diff-result", data-oneroster-diff-can-apply="{true|false}", and data-oneroster-diff-row-count="{rows.length}". The summary carries created/updated/enrollment/conflict counts. The table exposes Entity, Action, Source, Before, and After columns. Each row carries data-oneroster-diff-row="", data-oneroster-diff-row-kind, data-oneroster-diff-row-action, and data-oneroster-diff-row-sourced-id.
    • Clean preview rows render create / update user changes and enrollment creates; blocked previews render conflict rows with action block.
    • Screen reader: result wrapper uses role="status", aria-live="polite", and aria-atomic="true".

Dry-run validate form (LmsConnectorValidateForm, client island)#

Source: apps/oshun/web/src/components/lilith/LmsConnectorValidateForm.tsx. Wrapper carries data-lms-validate-form=""; eyebrow reads "Dry-run validate a connector descriptor".

  • Connector descriptor JSON (textarea)
    • Function: holds the candidate descriptor JSON the operator pastes; onChange updates local descriptorJson state. rows={8}, vertically resizable. Shows a multi-line placeholder example (canvas-prod / pacific-seminary / canvas / "Canvas · Pacific Seminary" / production).
    • Keyboard: standard textarea editing; first focusable control in the island.
    • Screen reader: announces via aria-label="Connector descriptor JSON".
    • Touch target: full-width textarea (width: 100%, padding: 10), exceeds 44×44.
    • Mobile (≤ 640 px): full-width; no layout delta in source.
    • Disabled when: never disabled.
    • Offline behavior: editing is local-only; no network until Validate.
    • Telemetry: none in source. Test hook: data-testid="lms-validate-input".
  • Validate (button, button[data-lms-validate-submit])
    • Function: on click, parses the textarea as JSON; on a parse error sets the inline error "The descriptor is not valid JSON." and makes NO request. Otherwise POSTs the parsed body to /v1/admin/lms/connectors/validate via api.post and renders the { valid, issues[] } result.
    • Keyboard: activated by Enter/Space when focused (standard button).
    • Screen reader: announces its visible label — "Validate", or "Validating…" while busy; carries aria-disabled and aria-busy during pending work.
    • Touch target: native button with minHeight: 44; covered by the route spec.
    • Mobile (≤ 640 px): no delta in source.
    • Disabled when: busy is true OR descriptorJson.trim() is empty — native disabled and aria-disabled are set. Label flips to "Validating…" while a request is in flight.
    • Offline behavior: the POST rejects → generic "Could not validate the descriptor — the service is unavailable." inline error.
    • Telemetry: none in source.
  • Validation error notice (text, conditional)
    • Function: shows the current error string in L.alert via LEyebrow. Possible strings: "The descriptor is not valid JSON." (client parse), "Validating a connector requires admin:* or tenant-console operator scope." (403), "The descriptor is missing required fields (connectorId, tenantId, providerKey)." (400), "Could not validate the descriptor — the service is unavailable." (other/network). Test hook: data-testid="lms-validate-error".
    • Screen reader: role="alert" with aria-atomic="true".
  • Validation result panel (region, conditional)
    • Function: shows the structured verdict when result is set. Carries data-testid="lms-validate-result" and data-lms-validate-valid="{true| false}"; border L.accent when valid, else L.alert. Heading is "Descriptor is valid ✓" or "Descriptor has {N} issue{s}". When issues exist, a <ul> lists each as [{severity}] {path}: {message} ({code}).
    • Screen reader: role="status" with aria-live="polite" and aria-atomic="true".
  • ← Operator hub (link, LBtn kind="ghost" size="md" href="/operator")
    • Function: navigates to /operator.
    • Keyboard: focusable anchor; Enter activates.
    • Screen reader: announces "← Operator hub".
    • Touch target: minHeight: 44; covered by the route spec.
    • Disabled when: never.
    • Telemetry: none in source.
  • SSO (link, LBtn kind="ghost" size="md" href="/operator/sso")
    • Function: navigates to /operator/sso.
    • Screen reader: announces "SSO".
    • Telemetry: none in source.
  • Tenants (link, LBtn kind="primary" size="md" href="/operator/tenants")
    • Function: navigates to /operator/tenants.
    • Screen reader: announces "Tenants".
    • Telemetry: none in source.

Non-interactive elements (documented for completeness)#

  • Invalid-connector panel — presentational; no controls. Per-issue text is rendered verbatim from the BFF ([{severity}] {path}: {message} ({code})).
  • Connector roster cards — presentational <div>s; not links, no buttons.
  • Masthead — presentational (left/right/kicker/title).

Data & contracts#

  • Reads: GET /v1/admin/lms/connectors via bffGet (server) → { generatedAt?, connectors: ConnectorSummary[], invalid: InvalidConnector[] }. ConnectorSummary = { connectorId, tenantId, source?: 'env' | 'managed', providerKey, displayName, status, environment, capabilities[], lti13: { issuer, clientId, deploymentIds[] } | null, hasScorm }. InvalidConnector = { connectorId, tenantId | null, issues: { code, path, severity, message }[] }. The summary omits secrets.
  • Managed connector writes: POST /v1/admin/lms/connectors via api.post (client island) validates and registers a descriptor in the admin-managed runtime store; DELETE /v1/admin/lms/connectors/:tenantId/:connectorId removes a managed descriptor, restores an env descriptor hidden by a managed override, or tombstones an env-only descriptor for the current runtime. Responses return { generatedAt, state, connector }, where state includes created, updated, overrode_env, deleted, env_disabled, or env_restored.
  • Dry-run validator write: POST /v1/admin/lms/connectors/validate via api.post (client island) — dry-run only, no persistence; accepts the parsed descriptor object, returns { valid: boolean, issues: ValidationIssue[] }.
  • OneRoster preview write: POST /v1/admin/lms/oneroster/preview via api.post (client island) — dry-run only, no persistence; accepts { tenantId, incomingUsers, incomingEnrollments }, diffs through the committed OneRoster projection, and returns { ok: true, preview: { canApply, conflicts, summary, rows[] } }. rows[] are user create/update, enrollment create, and conflict-block rows.
  • Realtime: none.
  • Caching: server fetch through bffGetcache: 'no-store' when an access token is forwarded (authenticated), else next.revalidate (default 60 s) for anonymous. bffGet returns null on timeout (5 s) / non-2xx / network error → page renders the empty state.
  • Auth/role check: route session-gate via proxy.ts (path absent from PUBLIC_PATHS/PUBLIC_PREFIXES → protected branch → 307 to /welcome when unauthenticated). BFF endpoints require admin:* or admin:workspace:tenant-console (generic admin:studio is rejected); a wrong-scope token returns 403 admin_scope_missing, a missing-bearer 401, a missing-ids body 400 invalid_request.

E2E coverage#

The Playwright route specs are the authoritative real-infra proof for this walkthrough: they run against the dev Next/BFF stack, verify the operator admin cookie is forwarded as the BFF bearer, bridge browser CRUD / OneRoster / validate requests back to the live BFF, prove malformed client payloads do not POST, and exercise BFF 401/403/400/422/503 contracts and fail-closed launch behavior. Page and component Vitest coverage backs the same UI contracts at narrower layers.

  • apps/oshun/web/e2e/operator-lms.spec.ts — page DOM coverage: generatedAt/count/card/field anchors; seeded roster + invalid panel via admin access cookie; OneRoster preview table seeded through real /v1/admin/lms/oneroster/apply and driven through browser /v1/admin/lms/oneroster/preview with no roster mutation; wrong-scope fail-soft empty state; malformed JSON with no POST; real-BFF 400/invalid/valid validate legs; browser managed connector save/delete through the real BFF bridge; native disabled/busy button semantics; alert/status feedback; footer destinations/tap targets; and 503 failure copy.
  • apps/oshun/web/e2e/lms-connectors.spec.ts — BFF/admin connector coverage: seeded valid connector, retained invalid descriptor, auth gates, dry-run invalid/valid verdicts, managed create/delete, managed override over env, delete-to-restore-env, and tenant-facing LTI login → launch fail-closed when the seeded platform JWKS is unavailable.
  • apps/oshun/web/src/app/operator/lms/__tests__/page.test.tsx — server-rendered page structure and empty/fallback rendering.
  • apps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.test.tsx and apps/oshun/web/src/components/lilith/LmsConnectorValidateForm.test.tsx — client island guardrails for preview and dry-run validation behavior.

Cross-references#

  • Source: apps/oshun/web/src/app/operator/lms/page.tsx
  • Component sources:
    • apps/oshun/web/src/components/lilith/LmsConnectorCrudPanel.tsx
    • apps/oshun/web/src/components/lilith/LmsConnectorValidateForm.tsx
    • apps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.tsx
    • apps/oshun/web/src/design-system/lilith (LWebShell, LCustomerNav, LMasthead, LBtn, LEyebrow, LV/L, SERIF_L, MONO_L)
    • apps/oshun/web/src/lib/server/bff-fetch.ts (bffGet)
    • apps/oshun/web/src/lib/api-client.ts (api, ApiRequestError)
  • Sibling routes: operator-sso.md, operator-tenants.md, operator-overview.md
  • Unit: apps/oshun/web/src/app/operator/lms/__tests__/page.test.tsx (present at this commit), apps/oshun/web/src/components/lilith/OneRosterDiffPreviewForm.test.tsx

Known downstream boundaries#

  • No "you lack access" copy for a signed-in non-admin — a 403 GET degrades to the generic empty card ("No LMS connectors configured…"), which can mislead an under-scoped operator into thinking nothing is configured rather than that they lack admin:* / admin:workspace:tenant-console.
  • Managed connector storage is not durable yet — create/override/delete is available in the running BFF, but production persistence, audit-event taxonomy, and external LMS-side registration ownership remain future hardening.