diff --git a/src/components/AccessControlCascade.module.scss b/src/components/AccessControlCascade.module.scss new file mode 100644 index 0000000..9f8a0d6 --- /dev/null +++ b/src/components/AccessControlCascade.module.scss @@ -0,0 +1,235 @@ +@import "~bootstrap/scss/_functions.scss"; +@import "~bootstrap/scss/_variables.scss"; + +$section-a-color: #3b82f6; +$section-b-color: #8b5cf6; +$extended-color: #f59e0b; +$default-color: $gray-700; + +.cascade { + margin: 2rem 0; + display: flex; + flex-direction: column; + gap: 1.75rem; +} + +.sectionTitle { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $gray-600; + margin-bottom: 0.6rem; +} + +.table { + border: 1px solid $gray-200; + border-radius: 12px; + overflow: hidden; + background: white; +} + +.headerRow, +.row { + display: grid; + grid-template-columns: minmax(160px, 1.3fr) repeat(3, 1fr); +} + +.row { + border-top: 1px solid $gray-200; +} + +.headerRow { + background: $gray-100; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $gray-600; +} + +.headerCell { + padding: 0.7rem 1rem; + border-left: 1px solid $gray-200; + + &:first-child { + border-left: none; + } +} + +.layerColumn { + padding: 0.9rem 1rem; + display: flex; + flex-direction: column; + justify-content: center; + border-left: 4px solid var(--layer-color); +} + +.layerName { + font-weight: 600; + font-size: 0.95rem; + color: $gray-900; +} + +.layerDesc { + font-size: 0.78rem; + color: $gray-600; + margin-top: 0.1rem; +} + +.cell { + padding: 0.9rem 1rem; + border-left: 1px solid $gray-200; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; + text-align: center; +} + +.cellSet { + font-weight: 600; + color: var(--layer-color); + background-color: var(--layer-bg); +} + +.cellInherit { + color: $gray-400; + font-style: italic; + font-size: 0.85rem; +} + +.layerDefault { + --layer-color: #{$default-color}; + --layer-bg: #{rgba($default-color, 0.06)}; +} + +.layerSectionA { + --layer-color: #{$section-a-color}; + --layer-bg: #{rgba($section-a-color, 0.08)}; +} + +.layerSectionB { + --layer-color: #{$section-b-color}; + --layer-bg: #{rgba($section-b-color, 0.08)}; +} + +.layerExtended { + --layer-color: #{$extended-color}; + --layer-bg: #{rgba($extended-color, 0.1)}; +} + +.resolvedHeader { + display: flex; + align-items: baseline; + gap: 0.6rem; + margin-bottom: 0.75rem; + flex-wrap: wrap; +} + +.resolvedName { + font-weight: 600; + font-size: 1rem; + color: $gray-900; +} + +.resolvedLabels { + font-size: 0.85rem; + color: $gray-600; +} + +.resolvedCard { + border: 1px solid $gray-200; + border-radius: 12px; + padding: 1rem 1.25rem; + background: white; + + & + & { + margin-top: 0.85rem; + } +} + +.resolvedFields { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +.resolvedField { + border: 1px solid $gray-200; + border-radius: 8px; + padding: 0.65rem 0.85rem; + background: $gray-100; +} + +.resolvedFieldLabel { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $gray-600; + font-weight: 700; +} + +.resolvedFieldValue { + font-size: 1.05rem; + font-weight: 600; + color: $gray-900; + margin: 0.15rem 0 0.45rem; +} + +.resolvedFieldSource { + font-size: 0.7rem; + font-weight: 700; + color: var(--layer-color); + text-transform: uppercase; + letter-spacing: 0.05em; + display: inline-flex; + align-items: center; + gap: 0.35rem; + + &::before { + content: ""; + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--layer-color); + } +} + +@media (max-width: 720px) { + .headerRow { + display: none; + } + + .row { + grid-template-columns: 1fr; + } + + .layerColumn { + grid-column: 1 / -1; + border-left: none; + border-top: 4px solid var(--layer-color); + padding: 0.75rem 1rem; + } + + .cell { + border-left: none; + border-top: 1px solid $gray-200; + justify-content: space-between; + text-align: left; + + &::before { + content: attr(data-label); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: $gray-600; + } + } + + .resolvedFields { + grid-template-columns: 1fr; + } +} diff --git a/src/components/AccessControlCascade.tsx b/src/components/AccessControlCascade.tsx new file mode 100644 index 0000000..0750505 --- /dev/null +++ b/src/components/AccessControlCascade.tsx @@ -0,0 +1,142 @@ +import classNames from "classnames"; + +import styles from "./AccessControlCascade.module.scss"; + +type Field = "release" | "due" | "timeLimit"; + +interface Layer { + name: string; + description: string; + className: string; + values: Partial>; +} + +interface ResolvedExample { + studentName: string; + studentLabels: string; + source: Record; +} + +const FIELDS: { key: Field; label: string }[] = [ + { key: "release", label: "Release date" }, + { key: "due", label: "Due date" }, + { key: "timeLimit", label: "Time limit" }, +]; + +const LAYERS: Layer[] = [ + { + name: "Default", + description: "Applies to everyone", + className: styles.layerDefault, + values: { release: "Mar 2", due: "Mar 13", timeLimit: "60 min" }, + }, + { + name: "Section A", + description: "Label override", + className: styles.layerSectionA, + values: { release: "Mar 4", due: "Mar 15" }, + }, + { + name: "Section B", + description: "Label override", + className: styles.layerSectionB, + values: { due: "Mar 16" }, + }, + { + name: "Extended time", + description: "Accommodation label", + className: styles.layerExtended, + values: { timeLimit: "90 min" }, + }, +]; + +const RESOLVED: ResolvedExample[] = [ + { + studentName: "Alice", + studentLabels: "Section A · Extended time", + source: { release: 1, due: 1, timeLimit: 3 }, + }, + { + studentName: "Bob", + studentLabels: "Section B", + source: { release: 0, due: 2, timeLimit: 0 }, + }, +]; + +export function AccessControlCascade() { + return ( +
+
+
Layered overrides
+
+
+
Layer
+ {FIELDS.map((f) => ( +
+ {f.label} +
+ ))} +
+ {LAYERS.map((layer) => ( +
+
+
{layer.name}
+
{layer.description}
+
+ {FIELDS.map((f) => { + const value = layer.values[f.key]; + return ( +
+ {value ?? "inherits"} +
+ ); + })} +
+ ))} +
+
+ +
+
Effective rules per student
+ {RESOLVED.map((r) => ( +
+
+ {r.studentName} + {r.studentLabels} +
+
+ {FIELDS.map((f) => { + const layer = LAYERS[r.source[f.key]]; + const value = layer.values[f.key]; + return ( +
+
{f.label}
+
{value}
+
+ from {layer.name} +
+
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/CreditTimeline.module.scss b/src/components/CreditTimeline.module.scss new file mode 100644 index 0000000..0073c69 --- /dev/null +++ b/src/components/CreditTimeline.module.scss @@ -0,0 +1,208 @@ +@import "~bootstrap/scss/_functions.scss"; +@import "~bootstrap/scss/_variables.scss"; + +$bonus-color: #10b981; +$full-color: #3b82f6; +$late-color: #f59e0b; +$practice-color: #6b7280; + +.timeline { + margin: 1.5rem 0 2rem; +} + +.sectionTitle { + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: $gray-600; + margin-bottom: 0.75rem; +} + +.chartWrap { + border: 1px solid $gray-200; + border-radius: 12px; + background: white; + padding: 1.25rem 1rem 0.5rem; +} + +.chartInner { + position: relative; + width: 100%; + height: 220px; +} + +.svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; +} + +.gridRow { + position: absolute; + left: 0; + right: 0; + height: 0; + border-top: 1px dashed $gray-200; + pointer-events: none; +} + +.axisLabel { + position: absolute; + left: 0; + top: 0; + transform: translateY(-50%); + font-size: 0.7rem; + font-weight: 600; + color: $gray-500; + background: white; + padding-right: 0.35rem; +} + +.stepLine { + fill: none; + stroke: #1a3a8f; + stroke-width: 2.5; + stroke-linejoin: miter; + stroke-linecap: square; +} + +.milestoneLine { + stroke: $gray-300; + stroke-width: 1; + stroke-dasharray: 2 3; +} + +.milestoneDot { + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + background: white; + border: 2.5px solid #1a3a8f; + transform: translate(-50%, -50%); + pointer-events: none; + box-shadow: 0 0 0 3px white; +} + +.creditBadge { + position: absolute; + transform: translateX(-50%); + font-size: 0.85rem; + font-weight: 700; + padding: 0.15rem 0.55rem; + border-radius: 999px; + background: white; + border: 1.5px solid currentColor; + white-space: nowrap; + pointer-events: none; + line-height: 1.2; + + &.segmentBonus { + color: $bonus-color; + } + &.segmentFull { + color: $full-color; + } + &.segmentLate { + color: $late-color; + } + &.segmentPractice { + color: $practice-color; + } +} + +.milestoneLabels { + position: relative; + height: 3rem; + margin-top: 0.5rem; +} + +.milestoneLabel { + position: absolute; + top: 0; + transform: translateX(-50%); + text-align: center; + font-size: 0.78rem; + white-space: nowrap; +} + +.milestoneName { + font-weight: 600; + color: $gray-900; +} + +.milestoneDate { + font-size: 0.72rem; + color: $gray-600; + margin-top: 0.1rem; +} + +.legend { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + margin-top: 1rem; + padding: 0.75rem 1rem; + border: 1px solid $gray-200; + border-radius: 10px; + background: $gray-100; +} + +.legendItem { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-size: 0.85rem; +} + +.legendSwatch { + display: inline-block; + width: 14px; + height: 14px; + border-radius: 4px; + + &.segmentBonus { + background: $bonus-color; + } + &.segmentFull { + background: $full-color; + } + &.segmentLate { + background: $late-color; + } + &.segmentPractice { + background: $practice-color; + } +} + +.legendLabel { + color: $gray-800; + font-weight: 500; +} + +.legendValue { + color: $gray-600; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 640px) { + .chartInner { + height: 180px; + } + + .milestoneLabel { + font-size: 0.7rem; + } + + .milestoneDate { + font-size: 0.66rem; + } + + .creditBadge { + font-size: 0.72rem; + padding: 0.1rem 0.4rem; + } +} diff --git a/src/components/CreditTimeline.tsx b/src/components/CreditTimeline.tsx new file mode 100644 index 0000000..9ce8a1c --- /dev/null +++ b/src/components/CreditTimeline.tsx @@ -0,0 +1,211 @@ +import styles from "./CreditTimeline.module.scss"; + +interface Milestone { + label: string; + date: string; + position: number; +} + +interface Segment { + credit: number; + label: string; + className: string; + startIndex: number; + endIndex: number; +} + +const MILESTONES: Milestone[] = [ + { label: "Release", date: "Mar 2", position: 0 }, + { label: "Early deadline", date: "Mar 10", position: 44 }, + { label: "Due date", date: "Mar 13", position: 61 }, + { label: "Late deadline", date: "Mar 18", position: 89 }, + { label: "End", date: "Mar 20", position: 100 }, +]; + +const SEGMENTS: Segment[] = [ + { + credit: 110, + label: "Bonus", + className: "segmentBonus", + startIndex: 0, + endIndex: 1, + }, + { + credit: 100, + label: "Full credit", + className: "segmentFull", + startIndex: 1, + endIndex: 2, + }, + { + credit: 80, + label: "Late credit", + className: "segmentLate", + startIndex: 2, + endIndex: 3, + }, + { + credit: 0, + label: "Practice", + className: "segmentPractice", + startIndex: 3, + endIndex: 4, + }, +]; + +const PLOT_LEFT_PCT = 8; +const PLOT_RIGHT_PCT = 96; +const CREDIT_MAX = 120; + +function xPct(position: number) { + return PLOT_LEFT_PCT + (position / 100) * (PLOT_RIGHT_PCT - PLOT_LEFT_PCT); +} + +function yPct(credit: number) { + return (1 - credit / CREDIT_MAX) * 100; +} + +export function CreditTimeline() { + const stepPath = (() => { + let d = ""; + SEGMENTS.forEach((seg, i) => { + const x1 = xPct(MILESTONES[seg.startIndex].position); + const x2 = xPct(MILESTONES[seg.endIndex].position); + const y = yPct(seg.credit); + if (i === 0) d += `M ${x1} ${y} L ${x2} ${y}`; + else d += ` L ${x1} ${y} L ${x2} ${y}`; + }); + return d; + })(); + + const areaPath = (() => { + const baseY = yPct(0); + let d = `M ${xPct(MILESTONES[0].position)} ${baseY}`; + SEGMENTS.forEach((seg) => { + const x1 = xPct(MILESTONES[seg.startIndex].position); + const x2 = xPct(MILESTONES[seg.endIndex].position); + const y = yPct(seg.credit); + d += ` L ${x1} ${y} L ${x2} ${y}`; + }); + d += ` L ${xPct(MILESTONES[MILESTONES.length - 1].position)} ${baseY} Z`; + return d; + })(); + + const gridLines = [0, 50, 100]; + + return ( +
+
Example credit timeline
+ +
+
+ {gridLines.map((g) => ( +
+ {g}% +
+ ))} + + + + {SEGMENTS.map((seg) => { + const startPos = MILESTONES[seg.startIndex].position; + const endPos = MILESTONES[seg.endIndex].position; + const midPct = xPct((startPos + endPos) / 2); + const top = yPct(seg.credit); + return ( +
+ {seg.credit}% +
+ ); + })} + + {MILESTONES.map((m, idx) => { + let dotCredit; + if (idx === 0) dotCredit = SEGMENTS[0].credit; + else if (idx === MILESTONES.length - 1) + dotCredit = SEGMENTS[SEGMENTS.length - 1].credit; + else dotCredit = SEGMENTS[idx - 1].credit; + return ( +
+ ); + })} +
+ +
+ {MILESTONES.map((m) => ( +
+
{m.label}
+
{m.date}
+
+ ))} +
+
+ +
+ {SEGMENTS.map((seg) => ( +
+ + {seg.label} + {seg.credit}% +
+ ))} +
+
+ ); +} diff --git a/src/pages/about/blog/modern-access-control/defaults_editor.png b/src/pages/about/blog/modern-access-control/defaults_editor.png new file mode 100644 index 0000000..2b4ee74 Binary files /dev/null and b/src/pages/about/blog/modern-access-control/defaults_editor.png differ diff --git a/src/pages/about/blog/modern-access-control/index.mdx b/src/pages/about/blog/modern-access-control/index.mdx new file mode 100644 index 0000000..9970cbb --- /dev/null +++ b/src/pages/about/blog/modern-access-control/index.mdx @@ -0,0 +1,139 @@ +import { + BlogMarkdownLayout, + BlogImage, + BlogCalloutBox, +} from "../../../../components/BlogMarkdownLayout"; +import { AccessControlCascade } from "../../../../components/AccessControlCascade"; +import { CreditTimeline } from "../../../../components/CreditTimeline"; + +import overview from "./overview.png"; +import defaultsEditor from "./defaults_editor.png"; +import studentOverride from "./student_override.png"; + +export const meta = { + title: "Modern access control", + date: "2026-05-05", + author: "Peter Stenger", + tags: ["Release"], +}; + +

+ Access control just got + simpler + to manage. +

+ +The new modern access control system is here! Release dates, deadlines, credit, time limits, passwords, PrairieTest integration, and per-student exceptions all live on a single **Access** page on every assessment. JSON editing is still available, but it's no longer required. + + + **Key Takeaways:** + +1. Your existing assessments don't need any changes. Legacy `allowAccess` rules continue to work. +2. The new system is **more powerful**: overrides stack across student labels and individual students, so a student can be in Section A *and* have an accommodation label without you writing a per-student rule. +3. The new system is **easier to understand**: every date has a specific role (release, due, early deadline, late deadline) instead of a pile of overlapping windows. + + + +### One page for everything + +Each assessment now has an **Access** tab with two sections: **Defaults** that apply to everyone, and **Overrides** that apply to selected students or labels. The summary card shows the current access state, the release/due timeline, credit after deadlines, and visibility settings. + + + +### Configure defaults in the UI + +Click **Edit** on the **Defaults** card to open the editor. From here you can configure everything that applies to every student: when the assessment opens, when it's due, how credit changes over time, time limits, passwords, PrairieTest integration, and what students can see before release and after completion. + + + +#### A single credit timeline + +Credit is now one chronological timeline: an optional release date, optional early deadlines for bonus credit, a due date, optional late deadlines for reduced credit, and a configurable choice for what happens after the last deadline (no submissions, practice submissions, or partial credit). + +For example, an assessment might be worth 110% until an early deadline, 100% until the due date, 80% until a late deadline, then allow practice submissions for 0% credit afterwards. + + + +#### Time limits, passwords, and PrairieTest + +Time limits and passwords are now toggles in the same editor. PrairieTest integration is also configured here: enter the exam UUID, choose what students see while their reservation is active, and the top-level settings handle visibility outside the reservation. + +#### Before-release and after-completion visibility + +Decide whether students see the assessment title before release, and what they can see after the assessment is complete. Question and score visibility can be hidden permanently, revealed on a date, or revealed only during a date range. + +### Overrides for labels and individual students + +Overrides customize access for selected students while inheriting anything they don't change from the defaults. You can target overrides at **student labels** (such as "Section A" or "Extended time") managed from the course instance **Students** page, or at **specific enrolled students**. + + + +Common uses include: + +- extending the due date for a section; +- adding extra time for an accommodation label; +- giving one student a password or different access window; +- revealing exam content differently for one group. + +Individual-student overrides are stored in PrairieLearn's database, so one-off accommodations and makeup windows no longer need to be committed to your course content files. + +Each override only stores the fields it changes — everything else falls through to the defaults. Multiple overrides on the same student stack together, so a student in Section A who also has the Extended time label gets Section A's dates and the extended time limit, with no per-student override needed. + + + +### Migrating from `allowAccess` + +Legacy `allowAccess` rules continue to work. When you're ready to migrate, there are two paths: + +1. From an assessment's **Access** tab, click **Migrate to modern format**. PrairieLearn shows the migrated changes and any warnings before you confirm. +2. When **copying a course instance**, PrairieLearn migrates compatible assessment-level rules and reports any caveats before you confirm the copy. + +UID-based legacy rules don't have a direct JSON equivalent, but they map cleanly to overrides for student labels or specific enrolled students after migration. + +### What the new model doesn't allow + +Giving each date a specific role (release, due, early/late deadline) means a few legacy patterns no longer fit. If your assessment uses any of these, the migration tool flags it so you can decide how to handle it: + +- **Non-contiguous access windows.** A legacy assessment that opens Jan 15-Feb 15, closes, then reopens Mar 1-Mar 15 can't be expressed as one credit timeline. In practice, this pattern usually means "release" and "reopen for review" — the new model handles that with after-completion visibility instead. +- **Credit that goes back up.** Legacy rules let credit dip and then climb again (e.g., 100% → 50% → 80%). The new model requires credit to be non-increasing from release through the final deadline, which matches how almost every real grading policy works. +- **Practice before release.** Legacy `allowAccess` could open a practice (0% credit) window before the assessment was available. The new model only allows practice after the last deadline, where it belongs. + +### JSON is still there when you need it + +Most instructors should use the Access page, but the new `accessControl` JSON format is still fully documented for scripted changes, diffs, and programmatic course generation. The first element of the array is the defaults rule; later elements are label-targeted overrides. + + + More detailed information about all these features can be found in the [modern + access control + documentation](https://docs.prairielearn.com/assessment/accessControlModern). + If you have questions, let us know on [Slack](/slack). + + +export default ({ children }) => ( + {children} +); diff --git a/src/pages/about/blog/modern-access-control/overview.png b/src/pages/about/blog/modern-access-control/overview.png new file mode 100644 index 0000000..15b7fcd Binary files /dev/null and b/src/pages/about/blog/modern-access-control/overview.png differ diff --git a/src/pages/about/blog/modern-access-control/student_override.png b/src/pages/about/blog/modern-access-control/student_override.png new file mode 100644 index 0000000..5530084 Binary files /dev/null and b/src/pages/about/blog/modern-access-control/student_override.png differ