feat(scheduling-webui): manage participant invitations

This commit is contained in:
2026-07-22 04:25:54 +02:00
parent fc356e22c6
commit b328df67a3
4 changed files with 384 additions and 12 deletions

View File

@@ -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

View File

@@ -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, /<SelectionList label=\{title\} className="scheduling-request-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selectedId === request\.id\}[\s\S]*className="scheduling-list-item"/);
assert.match(page, /className="scheduling-list-item"[\s\S]{0,100}disabled=\{disabled\}/);
assert.match(page, /editorMode \? \(/);
assert.match(page, /id="scheduling-editor-form"/);
@@ -86,6 +87,26 @@ assert.match(page, /showDecisionAction=\{canManageSelected\}/);
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/);
assert.doesNotMatch(page, /AdminIconButton/);
const participantGridStart = page.indexOf("function ParticipantsGrid(");
const participantGridEnd = page.indexOf("function invitationActionDisabledReason", participantGridStart);
const participantGrid = page.slice(participantGridStart, participantGridEnd);
assert.match(participantGrid, /\.\.\.\(canManage \? \[\{/);
assert.match(participantGrid, /minimumSlots=\{3\}/);
assert.ok(participantGrid.indexOf('id: "copy-invitation"') < participantGrid.indexOf('id: "send-invitation"'));
assert.ok(participantGrid.indexOf('id: "send-invitation"') < participantGrid.indexOf('id: "revoke-invitation"'));
assert.match(participantGrid, /schedulingInvitationActionBlocks\(request, participant, now\)/);
assert.match(participantGrid, /disabledReason: copyDisabledReason/);
assert.match(participantGrid, /disabledReason: deliveryDisabledReason/);
assert.match(participantGrid, /disabledReason: revokeDisabledReason/);
assert.match(page, /<ConfirmDialog[\s\S]*title=\{I18N\.revokeInvitationLabel\}[\s\S]*tone="danger"/);
assert.match(page, /navigator\.clipboard\.writeText\(value\)/);
assert.match(page, /navigator\.clipboard\.write\(\[new ClipboardItem/);
assert.match(page, /schedulingPublicInvitationUrl\(response\.action_url, window\.location\.origin\)/);
assert.match(page, /\["failed", "skipped"\]\.includes\(result\.status\)/);
assert.match(page, /isApiError\(err, 409\)/);
assert.match(page, /scheduleExpiryRefresh/);
assert.doesNotMatch(page, /(?:localStorage|sessionStorage).*action_url|action_url.*(?:localStorage|sessionStorage)/);
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
assert.match(page, /option_revision: slot\.revision/);
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
@@ -106,7 +127,8 @@ for (const field of [
"allow_comments",
"participant_email_required",
"anonymous_password_protection_enabled",
"public_participation_policy_enforcement_available"
"public_participation_policy_enforcement_available",
"participant_invitation_delivery_available"
]) {
assert.match(api, new RegExp(`${field}:`));
}
@@ -117,6 +139,9 @@ assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
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/);

View File

@@ -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<string, unknown>;
};
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<InvitationRevokeTarget | null>(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<boolean> {
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<HTMLFormElement>) {
event.preventDefault();
await persistAvailability();
@@ -706,9 +848,9 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
label={I18N.refresh}
icon={<RefreshCw aria-hidden="true" size={16} />}
onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
disabled={loading} />
disabled={loading || saving} />
{canCreateOrWrite ? (
<Button type="button" variant="primary" onClick={beginCreate}>
<Button type="button" variant="primary" onClick={beginCreate} disabled={saving}>
<Plus aria-hidden="true" size={16} /> {I18N.add}
</Button>
) : null}
@@ -720,12 +862,14 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
requests={groups.owned}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
disabled={saving}
onSelect={selectRequest} />
<RequestGroup
title={I18N.invitedRequests}
requests={groups.invited}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
disabled={saving}
onSelect={selectRequest} />
{canAdminister && groups.other.length ? (
<RequestGroup
@@ -733,6 +877,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
requests={groups.other}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
disabled={saving}
onSelect={selectRequest} />
) : null}
</Card>
@@ -1060,7 +1205,18 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
))}
</div>
{selected.effective_participant_visibility === "names_and_statuses" ? (
<ParticipantsGrid participants={selected.participants} />
<ParticipantsGrid
request={selected}
participants={selected.participants}
canManage={canManageSelected}
saving={saving}
now={invitationActionClock}
onCopy={(participant) => void runParticipantInvitationAction(selected.id, participant, "copy")}
onSend={(participant) => void runParticipantInvitationAction(selected.id, participant, "send")}
onRevoke={(participant) => setRevokeInvitationTarget({
requestId: selected.id,
participant
})} />
) : <p className="scheduling-capability-note">{I18N.participantRosterHidden}</p>}
</Card>
<Card title={I18N.notifications}>
@@ -1090,6 +1246,17 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
</section>
</div>
</section>
<ConfirmDialog
open={Boolean(revokeInvitationTarget)}
title={I18N.revokeInvitationLabel}
message={i18nMessage(I18N.revokeInvitationConfirm, {
value0: participantDisplayLabel(revokeInvitationTarget?.participant ?? null)
})}
confirmLabel={I18N.revokeLink}
tone="danger"
busy={saving}
onCancel={() => setRevokeInvitationTarget(null)}
onConfirm={() => void confirmRevokeInvitation()} />
</main>
);
@@ -1177,12 +1344,14 @@ function RequestGroup({
requests,
selectedId,
actor,
disabled,
onSelect
}: {
title: string;
requests: SchedulingRequest[];
selectedId?: string;
actor: SchedulingActor;
disabled: boolean;
onSelect: (requestId: string) => void;
}) {
return (
@@ -1196,6 +1365,7 @@ function RequestGroup({
key={request.id}
selected={selectedId === request.id}
className="scheduling-list-item"
disabled={disabled}
onClick={() => onSelect(request.id)}>
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
<StatusBadge status={request.status} label={requestStatusLabel(request, actor)} />
@@ -1636,7 +1806,25 @@ function CandidateSlotsGrid({
);
}
function ParticipantsGrid({ participants }: { participants: SchedulingParticipant[] }) {
function ParticipantsGrid({
request,
participants,
canManage,
saving,
now,
onCopy,
onSend,
onRevoke
}: {
request: SchedulingRequest;
participants: SchedulingParticipant[];
canManage: boolean;
saving: boolean;
now: Date;
onCopy: (participant: SchedulingParticipant) => void;
onSend: (participant: SchedulingParticipant) => void;
onRevoke: (participant: SchedulingParticipant) => void;
}) {
const columns: DataGridColumn<SchedulingParticipant>[] = [
{ id: "name", header: I18N.name, minWidth: 180, resizable: true, value: (participant) => participant.display_name ?? "" },
{ id: "email", header: I18N.participantEmail, minWidth: 220, resizable: true, value: (participant) => participant.email ?? "" },
@@ -1646,7 +1834,52 @@ function ParticipantsGrid({ participants }: { participants: SchedulingParticipan
width: 140,
value: (participant) => participantStatusLabel(participant.status),
render: (participant) => <StatusBadge status={participant.status} label={participantStatusLabel(participant.status)} />
}
},
...(canManage ? [{
id: "actions",
header: I18N.actions,
width: 132,
sticky: "end",
align: "right",
render: (participant) => {
const label = participantDisplayLabel(participant);
const blocks = schedulingInvitationActionBlocks(request, participant, now);
const copyDisabledReason = invitationActionDisabledReason(blocks.copy, request);
const deliveryDisabledReason = invitationActionDisabledReason(blocks.send, request);
const revokeDisabledReason = invitationActionDisabledReason(blocks.revoke, request);
return (
<TableActionGroup
minimumSlots={3}
actions={[
{
id: "copy-invitation",
label: i18nMessage(I18N.copyInvitationLink, { value0: label }),
icon: <Copy aria-hidden="true" size={16} />,
disabled: saving || Boolean(copyDisabledReason),
disabledReason: copyDisabledReason,
onClick: () => onCopy(participant)
},
{
id: "send-invitation",
label: i18nMessage(I18N.sendInvitation, { value0: label }),
icon: <Send aria-hidden="true" size={16} />,
disabled: saving || Boolean(deliveryDisabledReason),
disabledReason: deliveryDisabledReason,
onClick: () => onSend(participant)
},
{
id: "revoke-invitation",
label: i18nMessage(I18N.revokeInvitation, { value0: label }),
icon: <Link2Off aria-hidden="true" size={16} />,
variant: "danger",
disabled: saving || Boolean(revokeDisabledReason),
disabledReason: revokeDisabledReason,
onClick: () => onRevoke(participant)
}
]} />
);
}
} satisfies DataGridColumn<SchedulingParticipant>] : [])
];
return (
@@ -1660,6 +1893,68 @@ function ParticipantsGrid({ participants }: { participants: SchedulingParticipan
);
}
function invitationActionDisabledReason(
block: SchedulingInvitationActionBlock | null,
request: SchedulingRequest
): string | undefined {
if (block === "participation_policy_unavailable") {
return request.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable;
}
if (block === "cancellation_notice_expired") return I18N.cancellationNoticeExpired;
if (block === "delivery_unavailable") return I18N.invitationDeliveryUnavailable;
if (block === "no_delivery_target") return I18N.noDeliveryTarget;
if (block === "no_active_invitation") return I18N.noActiveInvitation;
if (block === "participant_revision_unavailable") return I18N.reloadInvitation;
return undefined;
}
function participantDisplayLabel(participant: SchedulingParticipant | null): string {
return participant?.display_name?.trim() || participant?.email?.trim() || I18N.participant;
}
async function copySchedulingInvitationLink(
responsePromise: Promise<SchedulingInvitationActionResponse>
): Promise<{ response: SchedulingInvitationActionResponse; copied: boolean }> {
if (
typeof navigator !== "undefined" &&
navigator.clipboard?.write &&
typeof ClipboardItem !== "undefined"
) {
try {
const content = responsePromise.then((response) => {
const value = response.action_url
? schedulingPublicInvitationUrl(response.action_url, window.location.origin)
: null;
return new Blob([value ?? ""], { type: "text/plain" });
}).catch(() => new Blob([], { type: "text/plain" }));
await navigator.clipboard.write([new ClipboardItem({ "text/plain": content })]);
const response = await responsePromise;
const copied = Boolean(
response.action_url &&
schedulingPublicInvitationUrl(response.action_url, window.location.origin)
);
return { response, copied };
} catch {
// The request result is reused below; no second invitation is issued.
}
}
const response = await responsePromise;
const value = response.action_url
? schedulingPublicInvitationUrl(response.action_url, window.location.origin)
: null;
if (!value) return { response, copied: false };
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return { response, copied: true };
}
} catch {
// The caller reports the bounded clipboard failure without exposing the URL.
}
return { response, copied: false };
}
function firstRequest(groups: SchedulingRequestGroups, includeManaged: boolean): SchedulingRequest | null {
return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null;
}

View File

@@ -1,12 +1,30 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Automatic invitation delivery is unavailable; copy the link instead.",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until",
"i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79": "Copy a fresh invitation link for {value0}",
"i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c": "Enter the details supplied with the invitation. For privacy, invalid and expired links use the same response.",
"i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b": "Loading scheduling request…",
"i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306": "Invitation delivery failed. The link was created but was not delivered.",
"i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba": "Invitation delivery requested.",
"i18n:govoplan-scheduling.invitation_link_copied.332973ec": "Invitation link copied.",
"i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4": "Invitation link revoked.",
"i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc": "No active invitation link to revoke.",
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "Open in Scheduling",
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Open scheduling request",
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Response deadline",
"i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4": "Reload the request before changing this invitation.",
"i18n:govoplan-scheduling.participant.554f4235": "Participant",
"i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf": "Revoke invitation link",
"i18n:govoplan-scheduling.revoke_link.da371ee1": "Revoke link",
"i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817": "Revoke the current invitation link for {value0}? It will stop working immediately.",
"i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa": "Revoke the invitation link for {value0}",
"i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea": "Send a fresh invitation to {value0}",
"i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c": "The cancellation notice has expired; a new link cannot be issued.",
"i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc": "The invitation link could not be copied. Check browser clipboard permissions and try again.",
"i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180": "This participant has no deliverable email address or account.",
"i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533": "This invitation changed. The request was reloaded; try again.",
"i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197": "This scheduling link is invalid, expired, or the access details do not match.",
"i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e": "This scheduling request was cancelled.",
"i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a": "This scheduling request is no longer accepting responses.",
@@ -155,12 +173,30 @@ export const generatedTranslations = {
},
de: {
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Die automatische Einladungszustellung ist nicht verfügbar; kopieren Sie stattdessen den Link.",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",
"i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79": "Einen neuen Einladungslink für {value0} kopieren",
"i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c": "Geben Sie die mit der Einladung übermittelten Daten ein. Aus Datenschutzgründen wird für ungültige und abgelaufene Links dieselbe Meldung angezeigt.",
"i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b": "Terminanfrage wird geladen …",
"i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306": "Die Zustellung der Einladung ist fehlgeschlagen. Der Link wurde erstellt, aber nicht zugestellt.",
"i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba": "Die Zustellung der Einladung wurde angefordert.",
"i18n:govoplan-scheduling.invitation_link_copied.332973ec": "Einladungslink kopiert.",
"i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4": "Einladungslink widerrufen.",
"i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc": "Kein aktiver Einladungslink zum Widerrufen vorhanden.",
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "In der Terminplanung öffnen",
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Terminanfrage öffnen",
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Antwortfrist",
"i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4": "Laden Sie die Anfrage neu, bevor Sie diese Einladung ändern.",
"i18n:govoplan-scheduling.participant.554f4235": "Teilnehmende Person",
"i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf": "Einladungslink widerrufen",
"i18n:govoplan-scheduling.revoke_link.da371ee1": "Link widerrufen",
"i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817": "Den aktuellen Einladungslink für {value0} widerrufen? Er funktioniert danach sofort nicht mehr.",
"i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa": "Den Einladungslink für {value0} widerrufen",
"i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea": "Eine neue Einladung an {value0} senden",
"i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c": "Der Stornierungshinweis ist abgelaufen; ein neuer Link kann nicht ausgestellt werden.",
"i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc": "Der Einladungslink konnte nicht kopiert werden. Prüfen Sie die Zwischenablageberechtigungen des Browsers und versuchen Sie es erneut.",
"i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180": "Für diese teilnehmende Person ist weder eine zustellbare E-Mail-Adresse noch ein Konto hinterlegt.",
"i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533": "Diese Einladung wurde zwischenzeitlich geändert. Die Anfrage wurde neu geladen; versuchen Sie es erneut.",
"i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197": "Dieser Terminlink ist ungültig oder abgelaufen, oder die Zugangsdaten stimmen nicht überein.",
"i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e": "Diese Terminanfrage wurde storniert.",
"i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a": "Diese Terminanfrage nimmt keine Antworten mehr an.",