From b328df67a3394caf77c7ed1c8ad9e011ad06a59b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 04:25:54 +0200 Subject: [PATCH] feat(scheduling-webui): manage participant invitations --- README.md | 28 +- .../test-scheduling-page-structure.mjs | 27 +- .../features/scheduling/SchedulingPage.tsx | 305 +++++++++++++++++- webui/src/i18n/generatedTranslations.ts | 36 +++ 4 files changed, 384 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a994f42..8972081 100644 --- a/README.md +++ b/README.md @@ -209,13 +209,29 @@ Organizers and Scheduling administrators use the participant-specific invitation action instead: - `POST /scheduling/requests/{request_id}/participants/{participant_id}/invitation` - with `{"action":"copy"}` rotates the previous invitation and returns the new - relative action URL once. The response is marked `no-store`. + with `{"action":"copy","participant_revision":"..."}` rotates the previous + invitation and returns the new relative action URL once. The response is + marked `no-store`. - The same endpoint with `{"action":"send"}` rotates the invitation and passes - its URL directly to the notification dispatch job. Neither the API response - nor Scheduling's durable notification projection contains the token. -- `DELETE` on the same resource revokes the active link immediately; exact - repeats are idempotent. + its URL directly to the notification dispatch job; it also requires the + current `participant_revision`. Neither the API response nor Scheduling's + durable notification projection contains the token. +- `DELETE` on the same resource uses a JSON body containing the current + `participant_revision` and revokes the active link immediately. A revoke + against the refreshed no-link projection is an idempotent replay. + +The semantic participant revision includes the current invitation identity. +It is checked after the participant row is locked, so stale copy, send, and +revoke commands return `409` before rotating a newer link or delivering to a +changed recipient. + +The participant DataGrid presents copy, send, and revoke as a fixed icon-only +action group. Authorized but unavailable actions remain visible and explain +why they are disabled: for example, delivery is disabled without a dispatch +provider or recipient target, and revoke is disabled when no active link +exists. The delivery capability is exposed only in management projections. +Copy accepts only the same-origin Scheduling public path and does not persist +the bearer URL in component state, logs, or browser storage. Links can be issued in any request state. Collection state and deadline checks remain independent submission requirements, so a link to a draft, closed, or diff --git a/webui/scripts/test-scheduling-page-structure.mjs b/webui/scripts/test-scheduling-page-structure.mjs index a0b61be..a7a68ba 100644 --- a/webui/scripts/test-scheduling-page-structure.mjs +++ b/webui/scripts/test-scheduling-page-structure.mjs @@ -27,6 +27,7 @@ assert.match(page, /title=\{I18N\.invitedRequests\}/); assert.ok(page.indexOf("title={I18N.myRequests}") < page.indexOf("title={I18N.invitedRequests}")); assert.match(page, //); assert.match(page, / \(\{/); assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/); assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/); assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/); +assert.match(api, /issueSchedulingParticipantInvitation\([\s\S]*json\(\{ action, participant_revision: participantRevision \}\)/); +assert.match(api, /revokeSchedulingParticipantInvitation\([\s\S]*method: "DELETE"[\s\S]*participant_revision: participantRevision/); +assert.match(api, /participants\/\$\{encodeURIComponent\(participantId\)\}\/invitation/); assert.match(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/); assert.match(page, /useSearchParams\(\)/); assert.match(page, /Promise\.allSettled/); diff --git a/webui/src/features/scheduling/SchedulingPage.tsx b/webui/src/features/scheduling/SchedulingPage.tsx index 346dd7a..e8ef24b 100644 --- a/webui/src/features/scheduling/SchedulingPage.tsx +++ b/webui/src/features/scheduling/SchedulingPage.tsx @@ -5,6 +5,8 @@ import { CalendarCheck, Check, Clock, + Copy, + Link2Off, Pencil, Plus, RefreshCw, @@ -15,6 +17,7 @@ import { import { Button, Card, + ConfirmDialog, DataGrid, DataGridRowActions, DateTimeField, @@ -33,6 +36,7 @@ import { StatusBadge, hasScope, i18nMessage, + isApiError, usePlatformLanguage, usePlatformUiCapability, useUnsavedChanges, @@ -54,15 +58,18 @@ import { decideSchedulingRequest, evaluateSchedulingFreeBusy, getSchedulingAvailabilityResponse, + issueSchedulingParticipantInvitation, listSchedulingNotifications, listSchedulingRequests, openSchedulingRequest, + revokeSchedulingParticipantInvitation, searchSchedulingPeople, schedulingSummary, submitSchedulingAvailability, updateSchedulingRequest, type SchedulingCandidateSlot, type SchedulingAvailabilityValue, + type SchedulingInvitationActionResponse, type SchedulingNotification, type SchedulingParticipant, type SchedulingPollOptionResult, @@ -76,10 +83,13 @@ import { participantDraftFromResponse, participantDraftsFromPicker, participantPayload, + schedulingInvitationActionBlocks, + schedulingPublicInvitationUrl, schedulingRelevantTimestamp, schedulingRequestIsOwned, schedulingSortPhase, type SchedulingActor, + type SchedulingInvitationActionBlock, type SchedulingParticipantDraft, type SchedulingRequestGroups } from "./schedulingViewModel"; @@ -97,6 +107,10 @@ type SlotDraft = { metadata?: Record; }; type ParticipantDraft = SchedulingParticipantDraft; +type InvitationRevokeTarget = { + requestId: string; + participant: SchedulingParticipant; +}; const I18N = { actions: "i18n:govoplan-core.actions.c3cd636a", @@ -118,10 +132,13 @@ const I18N = { calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e", candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5", candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b", + cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c", checkFreeBusy: "i18n:govoplan-scheduling.check_free_busy.e9700e00", chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6", + clipboardUnavailable: "i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc", closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916", closed: "i18n:govoplan-scheduling.closed.88d86b77", + copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79", description: "i18n:govoplan-scheduling.description.55f8ebc8", determined: "i18n:govoplan-scheduling.determined.9f23293d", decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d", @@ -133,7 +150,13 @@ const I18N = { generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c", free: "i18n:govoplan-scheduling.free.75f52718", holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e", + invitationDeliveryUnavailable: "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3", + invitationDeliveryFailed: "i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306", + invitationDeliveryRequested: "i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba", + invitationChanged: "i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533", invitedRequests: "i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba", + invitationLinkCopied: "i18n:govoplan-scheduling.invitation_link_copied.332973ec", + invitationLinkRevoked: "i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4", loading: "i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d", location: "i18n:govoplan-scheduling.location.d219c681", allowComments: "i18n:govoplan-scheduling.allow_comments.d63202a6", @@ -155,6 +178,8 @@ const I18N = { myRequests: "i18n:govoplan-scheduling.my_scheduling_requests.d28ef235", name: "i18n:govoplan-scheduling.name.709a2322", newRequest: "i18n:govoplan-scheduling.new_scheduling_request.2080f675", + noActiveInvitation: "i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc", + noDeliveryTarget: "i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180", noNotifications: "i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3", notificationsUnavailable: "i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3", noParticipants: "i18n:govoplan-scheduling.no_participants.73a89101", @@ -165,6 +190,7 @@ const I18N = { notifyOnAnswersHelp: "i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8", open: "i18n:govoplan-scheduling.open.cf9b7706", openPoll: "i18n:govoplan-scheduling.open_poll.2beac9a7", + participant: "i18n:govoplan-scheduling.participant.554f4235", participantEmail: "i18n:govoplan-scheduling.participant_email.2cadfd9e", participantRosterHidden: "i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a", participantPrivacy: "i18n:govoplan-scheduling.participant_privacy.108c470f", @@ -181,7 +207,12 @@ const I18N = { capacity: "i18n:govoplan-scheduling.capacity.d3c375f8", past: "i18n:govoplan-scheduling.past.405c12fb", refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1", + reloadInvitation: "i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4", reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf", + revokeInvitation: "i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa", + revokeInvitationConfirm: "i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817", + revokeInvitationLabel: "i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf", + revokeLink: "i18n:govoplan-scheduling.revoke_link.da371ee1", requestFailed: "i18n:govoplan-scheduling.request_failed.9fcda32c", publicPolicyUnavailable: "i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0", policyLocked: "i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740", @@ -199,6 +230,7 @@ const I18N = { unsavedResponse: "i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1", singleChoice: "i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c", singleChoiceHelp: "i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465", + sendInvitation: "i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea", sendResponse: "i18n:govoplan-scheduling.submit_response.a5f0c053", start: "i18n:govoplan-scheduling.start.952f3754", statusLabel: "i18n:govoplan-scheduling.status.bae7d5be", @@ -255,6 +287,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin const [summaryUnavailable, setSummaryUnavailable] = useState(false); const [notificationsUnavailable, setNotificationsUnavailable] = useState(false); const [detailsLoading, setDetailsLoading] = useState(false); + const [revokeInvitationTarget, setRevokeInvitationTarget] = useState(null); + const [invitationActionClock, setInvitationActionClock] = useState(() => new Date()); const detailLoadSequence = useRef(0); const canWrite = hasScope(auth, "scheduling:schedule:write"); @@ -356,6 +390,29 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin void loadDetails(selected.id); }, [editorMode, selected?.id]); + useEffect(() => { + setInvitationActionClock(new Date()); + if (selected?.status !== "cancelled" || !selected.cancellation_notice_until) return undefined; + const expiresAt = Date.parse(selected.cancellation_notice_until); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return undefined; + let timer: number | undefined; + const scheduleExpiryRefresh = () => { + const remaining = expiresAt - Date.now(); + if (remaining <= 0) { + setInvitationActionClock(new Date()); + return; + } + timer = window.setTimeout( + scheduleExpiryRefresh, + Math.min(remaining + 25, 2_147_483_647) + ); + }; + scheduleExpiryRefresh(); + return () => { + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [selected?.cancellation_notice_until, selected?.status]); + useEffect(() => { if (!selected?.id || !selectedParticipant || !canRespond || editorMode || selected.status !== "collecting") { setAvailabilityLoading(false); @@ -645,6 +702,91 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin } } + async function runParticipantInvitationAction( + requestId: string, + participant: SchedulingParticipant, + action: "copy" | "send" | "revoke" + ): Promise { + const request = requests.find((item) => item.id === requestId); + if (!request || !(canAdminister || (canWrite && schedulingRequestIsOwned(request, actor)))) return false; + if (!participant.revision) { + setError(I18N.reloadInvitation); + return false; + } + let succeeded = false; + let failureMessage = ""; + let completionMessage = ""; + setSaving(true); + setError(""); + setSuccess(""); + try { + let copied = true; + let result: SchedulingInvitationActionResponse; + if (action === "copy") { + const copyResult = await copySchedulingInvitationLink( + issueSchedulingParticipantInvitation( + settings, + requestId, + participant.id, + participant.revision, + action + ) + ); + result = copyResult.response; + copied = copyResult.copied; + } else if (action === "send") { + result = await issueSchedulingParticipantInvitation( + settings, + requestId, + participant.id, + participant.revision, + action + ); + } else { + result = await revokeSchedulingParticipantInvitation( + settings, + requestId, + participant.id, + participant.revision + ); + } + if (action === "copy") { + if (!copied) { + throw new Error(translateText(I18N.clipboardUnavailable)); + } + completionMessage = I18N.invitationLinkCopied; + } else if (action === "send") { + if (["failed", "skipped"].includes(result.status)) { + throw new Error(translateText(I18N.invitationDeliveryFailed)); + } + completionMessage = I18N.invitationDeliveryRequested; + } else { + completionMessage = I18N.invitationLinkRevoked; + } + succeeded = true; + } catch (err) { + if (isApiError(err, 409)) { + failureMessage = I18N.invitationChanged; + } else { + failureMessage = errorMessage(err, translateText(I18N.requestFailed)); + } + } finally { + await loadRequests(requestId); + await loadDetails(requestId); + setSaving(false); + if (failureMessage) setError(failureMessage); + if (completionMessage) setSuccess(completionMessage); + } + return succeeded; + } + + async function confirmRevokeInvitation() { + if (!revokeInvitationTarget) return; + const target = revokeInvitationTarget; + setRevokeInvitationTarget(null); + await runParticipantInvitationAction(target.requestId, target.participant, "revoke"); + } + async function sendAvailability(event: FormEvent) { event.preventDefault(); await persistAvailability(); @@ -706,9 +848,9 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin label={I18N.refresh} icon={