feat(scheduling-webui): manage participant invitations
This commit is contained in:
28
README.md
28
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
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user