From e91fc8ae02c762ae8cf26f3ec2e5d96b341df35f Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sun, 9 Aug 2026 13:32:06 -0500 Subject: [PATCH 1/4] feat: honor per-activity CFP reopen in the submission edit gate An admin can reopen submission for a single presentation for a time-boxed window. The portal decides editability on its own, so canEdit() has to honor that override and stop honoring it the moment the window lapses. canEdit() computed closed-ness once in the constructor, so a page opened before a deadline kept rendering the edit form past it. It is now computed per call, takes the server-synced clock, and mirrors the API's isSubmissionReopened() on all three of its invariants: the plan is enabled, its submission window has actually ended, and the grant is still live. The last two matter because "not open" also covers "has not started yet", and because a plan disabled after a grant does reach client state: navigating refetches the plan by id, and that endpoint applies no enabled filter, unlike the /me feed the client discovers plans through. The clock those comparisons read was seeded with Date.now(), milliseconds, where uicore's Clock, the time service and the API's datetime_epoch fields are all epoch seconds, so an active reopen window read as long expired. It now seeds epoch seconds, matching event-site, track-chairs and fnmeeting. Seeding was not sufficient on its own: clockState was persisted, and redux-persist's blacklist only filters outbound writes while autoMergeLevel2 merges every stored key back in, so a returning user rehydrated the old millisecond value onto the very render that decides whether to redirect to the preview page. That redirect is one way and preview offers only Done, so handling REHYDRATE in the reducer is what keeps a speaker off a dead end. The model also captured its selection plan at construction while the layout only ever handed it a fresh presentation payload, so it is now given a fresh plan too. Adds a banner naming the deadline while a grant is live, formatted and phrased like the submission deadline the app already renders. Known limitation: if the time service accepts a request and never answers, uicore's Clock never ticks and nowUtc stays frozen at its seed, so a window does not lapse client side. The server still refuses late writes. Co-Authored-By: Claude --- src/components/presentations-table.js | 2 +- src/i18n/en.json | 1 + src/layouts/presentation-layout.js | 8 ++++-- src/model/presentation.js | 41 +++++++++++++++++++++++---- src/pages/edit-presentation-page.js | 15 ++++++++-- src/reducers/clock-reducer.js | 12 +++++++- src/store.js | 3 ++ 7 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/components/presentations-table.js b/src/components/presentations-table.js index cd8dc21..67cc2ac 100644 --- a/src/components/presentations-table.js +++ b/src/components/presentations-table.js @@ -34,7 +34,7 @@ const PresentationsTable = ({ const handleEditPresentation = (ev, presentation) => { ev.preventDefault(); - history.push(presentation.getProgressLink()); + history.push(presentation.getProgressLink(nowUtc)); }; const handleReviewPresentation = (ev, presentation) => { diff --git a/src/i18n/en.json b/src/i18n/en.json index 516404b..bc37772 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -185,6 +185,7 @@ "review_subtitle": "Your {presentation} is submitted and awaiting review by Track Chairs", "permission_denied": "Permission Denied", "no_edit": "You are not allowed to edit this presentation", + "submission_reopened": "Submission reopened until {end_date} {when}. Finish your changes before it closes.", "chair_comments": "Chair Comments", "presentation_material": "{presentation} Material", "role": "Role", diff --git a/src/layouts/presentation-layout.js b/src/layouts/presentation-layout.js index 5973b40..4953983 100644 --- a/src/layouts/presentation-layout.js +++ b/src/layouts/presentation-layout.js @@ -51,16 +51,17 @@ class PresentationLayout extends React.Component { this.props.getPresentation(newId); } + this.presentation.updateSelectionPlan(newProps.selectionPlan); this.presentation.updatePresentation(newProps.entity, newProps.track); } render(){ - let { match, entity, speaker, history, loading, location, selectionPlan, selectionPlansSettings } = this.props; + let { match, entity, speaker, history, loading, location, selectionPlan, selectionPlansSettings, nowUtc } = this.props; let isNew = !match.params.presentation_id; if (loading || (!isNew && !entity.id)) return null; - if (!isNew && match.params.presentation_id == entity.id && !this.presentation.canEdit() && !location.pathname.endsWith('preview') ) { + if (!isNew && match.params.presentation_id == entity.id && !this.presentation.canEdit(nowUtc) && !location.pathname.endsWith('preview') ) { return(); } @@ -87,13 +88,14 @@ class PresentationLayout extends React.Component { } -const mapStateToProps = ({ baseState, presentationState }) => ({ +const mapStateToProps = ({ baseState, presentationState, clockState }) => ({ speaker: baseState.speaker, summit: baseState.summit, loading: baseState.loading, tagGroups: baseState.tagGroups, selectionPlansSettings: baseState.selectionPlansSettings, loggedSpeaker: baseState.speaker, + nowUtc: clockState.nowUtc, ...presentationState }) diff --git a/src/model/presentation.js b/src/model/presentation.js index eae416d..592f2e1 100644 --- a/src/model/presentation.js +++ b/src/model/presentation.js @@ -12,7 +12,7 @@ **/ import T from 'i18n-react/dist/i18n-react'; -import {formatEpoch, nowBetween} from "../utils/methods"; +import {formatEpoch, nowAfter, nowBetween} from "../utils/methods"; const SelectionStatus_Accepted = 'accepted'; const SelectionStatus_Alternate = 'alternate'; @@ -39,7 +39,6 @@ class Presentation { this._presentation.selectionPlan = summit.selection_plans.find(sp => sp.id === presentation.selection_plan_id); this._tagGroups = tagGroups; this._track = null; - this._submissionIsClosed = selectionPlan ? !nowBetween(selectionPlan.submission_begin_date, selectionPlan.submission_end_date) : true; this._steps = [ {name: 'NEW', lcName: 'new', step: 0}, @@ -77,6 +76,12 @@ class Presentation { this._presentation.progressNum = currentStep.step; } + // the plan is captured at construction, but navigating refetches it by id and swaps the copy + // held in redux, so a long-lived instance has to be told or canEdit keeps gating on the old one + updateSelectionPlan(selectionPlan) { + this._selectionPlan = selectionPlan; + } + /** * @param nowUtc * @returns {React.ReactNode} @@ -146,8 +151,32 @@ class Presentation { return (this._presentation.is_published || this._presentation.status === 'Received'); } - canEdit() { - if (!this._selectionPlan || this._submissionIsClosed) return false; + /** + * @param nowUtc epoch seconds, server-synced via the Clock + * @returns {boolean} + */ + canEdit(nowUtc) { + if (!this._selectionPlan) return false; + // the API refuses writes on a disabled plan, and a disabled one does reach client state: + // selection-plan-layout refetches the plan by id on navigation, that endpoint applies no + // enabled filter unlike the /me feed, and base-reducer swaps the filtered copy for it. + // Only an explicit false blocks, so a payload without the field still edits normally. + if (this._selectionPlan.is_enabled === false) return false; + + // computed per call, not snapshotted in the constructor, so a window that ends while the + // page is open locks the form without a reload + const submissionIsClosed = !nowBetween(this._selectionPlan.submission_begin_date, this._selectionPlan.submission_end_date); + // ungranted arrives as null on the list feeds and '' on the detail feed, which coerces + // every null to empty string; !! just renders both false + const reopenedUntil = this._presentation.submission_reopened_until; + // a grant only counts once the window has actually ENDED, not merely whenever it is not + // open: submissionIsClosed is also true before the window starts, and honoring a grant + // there would admit edits the API refuses, since isSubmissionReopened() requires + // now > submission_end_date + const submissionEnded = nowAfter(this._selectionPlan.submission_end_date); + const reopened = !!reopenedUntil && submissionEnded && nowUtc < reopenedUntil; + + if (submissionIsClosed && !reopened) return false; let speakers = this._presentation.speakers.map(s => { if (typeof s == 'object') return s.id; @@ -172,9 +201,9 @@ class Presentation { return (!this._presentation.is_published && belongsToSP); } - getProgressLink() { + getProgressLink(nowUtc) { - if (this.canEdit()) { + if (this.canEdit(nowUtc)) { let step = 'summary'; diff --git a/src/pages/edit-presentation-page.js b/src/pages/edit-presentation-page.js index cfaf1b0..baff473 100644 --- a/src/pages/edit-presentation-page.js +++ b/src/pages/edit-presentation-page.js @@ -15,6 +15,8 @@ import React, {useContext, useEffect, useState} from 'react'; import {connect} from 'react-redux'; import T from 'i18n-react/dist/i18n-react'; import Swal from "sweetalert2"; +import moment from "moment-timezone"; +import {formatEpoch} from "openstack-uicore-foundation/lib/utils/methods"; import { savePresentation, completePresentation, @@ -42,7 +44,7 @@ import {getMarketingValue} from "../components/marketing-setting"; import '../styles/edit-presentation-page.less'; import {SelectionPlanContext} from "../components/SelectionPlanContext"; -const EditPresentationPage = ({entity, track, presentation, selectionPlan, summit, match, selectionPlansSettings, showInfoPopup, setShowInfoPopup, ...props}) => { +const EditPresentationPage = ({entity, track, presentation, selectionPlan, summit, match, selectionPlansSettings, showInfoPopup, setShowInfoPopup, nowUtc, ...props}) => { const {setSelectionPlanCtx} = useContext(SelectionPlanContext); const [selectionPlanSettings, setSelectionPlanSettings] = useState(null); @@ -107,6 +109,14 @@ const EditPresentationPage = ({entity, track, presentation, selectionPlan, summi

