Participant event editing (session cookie architecture), per-field inline validation, participant event deletion, the add- and edit-activity submit flows, form time-gating, and the form draft cache.
Part of the architecture index. Section IDs (03-§N.M) are stable and cited from code; they do not encode the file path.
Participants who submit an event gain temporary ownership of that event, tracked through a browser cookie. They can then edit the event until its date passes. No server-side session store is used.
| Property | Value |
|---|---|
| Name | sb_session |
| Content | JSON array of event ID strings |
| Max-Age | 7 days (604 800 s) |
| Secure | Yes (HTTPS only) |
| SameSite | Strict |
| HttpOnly | No — see note below |
Why the cookie is not httpOnly:
The schedule pages are static HTML, pre-rendered at build time. There is no
server-side rendering at request time. Client-side JavaScript is therefore the
only layer that can read the cookie and selectively show edit links for events
the current visitor owns. Making the cookie httpOnly would prevent this.
Security is maintained through server-side validation: the /edit-event endpoint
always verifies that the target event ID appears in the cookie sent with the
request. An attacker who cannot forge a session cookie they do not have cannot
edit events they do not own.
Set-Cookie: sb_session=….source/assets/js/client/session.js reads the
cookie, removes IDs for events whose dates have passed, and writes the
cleaned cookie back (or deletes it if the array becomes empty).
The write-back must include the same Domain attribute the server used,
read from a data-cookie-domain attribute injected on <body> at build time.session.js detects duplicate sb_session
cookies (e.g. one with Domain and one without, caused by a historical
bug in removeIdFromCookie). If duplicates are found, all ID arrays are
merged and deduplicated, both cookie variants are deleted, and a single
correct cookie is written back. This repair is transparent to the user.redigera.html) includes a collapsible “Om din cookie”
section that displays the cookie contents: protocol, cookie domain,
stored event IDs with their status (active / expired / not found in
schema), and whether automatic repair was performed.At build time, source/build/build.js writes public/events.json — a JSON
array of all public event fields for the active camp. The edit page
(/redigera.html) fetches this file client-side to pre-populate the edit
form with current event data.
POST /edit-event handles edit submissions:
sb_session cookie from the request.adminToken.POST /add-event).meta.updated_at.flowchart TD
A[Participant clicks Redigera link] --> B[/redigera.html?id=event-id/]
B --> C{JS: owns this ID via cookie OR has admin token?}
C -->|No| D[Show error — not authorised]
C -->|Yes| E[Fetch /events.json · pre-populate form]
E --> F[User edits and submits]
F --> G["POST /edit-event (server)"]
G --> H{Cookie ownership OR valid adminToken? + fields + date not passed}
H -->|Fail| I[HTTP 400/403]
H -->|Pass| J[GitHub API: read camp YAML]
J --> K[Replace event fields · update meta.updated_at]
K --> L[Commit to ephemeral branch · open PR · enable auto-merge]
L --> M[Auto-merge · deploy · schedule updated]
Before the session cookie is set, the add-activity page prompts the user for
cookie consent (first submission only, per browser). The consent decision is
stored in localStorage under the key sb_cookie_consent. If the user
declines, the event is still submitted but no session cookie is set.
| File | Role |
|---|---|
source/assets/js/client/session.js |
Reads/cleans session cookie; injects edit links on schedule pages |
source/assets/js/client/cookie-consent.js |
Displays consent prompt; writes localStorage decision |
source/assets/js/client/redigera.js |
Edit form logic: load event data, validate, submit |
source/build/render-edit.js |
Renders static /redigera.html at build time |
source/api/edit-event.js |
Server-side edit handler: ownership check, YAML patch, GitHub PR |
| File | Change |
|---|---|
app.js |
Add POST /edit-event route; add cookie-parser middleware |
source/build/build.js |
Build /redigera.html; write public/events.json |
source/build/render.js |
Add data-event-id attribute to event rows |
source/api/github.js |
Add updateEventInActiveCamp() function |
Both the add-activity and edit-activity forms validate required fields on submit. Each validation error is displayed inline, directly below the input it relates to — not in a single aggregated error box.
Each .field div contains an error <span> after its input element:
<span class="field-error" id="err-title" hidden></span>
The input links to its error span via aria-describedby="err-title".
aria-invalid="true" on the input,
populate and show its .field-error span.aria-invalid, hide the error span.Errors are cleared on the next submit attempt — not on individual keystroke or blur. This keeps the JS simple and avoids distracting the user while they are still filling in the form.
The optional link field validates on blur — unlike required fields which
validate on submit. When the user leaves the field and the value is
non-empty, lagg-till.js checks:
http:// or https:// (case-insensitive).If either check fails, setFieldError shows the error below the field.
The error is cleared on the input event so the user gets immediate
feedback as they correct the value. The submit flow also checks the link
field error state — submission is blocked while the error is visible.
aria-invalid="true" communicates the error state to screen readers.aria-describedby links each input to its error message so the
error is announced when the input receives focus.| File | Change |
|---|---|
source/build/render-add.js |
Add .field-error spans and aria-describedby to inputs; remove #form-errors div |
source/build/render-edit.js |
Same changes as render-add.js |
source/assets/js/client/lagg-till.js |
Rewrite validation to show per-field errors |
source/assets/js/client/redigera.js |
Same validation rewrite |
source/assets/cs/style.css |
Add .field-error and [aria-invalid="true"] styles; remove .form-errors styles |
Participants who own an event can delete it from the edit page. Deletion removes the event entirely from the camp’s YAML file using the same ephemeral-branch → PR → auto-merge pipeline as additions and edits.
POST /delete-event { id }
├─ parse sb_session cookie → owned IDs
├─ reject if id not in cookie → 403
├─ reject if editing period closed → 400
├─ reject if event date < today → 400
└─ removeEventFromActiveCamp(id)
├─ resolve active camp from camps.yaml
├─ fetch camp YAML + SHA from GitHub
├─ remove event entry from YAML
├─ create ephemeral branch event-delete/<id>
├─ commit removal
├─ open PR → auto-merge (squash)
└─ return success
POST /delete-event with credentials: 'include'.sb_session cookie; confirmation shown.The build step derives the delete URL from the API_URL environment
variable by replacing a trailing /add-event path segment with
/delete-event; if API_URL does not end with /add-event, the delete
URL falls back to /delete-event.
| File | Change |
|---|---|
app.js |
Add POST /delete-event route |
source/api/edit-event.js |
Add removeEventFromYaml() function |
source/api/github.js |
Add removeEventFromActiveCamp() function |
source/build/render-edit.js |
Add delete button and confirmation dialog HTML |
source/assets/js/client/redigera.js |
Add delete button handler, confirmation, progress modal |
When the user presses “Skicka” and validation passes, the submit flow proceeds through four stages before returning control to the user:
cookie-consent.js renders the consent
prompt inside the existing #submit-modal as a modal dialog with backdrop
and focus trap. The user accepts or declines. The modal content then
transitions to the progress state (stage 3) without closing.All <input>, <select>, and <textarea> elements inside #event-form
are disabled by wrapping the form body in a <fieldset> and setting
fieldset.disabled = true. This is simpler and more reliable than disabling
each element individually. CSS uses opacity and cursor: not-allowed on
fieldset:disabled to communicate the locked state visually.
The modal is a <div> injected into <body> by lagg-till.js on first
submit. It is re-used on subsequent submissions (“Lägg till en till”).
Structure:
<div id="submit-modal" role="dialog" aria-modal="true" aria-labelledby="modal-heading" hidden>
<div class="modal-backdrop"></div>
<div class="modal-box">
<h2 id="modal-heading"><!-- heading text set by JS --></h2>
<!-- spinner / message / actions set by JS -->
</div>
</div>
The backdrop covers the full viewport (fixed, full-width/height) and blocks
scroll via overflow: hidden on <body> while open. The modal box is
centered with flexbox.
Focus is trapped: when the modal opens, focus moves to the first focusable
element inside .modal-box. Tab and Shift+Tab wrap within the modal.
| State | Heading | Content |
|---|---|---|
| Loading | “Skickar…” | Spinner + “Skickar till GitHub…” |
| Success | “Aktiviteten är tillagd!” | Title, “Den syns i schemat om ungefär en minut.”, optional no-edit note, two action buttons |
| Error | “Något gick fel” | Error message + “Försök igen” button |
fieldset.disabled = false,
restores focus to the submit button. Form data is preserved so the user
can correct the issue.form.reset(), sets
fieldset.disabled = false, scrolls to top.| File | Change |
|---|---|
source/build/render-add.js |
Wrap form fields in <fieldset>, remove #result section, add #submit-modal skeleton |
source/assets/js/client/lagg-till.js |
Implement lock/modal/state logic |
source/assets/cs/style.css |
Add fieldset:disabled style, modal backdrop, modal box |
The edit-activity submit flow mirrors the add-activity flow (§8) but without a consent step, and with success text appropriate for an update rather than a new submission.
When the user presses “Spara ändringar” and validation passes:
fieldset.disabled = true.The edit form wraps all its fields in a <fieldset> (same pattern as the add
form). Setting fieldset.disabled = true disables all child inputs and the
submit button atomically. CSS communicates the locked state visually via
opacity and cursor: not-allowed on fieldset:disabled.
The modal uses the same #submit-modal HTML skeleton and CSS as the add form —
role="dialog", aria-modal="true", aria-labelledby="modal-heading", focus
trapping, and body.modal-open { overflow: hidden }.
| State | Heading | Content |
|---|---|---|
| Loading | “Sparar…” | Spinner + “Sparar till GitHub…” |
| Success | “Aktiviteten är uppdaterad!” | Title, “Den syns i schemat om ungefär en minut.”, “Gå till schemat →” link |
| Error | “Något gick fel” | Error message + “Försök igen” button |
Closes the modal, sets fieldset.disabled = false, restores focus to the submit
button. Form data is preserved so the user can correct and resubmit.
| File | Change |
|---|---|
source/build/render-edit.js |
Wrap form fields in <fieldset>, remove #result section, add #submit-modal skeleton |
source/assets/js/client/redigera.js |
Implement lock/modal/state logic |
The add-activity and edit-activity forms are only usable during a defined period around the active camp. Outside this period, submissions are rejected.
Each camp in camps.yaml has an opens_for_editing field (see 05-DATA_CONTRACT.md §1).
The submission period runs from opens_for_editing through end_date + 1 day
(inclusive on both ends). Dates are compared as plain YYYY-MM-DD strings — no
timezone handling.
At build time, render-add.js and render-edit.js read opens_for_editing and
end_date from the active camp and embed them as data-opens and data-closes
attributes on the <form> element. The closes date is computed as end_date + 1 day.
lagg-till.js and redigera.js read the data-opens and data-closes attributes
at page load and compare against today’s date (new Date().toISOString().slice(0, 10)).
If outside the period:
app.js reads opens_for_editing and end_date from the active camp in
camps.yaml (already fetched from GitHub). Both POST /add-event and
POST /edit-event check the current date against the period before processing
the request. Requests outside the period receive HTTP 403 with a Swedish error
message.
| File | Change |
|---|---|
source/data/camps.yaml |
Add opens_for_editing to every camp entry |
docs/05-DATA_CONTRACT.md |
Document the new field |
source/build/render-add.js |
Embed data-opens and data-closes on form |
source/build/render-edit.js |
Embed data-opens and data-closes on form |
source/assets/js/client/lagg-till.js |
Client-side date check and form disabling |
source/assets/js/client/redigera.js |
Client-side date check and form disabling |
app.js |
Server-side date check on both endpoints |
Administrators can open the add-activity and edit-activity forms before
opens_for_editing. The bypass is one-sided: it only opens the pre-period
lock. After end_date + 1 day the forms stay locked for everyone so finished
camps cannot be altered retroactively through the website.
Client side (lagg-till.js, redigera.js):
today < opens, the script also
checks for a valid admin token (same extraction as used in redigera.js
for edit/delete — see §12 Admin Token).today > closes, the admin button
is never rendered.lagg-till.js reads the admin token the same way redigera.js does and
includes it in the /add-event request body as adminToken when the
admin bypass is active.Server side (app.js):
POST /add-event, POST /edit-event, and POST /delete-event
verifies the admin token (using verifyAdminToken from source/api/admin.js)
and treats the request as admin if valid.today < opens_for_editing) is skipped for admin
requests.today > end_date + 1 day) is not skipped —
it applies to admins too.time-gate.js exposes two helpers alongside the existing
isOutsideEditingPeriod: isBeforeEditingPeriod(today, opens) and
isAfterEditingPeriod(today, endDate). Endpoints use them to distinguish the
two lock reasons. The combined helper is retained for callers that do not
need the distinction.
When filling in the add-activity form, field values are continuously saved to
sessionStorage so that a page reload restores all input. This protects
against accidental reloads, browser lock-ups, and navigation mistakes.
A single sessionStorage key sb_form_draft holds a JSON object with the
current field values:
{
"title": "Morgonyoga",
"start": "08:00",
"end": "09:00",
"location": "Stora stugan",
"responsible": "Anna",
"description": "Ta med matta",
"link": "",
"dates": ["2025-08-04", "2025-08-05"]
}
| Field | Event |
|---|---|
| Title, start, end, responsible, description, link | input |
| Location (select) | change |
| Day-grid dates | click (on day button) |
On DOMContentLoaded, if sb_form_draft exists in sessionStorage:
.value assignment.<select> is restored if the saved value matches an option.aria-pressed / class toggled) and the hidden input updated.sb_responsible localStorage logic is unaffected — the draft
cache does not replace it.The draft is removed (sessionStorage.removeItem('sb_form_draft')) after a
successful submission response. Because sessionStorage is scoped to the
browser tab, closing the tab also clears the data — no expiry logic is needed.
| File | Change |
|---|---|
source/assets/js/client/lagg-till.js |
Add save/restore/clear logic for sb_form_draft |