{title} {`${selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation")}`}

+ {entity.submission_reopened_until > nowUtc && +
+ {T.translate("edit_presentation.submission_reopened", { + end_date: formatEpoch(entity.submission_reopened_until, "MMMM DD, YYYY h:mm a"), + when: moment.tz.guess(), + })} +
+ } {step === 'summary' && @@ -192,12 +202,13 @@ const EditPresentationPage = ({entity, track, presentation, selectionPlan, summi ); } -const mapStateToProps = ({baseState, presentationState}) => ({ +const mapStateToProps = ({baseState, presentationState, clockState}) => ({ summit: baseState.summit, tagGroups: baseState.tagGroups, loading: baseState.loading, loggedSpeaker: baseState.speaker, selectionPlansSettings: baseState.selectionPlansSettings, + nowUtc: clockState.nowUtc, ...presentationState }) diff --git a/src/reducers/clock-reducer.js b/src/reducers/clock-reducer.js index 30dd27b..9cfce91 100644 --- a/src/reducers/clock-reducer.js +++ b/src/reducers/clock-reducer.js @@ -10,11 +10,14 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ +import moment from 'moment-timezone'; +import { REHYDRATE } from 'redux-persist'; import { LOGOUT_USER } from 'openstack-uicore-foundation/lib/security/actions'; import { UPDATE_CLOCK, } from '../actions/clock-actions'; -const localNowUtc = Date.now(); +// epoch SECONDS, matching the Clock ticks that replace it and every consumer of nowUtc +const localNowUtc = moment().unix(); const DEFAULT_STATE = { nowUtc: localNowUtc, @@ -26,6 +29,13 @@ const clockReducer = (state = DEFAULT_STATE, action) => { switch (type) { case LOGOUT_USER: return DEFAULT_STATE; + case REHYDRATE: + // A persisted clock is always stale, and builds before this seed was corrected stored + // milliseconds. The config blacklist cannot discard it: it only filters outbound + // writes, while autoMergeLevel2 merges every stored key back in on rehydrate. Returning + // a NEW object is what suppresses that merge, since the reconciler skips any key whose + // substate the reducer already modified. + return { nowUtc: moment().unix() }; case UPDATE_CLOCK: { const { timestamp } = payload; return { ...state, nowUtc: timestamp }; diff --git a/src/store.js b/src/store.js index df2cb42..b8198ba 100644 --- a/src/store.js +++ b/src/store.js @@ -28,6 +28,9 @@ import clockReducer from "./reducers/clock-reducer"; const config = { key: 'root', storage, + // the clock is live state — persisting it rehydrates a stale nowUtc over the seed, which is how + // a millisecond value written before the seed was fixed survives into the next session + blacklist: ['clockState'], } const reducers = persistCombineReducers(config, { From 9823e8044702ec17709f27fa30addd30392aba04 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Sun, 9 Aug 2026 13:39:21 -0500 Subject: [PATCH 2/4] fix: put the reopen window-ended check on the trusted clock The reopen predicate mixed clocks in a single expression: submissionEnded read the browser clock through nowAfter, while the deadline comparison beside it read the server-synced nowUtc. The spec's rule is that the plan window check stays on local time for consistency with the rest of the app, and the short reopen check uses the trusted clock. This line belongs to the second, so it now compares against nowUtc. submissionIsClosed deliberately stays on nowBetween. It is the plan window check the spec keeps local, every other window check in the app agrees with it, and moving it would change the gate for everyone rather than for a reopened talk. Co-Authored-By: Claude --- src/model/presentation.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/model/presentation.js b/src/model/presentation.js index 592f2e1..8fe595b 100644 --- a/src/model/presentation.js +++ b/src/model/presentation.js @@ -12,7 +12,7 @@ **/ import T from 'i18n-react/dist/i18n-react'; -import {formatEpoch, nowAfter, nowBetween} from "../utils/methods"; +import {formatEpoch, nowBetween} from "../utils/methods"; const SelectionStatus_Accepted = 'accepted'; const SelectionStatus_Alternate = 'alternate'; @@ -172,8 +172,9 @@ class Presentation { // a grant only counts once the window has actually ENDED, not merely whenever it is not // open: submissionIsClosed is also true before the window starts, and honoring a grant // there would admit edits the API refuses, since isSubmissionReopened() requires - // now > submission_end_date - const submissionEnded = nowAfter(this._selectionPlan.submission_end_date); + // now > submission_end_date. On the trusted clock, not nowBetween's local one, because + // this is part of the reopen check rather than the plan window check + const submissionEnded = nowUtc > this._selectionPlan.submission_end_date; const reopened = !!reopenedUntil && submissionEnded && nowUtc < reopenedUntil; if (submissionIsClosed && !reopened) return false; From ba8dc8a227a74de9535e1975690d86afba4a98f5 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Tue, 11 Aug 2026 15:29:56 -0500 Subject: [PATCH 3/4] fix: address review feedback on the reopen edit gate Three items from smarcet's review, kept in one commit because they interleave within presentation.js and presentation-layout.js and cannot be separated by path. 1. Seed nowUtc as null rather than the browser clock, and suppress the canEdit() redirect until the first Clock tick, as SDS section 5 asked for. uicore's Clock leaves timestamp null until the time service answers and tick() no-ops until then, so a seed survives a whole round trip; a device clock running fast past submission_reopened_until read a live grant as expired, and that redirect is one-way. The other nowUtc consumers get null branches as flagged: getStatus renders nothing rather than a wrong status (null coerces to 0 in its window comparisons), and the presentations-table row click no-ops rather than resolving every reopened presentation to /preview, which was sticky through the same guard. 2. Add Presentation.getReopenedUntil() as the single definition of an operative grant: plan enabled, window actually ended, grant live. canEdit() gates on it and the banner displays it, so the banner can no longer announce a deadline that nothing gates on, which is what happened once an admin extended submission_end_date past a grant. 3. Gate componentWillReceiveProps on the props each call actually reads. Subscribing the layout to the clock put updateSelectionPlan and updatePresentation on a 1 Hz loop, and updatePresentation recomputes allowed media uploads and grouped tags, rewrites step visibility, and writes progressNum onto the redux entity. The per-tick re-render stays, since that is what locks the form on time. Co-Authored-By: Claude --- src/components/presentations-table.js | 4 +++ src/layouts/presentation-layout.js | 21 +++++++++++-- src/model/presentation.js | 44 ++++++++++++++++++++------- src/pages/edit-presentation-page.js | 8 +++-- src/reducers/clock-reducer.js | 12 +++++--- 5 files changed, 68 insertions(+), 21 deletions(-) diff --git a/src/components/presentations-table.js b/src/components/presentations-table.js index 67cc2ac..09968b9 100644 --- a/src/components/presentations-table.js +++ b/src/components/presentations-table.js @@ -34,6 +34,10 @@ const PresentationsTable = ({ const handleEditPresentation = (ev, presentation) => { ev.preventDefault(); + // getProgressLink asks canEdit, so before the first Clock tick it would resolve every + // reopened presentation to /preview, and the layout's redirect guard makes that one-way. + // Do nothing until the clock is real rather than navigate somewhere we cannot come back from. + if (nowUtc == null) return; history.push(presentation.getProgressLink(nowUtc)); }; diff --git a/src/layouts/presentation-layout.js b/src/layouts/presentation-layout.js index 4953983..9b65cb8 100644 --- a/src/layouts/presentation-layout.js +++ b/src/layouts/presentation-layout.js @@ -51,8 +51,20 @@ class PresentationLayout extends React.Component { this.props.getPresentation(newId); } - this.presentation.updateSelectionPlan(newProps.selectionPlan); - this.presentation.updatePresentation(newProps.entity, newProps.track); + // Gated on the props each call actually reads. This component now subscribes to the + // clock, so props change every second; updatePresentation is not cheap or side-effect + // free (it recomputes allowed media uploads and grouped tags, rewrites step visibility, + // and writes progressNum onto the redux entity), and none of that depends on the tick. + // The per-tick re-render still happens, which is what locks the form on time. + // Identity comparison is sound here: presentation-reducer builds a new entity object on + // RECEIVE_PRESENTATION and PRESENTATION_UPDATED. + if (newProps.selectionPlan !== this.props.selectionPlan) { + this.presentation.updateSelectionPlan(newProps.selectionPlan); + } + + if (newProps.entity !== this.props.entity || newProps.track !== this.props.track) { + this.presentation.updatePresentation(newProps.entity, newProps.track); + } } render(){ @@ -61,7 +73,10 @@ class PresentationLayout extends React.Component { if (loading || (!isNew && !entity.id)) return null; - if (!isNew && match.params.presentation_id == entity.id && !this.presentation.canEdit(nowUtc) && !location.pathname.endsWith('preview') ) { + // nowUtc is null until the first Clock tick. Evaluating the gate against a seed would + // let a fast device clock read a live grant as expired, and this redirect is one-way: + // the guard below skips it once already on /preview, so a corrected tick never undoes it. + if (!isNew && nowUtc != null && match.params.presentation_id == entity.id && !this.presentation.canEdit(nowUtc) && !location.pathname.endsWith('preview') ) { return(); } diff --git a/src/model/presentation.js b/src/model/presentation.js index 8fe595b..13780e9 100644 --- a/src/model/presentation.js +++ b/src/model/presentation.js @@ -87,6 +87,10 @@ class Presentation { * @returns {React.ReactNode} */ getStatus(nowUtc) { + // every branch below classifies the submission and selection windows against nowUtc, so + // before the first Clock tick there is no answer to give. null coerces to 0 in these + // comparisons, which would render a confidently wrong status; render nothing instead. + if (nowUtc == null) return null; const {is_published, status, selection_status, selectionPlan} = this._presentation; const { @@ -151,6 +155,31 @@ class Presentation { return (this._presentation.is_published || this._presentation.status === 'Received'); } + /** + * The operative reopen deadline, or null when a grant is not what is letting this + * presentation be edited. Three things must hold, mirroring the API's + * isSubmissionReopened(): the plan is enabled, its window has actually ENDED, and the + * grant is still live. "Not open" is not the same as "ended" — the window is also not + * open before it starts, and honoring a grant there would admit edits the API refuses. + * + * Single definition on purpose: canEdit() gates on it and the banner displays it, and if + * the two drifted the banner would announce a deadline that does not constrain anything — + * e.g. after an admin extends submission_end_date past an existing grant. + * + * @param nowUtc epoch seconds from the Clock, or null before the first tick + * @returns {number|null} + */ + getReopenedUntil(nowUtc) { + if (nowUtc == null) return null; + if (!this._selectionPlan || this._selectionPlan.is_enabled === false) return null; + // ungranted arrives as null on the list feeds and '' on the detail feed, which coerces + // every null to empty string; a falsy check covers both + const until = this._presentation.submission_reopened_until; + if (!until) return null; + if (nowUtc <= this._selectionPlan.submission_end_date) return null; + return nowUtc < until ? until : null; + } + /** * @param nowUtc epoch seconds, server-synced via the Clock * @returns {boolean} @@ -164,18 +193,11 @@ class Presentation { if (this._selectionPlan.is_enabled === false) return false; // computed per call, not snapshotted in the constructor, so a window that ends while the - // page is open locks the form without a reload + // page is open locks the form without a reload. Still on nowBetween's local clock, as it + // was before this feature; the reopen check below is the part that moved to the synced + // one, because a 24h grant makes skew a far larger fraction than a multi-week plan window. const submissionIsClosed = !nowBetween(this._selectionPlan.submission_begin_date, this._selectionPlan.submission_end_date); - // ungranted arrives as null on the list feeds and '' on the detail feed, which coerces - // every null to empty string; !! just renders both false - const reopenedUntil = this._presentation.submission_reopened_until; - // a grant only counts once the window has actually ENDED, not merely whenever it is not - // open: submissionIsClosed is also true before the window starts, and honoring a grant - // there would admit edits the API refuses, since isSubmissionReopened() requires - // now > submission_end_date. On the trusted clock, not nowBetween's local one, because - // this is part of the reopen check rather than the plan window check - const submissionEnded = nowUtc > this._selectionPlan.submission_end_date; - const reopened = !!reopenedUntil && submissionEnded && nowUtc < reopenedUntil; + const reopened = !!this.getReopenedUntil(nowUtc); if (submissionIsClosed && !reopened) return false; diff --git a/src/pages/edit-presentation-page.js b/src/pages/edit-presentation-page.js index baff473..7225658 100644 --- a/src/pages/edit-presentation-page.js +++ b/src/pages/edit-presentation-page.js @@ -104,15 +104,19 @@ const EditPresentationPage = ({entity, track, presentation, selectionPlan, summi }); } + // asks the model rather than re-deriving the condition, so the banner cannot announce a + // deadline that canEdit() does not actually gate on + const reopenedUntil = presentation.getReopenedUntil(nowUtc); + return (

{title} {`${selectionPlanSettings?.CFP_PRESENTATIONS_SINGULAR_LABEL || T.translate("edit_presentation.presentation")}`}

- {entity.submission_reopened_until > nowUtc && + {reopenedUntil &&
{T.translate("edit_presentation.submission_reopened", { - end_date: formatEpoch(entity.submission_reopened_until, "MMMM DD, YYYY h:mm a"), + end_date: formatEpoch(reopenedUntil, "MMMM DD, YYYY h:mm a"), when: moment.tz.guess(), })}
diff --git a/src/reducers/clock-reducer.js b/src/reducers/clock-reducer.js index 9cfce91..ed259e7 100644 --- a/src/reducers/clock-reducer.js +++ b/src/reducers/clock-reducer.js @@ -10,17 +10,19 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ -import moment from 'moment-timezone'; import { REHYDRATE } from 'redux-persist'; import { LOGOUT_USER } from 'openstack-uicore-foundation/lib/security/actions'; import { UPDATE_CLOCK, } from '../actions/clock-actions'; -// epoch SECONDS, matching the Clock ticks that replace it and every consumer of nowUtc -const localNowUtc = moment().unix(); +// null, not the browser clock: uicore's Clock leaves state.timestamp null until the time +// service answers, and tick() no-ops until then, so any seed here survives for a whole round +// trip. A device clock running fast past submission_reopened_until would make an active grant +// read as expired, and the resulting redirect to /preview is one-way. Consumers treat null as +// "not known yet" rather than as a time. Ticks arrive in epoch SECONDS, as does the API. const DEFAULT_STATE = { - nowUtc: localNowUtc, + nowUtc: null, }; const clockReducer = (state = DEFAULT_STATE, action) => { @@ -35,7 +37,7 @@ const clockReducer = (state = DEFAULT_STATE, action) => { // writes, while autoMergeLevel2 merges every stored key back in on rehydrate. Returning // a NEW object is what suppresses that merge, since the reconciler skips any key whose // substate the reducer already modified. - return { nowUtc: moment().unix() }; + return { nowUtc: null }; case UPDATE_CLOCK: { const { timestamp } = payload; return { ...state, nowUtc: timestamp }; From c1cb9b12f69fa1ee473247bc76e57214e1138cc5 Mon Sep 17 00:00:00 2001 From: Casey Locker Date: Wed, 12 Aug 2026 10:21:42 -0500 Subject: [PATCH 4/4] fix: refuse a reopen grant on a plan with no submission end date getReopenedUntil() claimed to mirror the API's isSubmissionReopened(), but it enforced three of that method's four invariants: the API returns false when the plan has no submission end date (Presentation.php:2610) and it had no equivalent. Without the guard the comparison below it is nowUtc <= 0, since null and '' both coerce and undefined gives NaN. That is false, so the check falls through and the grant is honored. nowBetween() reads the same missing end date as a closed window, so canEdit() then rests entirely on the grant and the form renders, while the banner announces a deadline nothing enforces. Nothing valid is behind that: the reopen endpoint refuses to stamp a grant on a plan without an end date, so reaching this state takes the end date being cleared after the fact, and every write from the reopened form fails once it is. Falsy check rather than a null check, matching the file's convention for API-coerced empties. Reported by smarcet on PR #90. Co-Authored-By: Claude --- src/model/presentation.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/model/presentation.js b/src/model/presentation.js index 13780e9..e6265fb 100644 --- a/src/model/presentation.js +++ b/src/model/presentation.js @@ -157,10 +157,11 @@ class Presentation { /** * The operative reopen deadline, or null when a grant is not what is letting this - * presentation be edited. Three things must hold, mirroring the API's - * isSubmissionReopened(): the plan is enabled, its window has actually ENDED, and the - * grant is still live. "Not open" is not the same as "ended" — the window is also not - * open before it starts, and honoring a grant there would admit edits the API refuses. + * presentation be edited. Four things must hold, mirroring the API's + * isSubmissionReopened(): the plan is enabled, it has a submission end date, that window + * has actually ENDED, and the grant is still live. "Not open" is not the same as "ended" — + * the window is also not open before it starts, and honoring a grant there would admit + * edits the API refuses. * * Single definition on purpose: canEdit() gates on it and the banner displays it, and if * the two drifted the banner would announce a deadline that does not constrain anything — @@ -176,6 +177,11 @@ class Presentation { // every null to empty string; a falsy check covers both const until = this._presentation.submission_reopened_until; if (!until) return null; + // no end date means no window to have ended, so there is nothing to reopen. Without this + // the comparison below is nowUtc <= 0 (null and '' coerce, undefined gives NaN), which is + // false, so the grant would be honored and the form would render against a plan every + // write fails on. Falsy check, matching the coercion note above. + if (!this._selectionPlan.submission_end_date) return null; if (nowUtc <= this._selectionPlan.submission_end_date) return null; return nowUtc < until ? until : null; }