feat: add governed public self-enrollment links

This commit is contained in:
2026-08-20 13:03:57 +02:00
parent 79cfaa951a
commit 539e3cbb5e
18 changed files with 2481 additions and 13 deletions
@@ -4,11 +4,13 @@ import { fileURLToPath } from "node:url";
const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url));
const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url));
const enrollmentPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingEnrollmentPage.tsx", import.meta.url));
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url));
const widgetPath = fileURLToPath(new URL("../src/features/scheduling/SchedulingRequestsWidget.tsx", import.meta.url));
const page = readFileSync(pagePath, "utf8");
const publicPage = readFileSync(publicPagePath, "utf8");
const enrollmentPage = readFileSync(enrollmentPagePath, "utf8");
const api = readFileSync(apiPath, "utf8");
const moduleSource = readFileSync(modulePath, "utf8");
const widget = readFileSync(widgetPath, "utf8");
@@ -48,6 +50,11 @@ assert.match(page, /<StageRail[\s\S]*schedulingLifecycleStages\(selected\.status
assert.match(page, /topicId: "scheduling\.find-and-decide-meeting-time"/);
assert.match(page, /topicId: "scheduling\.calendar-coordination"/);
assert.match(page, /topicId: "scheduling\.participation-governance"/);
assert.match(page, /topicId: "scheduling\.public-self-enrollment"/);
assert.match(page, /function SelfEnrollmentLinksCard/);
assert.match(page, /createSchedulingEnrollmentLink/);
assert.match(page, /revokeSchedulingEnrollmentLink/);
assert.match(page, /title=\{I18N\.selfEnrollmentRevokeTitle\}[\s\S]*tone="danger"/);
assert.match(page, /public_participation_policy_enforcement_available === false[\s\S]*<ActionBlockerHint/);
assert.match(page, /<Card title=\{I18N\.generalSettings\}>/);
assert.match(page, /<Card title=\{I18N\.participantPrivacy\}>/);
@@ -161,11 +168,14 @@ assert.match(api, /issueSchedulingParticipantInvitation\([\s\S]*json\(\{ action,
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(api, /\/api\/v1\/scheduling\/public-enrollment\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
assert.match(page, /useSearchParams\(\)/);
assert.match(page, /Promise\.allSettled/);
assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/);
assert.match(moduleSource, /SchedulingPublicPage/);
assert.match(moduleSource, /path: "\/scheduling\/enrol\/:requestId\/:token"/);
assert.match(moduleSource, /SchedulingEnrollmentPage/);
assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*DocumentationHelpLink,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*PasswordField,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(publicPage, /<PasswordField[\s\S]*autoComplete="current-password"/);
assert.doesNotMatch(publicPage, /<input[\s\S]{0,120}type="password"/);
@@ -178,6 +188,14 @@ assert.match(publicPage, /idempotency_key: newIdempotencyKey\(\)/);
assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/);
assert.match(enrollmentPage, /submitAuthenticatedSchedulingEnrollment/);
assert.match(enrollmentPage, /submitPublicSchedulingEnrollment/);
assert.match(enrollmentPage, /bind_account_confirmed: true/);
assert.match(enrollmentPage, /participant_proof:/);
assert.match(enrollmentPage, /topicId: "scheduling\.public-self-enrollment"/);
assert.doesNotMatch(enrollmentPage, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(enrollmentPage, /(?:localStorage|sessionStorage).*(?:token|proof)|(?:token|proof).*(?:localStorage|sessionStorage)/);
assert.match(widget, /DocumentationHelpLink/);
assert.match(widget, /to: `\/scheduling\?request_id=\$\{encodeURIComponent\(request\.id\)\}`/);
assert.match(widget, /label=\{request\.status === "collecting" \? I18N\.open : I18N\.draft\}/);
+149
View File
@@ -264,6 +264,82 @@ export type SchedulingPublicParticipationSubmitPayload = SchedulingPublicPartici
idempotency_key?: string;
};
export type SchedulingEnrollmentLink = {
id: string;
request_id: string;
status: "active" | "expired" | "revoked" | "exhausted";
expires_at: string;
max_enrollments: number;
enrollment_count: number;
allow_anonymous: boolean;
allow_authenticated: boolean;
created_at: string;
revoked_at?: string | null;
};
export type SchedulingEnrollmentLinkListResponse = { links: SchedulingEnrollmentLink[] };
export type SchedulingEnrollmentLinkActionResponse = {
link: SchedulingEnrollmentLink;
action_url?: string | null;
replayed: boolean;
};
export type SchedulingEnrollmentLinkCreatePayload = {
expires_at: string;
max_enrollments: number;
allow_anonymous: boolean;
allow_authenticated: boolean;
};
export type SchedulingPublicEnrollmentResponse = {
request_id: string;
link_id: string;
title: string;
description?: string | null;
location?: string | null;
timezone: string;
status: SchedulingStatus;
deadline_at?: string | null;
enrollment_expires_at: string;
enrollment_remaining: number;
display_name_required: boolean;
participant_email_required: boolean;
anonymous_allowed: boolean;
authenticated_allowed: boolean;
anonymous_password_required: boolean;
single_choice: boolean;
max_participants_per_option: number | null;
allow_maybe: boolean;
allow_comments: boolean;
allow_participant_updates: boolean;
enrolled: boolean;
account_bound: boolean;
has_response: boolean;
submitted_at?: string | null;
answers: Array<{ slot_id: string; value: SchedulingAvailabilityValue }>;
comment?: string | null;
replayed: boolean;
slots: SchedulingPublicCandidateSlot[];
};
export type SchedulingPublicEnrollmentSubmitPayload = SchedulingAvailabilityPayload & {
display_name: string;
email?: string | null;
password?: string | null;
participant_proof: string;
idempotency_key: string;
};
export type SchedulingAuthenticatedEnrollmentSubmitPayload = SchedulingAvailabilityPayload & {
display_name: string;
email?: string | null;
password?: string | null;
bind_account_confirmed: boolean;
participant_proof?: string | null;
idempotency_key: string;
};
export type SchedulingCalendarActionResponse = {
request: SchedulingRequest;
created_event_ids: string[];
@@ -423,6 +499,79 @@ export function submitPublicSchedulingParticipation(
);
}
export function listSchedulingEnrollmentLinks(
settings: ApiSettings,
requestId: string
): Promise<SchedulingEnrollmentLinkListResponse> {
return apiFetch<SchedulingEnrollmentLinkListResponse>(
settings,
`/api/v1/scheduling/requests/${requestId}/enrollment-links`
);
}
export function createSchedulingEnrollmentLink(
settings: ApiSettings,
requestId: string,
payload: SchedulingEnrollmentLinkCreatePayload
): Promise<SchedulingEnrollmentLinkActionResponse> {
return apiFetch<SchedulingEnrollmentLinkActionResponse>(
settings,
`/api/v1/scheduling/requests/${requestId}/enrollment-links`,
json(payload)
);
}
export function revokeSchedulingEnrollmentLink(
settings: ApiSettings,
requestId: string,
linkId: string
): Promise<SchedulingEnrollmentLinkActionResponse> {
return apiFetch<SchedulingEnrollmentLinkActionResponse>(
settings,
`/api/v1/scheduling/requests/${requestId}/enrollment-links/${linkId}`,
{ method: "DELETE" }
);
}
export function getPublicSchedulingEnrollment(
settings: ApiSettings,
requestId: string,
token: string,
password?: string
): Promise<SchedulingPublicEnrollmentResponse> {
return apiFetch<SchedulingPublicEnrollmentResponse>(
settings,
`/api/v1/scheduling/public-enrollment/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}`,
json({ password: password || null })
);
}
export function submitPublicSchedulingEnrollment(
settings: ApiSettings,
requestId: string,
token: string,
payload: SchedulingPublicEnrollmentSubmitPayload
): Promise<SchedulingPublicEnrollmentResponse> {
return apiFetch<SchedulingPublicEnrollmentResponse>(
settings,
`/api/v1/scheduling/public-enrollment/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}/responses`,
json(payload)
);
}
export function submitAuthenticatedSchedulingEnrollment(
settings: ApiSettings,
requestId: string,
token: string,
payload: SchedulingAuthenticatedEnrollmentSubmitPayload
): Promise<SchedulingPublicEnrollmentResponse> {
return apiFetch<SchedulingPublicEnrollmentResponse>(
settings,
`/api/v1/scheduling/public-enrollment/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}/authenticated-responses`,
json(payload)
);
}
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
}
@@ -0,0 +1,301 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router";
import {
Button,
Card,
DismissibleAlert,
DocumentationHelpLink,
FormField,
FormGrid,
LoadingFrame,
PasswordField,
formatDateTime,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
getPublicSchedulingEnrollment,
submitAuthenticatedSchedulingEnrollment,
submitPublicSchedulingEnrollment,
type SchedulingAvailabilityValue,
type SchedulingPublicEnrollmentResponse
} from "../../api/scheduling";
import { applySchedulingAvailabilityChoice } from "./schedulingViewModel";
type SchedulingEnrollmentPageProps = {
settings: ApiSettings;
auth: AuthInfo | null;
};
const I18N = {
access: "i18n:govoplan-scheduling.self_enrollment.access",
answerRequired: "i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f",
available: "i18n:govoplan-scheduling.available.7c62a142",
back: "i18n:govoplan-scheduling.open_in_scheduling.48df1541",
bindAccount: "i18n:govoplan-scheduling.self_enrollment.bind_account",
bindHelp: "i18n:govoplan-scheduling.self_enrollment.bind_help",
claimAnonymous: "i18n:govoplan-scheduling.self_enrollment.claim_anonymous",
comment: "i18n:govoplan-scheduling.comment.d03495b1",
deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa",
displayName: "i18n:govoplan-scheduling.name.709a2322",
email: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
expires: "i18n:govoplan-scheduling.self_enrollment.expires",
invalid: "i18n:govoplan-scheduling.self_enrollment.invalid",
loading: "i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b",
maybe: "i18n:govoplan-scheduling.maybe.56dd8d0b",
participantDetails: "i18n:govoplan-scheduling.self_enrollment.participant_details",
password: "i18n:govoplan-scheduling.guest_password.94545e82",
proof: "i18n:govoplan-scheduling.self_enrollment.proof",
proofHelp: "i18n:govoplan-scheduling.self_enrollment.proof_help",
remaining: "i18n:govoplan-scheduling.self_enrollment.remaining",
response: "i18n:govoplan-scheduling.your_availability.f86c8215",
saved: "i18n:govoplan-scheduling.self_enrollment.saved",
saving: "i18n:govoplan-scheduling.saving.56a2285c",
submit: "i18n:govoplan-scheduling.self_enrollment.submit",
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79"
} as const;
function newSecret(prefix: string): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}-${Math.random().toString(16).slice(2)}`;
}
function initialAvailability(response: SchedulingPublicEnrollmentResponse) {
const previous = new Map(response.answers.map((answer) => [answer.slot_id, answer.value]));
return Object.fromEntries(response.slots.map((slot) => [slot.id, previous.get(slot.id) ?? ""])) as Record<
string,
SchedulingAvailabilityValue | ""
>;
}
export default function SchedulingEnrollmentPage({ settings, auth }: SchedulingEnrollmentPageProps) {
const { requestId = "", token = "" } = useParams();
const [enrollment, setEnrollment] = useState<SchedulingPublicEnrollmentResponse | null>(null);
const [displayName, setDisplayName] = useState(auth?.user.display_name ?? "");
const [email, setEmail] = useState(auth?.user.email ?? "");
const [password, setPassword] = useState("");
const [proof, setProof] = useState(() => newSecret("scheduling-proof"));
const [bindAccount, setBindAccount] = useState(Boolean(auth));
const [claimAnonymous, setClaimAnonymous] = useState(false);
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
const [comment, setComment] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [needsAccess, setNeedsAccess] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const slotIds = useMemo(() => enrollment?.slots.map((slot) => slot.id) ?? [], [enrollment]);
function applyResponse(next: SchedulingPublicEnrollmentResponse) {
setEnrollment(next);
setAvailability(initialAvailability(next));
setComment(next.comment ?? "");
setNeedsAccess(false);
setError("");
}
useEffect(() => {
let cancelled = false;
setLoading(true);
void getPublicSchedulingEnrollment(settings, requestId, token)
.then((next) => {
if (!cancelled) applyResponse(next);
})
.catch(() => {
if (!cancelled) setNeedsAccess(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [requestId, settings.apiBaseUrl, settings.apiKey, token]);
async function openEnrollment(event: FormEvent) {
event.preventDefault();
setLoading(true);
setError("");
try {
applyResponse(await getPublicSchedulingEnrollment(settings, requestId, token, password));
} catch {
setError(I18N.invalid);
} finally {
setLoading(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!enrollment || !enrollment.slots.some((slot) => availability[slot.id])) {
setError(I18N.answerRequired);
return;
}
setSaving(true);
setError("");
setSuccess("");
const common = {
display_name: displayName.trim(),
email: email.trim() || null,
password: password || null,
answers: enrollment.slots
.filter((slot) => availability[slot.id])
.map((slot) => ({
slot_id: slot.id,
value: availability[slot.id] as SchedulingAvailabilityValue,
option_revision: slot.revision
})),
comment: enrollment.allow_comments ? comment.trim() || null : null,
idempotency_key: newSecret("scheduling-enrollment")
};
try {
const next = auth && bindAccount
? await submitAuthenticatedSchedulingEnrollment(settings, requestId, token, {
...common,
bind_account_confirmed: true,
participant_proof: claimAnonymous || (enrollment.enrolled && !enrollment.account_bound) ? proof : null
})
: await submitPublicSchedulingEnrollment(settings, requestId, token, {
...common,
participant_proof: proof
});
applyResponse(next);
setSuccess(I18N.saved);
} catch {
setError(I18N.invalid);
} finally {
setSaving(false);
}
}
return (
<main className="scheduling-public-page">
<LoadingFrame loading={loading} label={I18N.loading}>
{auth ? (
<div className="scheduling-public-deep-link">
<Link className="btn btn-secondary" to={`/scheduling?request_id=${encodeURIComponent(requestId)}`}>
{I18N.back}
</Link>
</div>
) : null}
{needsAccess && !enrollment ? (
<Card title={I18N.access}>
<form className="scheduling-public-access-form" onSubmit={openEnrollment}>
{error ? <DismissibleAlert tone="danger">{error}</DismissibleAlert> : null}
<FormField label={I18N.password}>
<PasswordField
autoComplete="current-password"
value={password}
onValueChange={setPassword} />
</FormField>
<div className="scheduling-public-actions">
<Button type="submit" variant="primary" disabled={loading}>{I18N.access}</Button>
</div>
</form>
</Card>
) : null}
{enrollment ? (
<form className="scheduling-public-content" onSubmit={submit}>
<Card
title={enrollment.title}
actions={<DocumentationHelpLink reference={{
topicId: "scheduling.public-self-enrollment",
documentationType: "user"
}} />}>
{enrollment.description ? <p>{enrollment.description}</p> : null}
<dl className="scheduling-public-summary">
{enrollment.deadline_at ? <><dt>{I18N.deadline}</dt><dd>{formatDateTime(enrollment.deadline_at)}</dd></> : null}
<dt>{I18N.expires}</dt><dd>{formatDateTime(enrollment.enrollment_expires_at)}</dd>
<dt>{I18N.remaining}</dt><dd>{enrollment.enrollment_remaining}</dd>
</dl>
</Card>
{error ? <DismissibleAlert tone="danger">{error}</DismissibleAlert> : null}
{success ? <DismissibleAlert tone="success">{success}</DismissibleAlert> : null}
<Card title={I18N.participantDetails}>
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label={I18N.displayName}>
<input required maxLength={500} value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
</FormField>
<FormField label={I18N.email}>
<input type="email" required={enrollment.participant_email_required} maxLength={320} value={email} onChange={(event) => setEmail(event.target.value)} />
</FormField>
</FormGrid>
{auth && enrollment.authenticated_allowed ? (
<>
<label className="scheduling-enrollment-confirmation">
<input type="checkbox" checked={bindAccount} onChange={(event) => setBindAccount(event.target.checked)} />
<span><strong>{I18N.bindAccount}</strong><small>{I18N.bindHelp}</small></span>
</label>
{bindAccount ? (
<label className="scheduling-enrollment-confirmation">
<input type="checkbox" checked={claimAnonymous} onChange={(event) => setClaimAnonymous(event.target.checked)} />
<span>{I18N.claimAnonymous}</span>
</label>
) : null}
</>
) : null}
{((!auth || !bindAccount) && enrollment.anonymous_allowed) || claimAnonymous ? (
<FormField label={I18N.proof} help={I18N.proofHelp}>
<PasswordField
autoComplete="off"
value={proof}
onValueChange={setProof} />
</FormField>
) : null}
</Card>
<Card title={I18N.response}>
<div className="scheduling-public-slots">
{enrollment.slots.map((slot) => (
<fieldset className="scheduling-public-slot" key={slot.id} disabled={saving}>
<legend>{slot.label}</legend>
<p>{formatDateTime(slot.start_at)} {formatDateTime(slot.end_at)}</p>
<div className="scheduling-public-choice-group">
{([
["available", I18N.available],
...(enrollment.allow_maybe ? [["maybe", I18N.maybe] as const] : []),
["unavailable", I18N.unavailable]
] as Array<[SchedulingAvailabilityValue, string]>).map(([value, label]) => (
<label key={value}>
<input
type="radio"
name={`slot-${slot.id}`}
checked={availability[slot.id] === value}
onChange={() => setAvailability((current) => applySchedulingAvailabilityChoice(
slotIds,
current,
slot.id,
value,
enrollment.single_choice
))} />
<span>{label}</span>
</label>
))}
</div>
</fieldset>
))}
</div>
{enrollment.allow_comments ? (
<FormField label={I18N.comment}>
<textarea rows={4} maxLength={4000} value={comment} onChange={(event) => setComment(event.target.value)} />
</FormField>
) : null}
<div className="scheduling-public-actions">
<Button type="submit" variant="primary" disabled={saving || !displayName.trim()}>
{saving ? I18N.saving : I18N.submit}
</Button>
</div>
</Card>
</form>
) : null}
</LoadingFrame>
</main>
);
}
@@ -58,6 +58,7 @@ import {
closeSchedulingRequest,
createSchedulingCalendarEvent,
createSchedulingHolds,
createSchedulingEnrollmentLink,
createSchedulingNotifications,
createSchedulingRequest,
decideSchedulingRequest,
@@ -65,9 +66,11 @@ import {
getSchedulingAvailabilityResponse,
issueSchedulingParticipantInvitation,
listSchedulingNotifications,
listSchedulingEnrollmentLinks,
listSchedulingRequests,
openSchedulingRequest,
revokeSchedulingParticipantInvitation,
revokeSchedulingEnrollmentLink,
searchSchedulingPeople,
schedulingSummary,
submitSchedulingAvailability,
@@ -75,6 +78,7 @@ import {
type SchedulingCandidateSlot,
type SchedulingAvailabilityValue,
type SchedulingInvitationActionResponse,
type SchedulingEnrollmentLink,
type SchedulingNotification,
type SchedulingParticipant,
type SchedulingPollOptionResult,
@@ -251,6 +255,30 @@ const I18N = {
requiresEventWrite: "i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763",
responseRecorded: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
responseReplace: "i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53",
selfEnrollmentAllowAnonymous: "i18n:govoplan-scheduling.self_enrollment.allow_anonymous",
selfEnrollmentAllowAuthenticated: "i18n:govoplan-scheduling.self_enrollment.allow_authenticated",
selfEnrollmentClipboardFailed: "i18n:govoplan-scheduling.self_enrollment.clipboard_failed",
selfEnrollmentCopied: "i18n:govoplan-scheduling.self_enrollment.copied",
selfEnrollmentExpiresAt: "i18n:govoplan-scheduling.self_enrollment.expires_at",
selfEnrollmentIssueCopy: "i18n:govoplan-scheduling.self_enrollment.issue_copy",
selfEnrollmentIssueFailed: "i18n:govoplan-scheduling.self_enrollment.issue_failed",
selfEnrollmentLinks: "i18n:govoplan-scheduling.self_enrollment.links",
selfEnrollmentLinksHelp: "i18n:govoplan-scheduling.self_enrollment.links_help",
selfEnrollmentLoadingLinks: "i18n:govoplan-scheduling.self_enrollment.loading_links",
selfEnrollmentLoadFailed: "i18n:govoplan-scheduling.self_enrollment.load_failed",
selfEnrollmentMaximum: "i18n:govoplan-scheduling.self_enrollment.maximum",
selfEnrollmentModeAnonymous: "i18n:govoplan-scheduling.self_enrollment.mode_anonymous",
selfEnrollmentModeAuthenticated: "i18n:govoplan-scheduling.self_enrollment.mode_authenticated",
selfEnrollmentNoLinks: "i18n:govoplan-scheduling.self_enrollment.no_links",
selfEnrollmentOpenFirst: "i18n:govoplan-scheduling.self_enrollment.open_first",
selfEnrollmentRevokeFailed: "i18n:govoplan-scheduling.self_enrollment.revoke_failed",
selfEnrollmentRevoked: "i18n:govoplan-scheduling.self_enrollment.revoked",
selfEnrollmentRevokeMessage: "i18n:govoplan-scheduling.self_enrollment.revoke_message",
selfEnrollmentRevokeTitle: "i18n:govoplan-scheduling.self_enrollment.revoke_title",
selfEnrollmentStatusActive: "i18n:govoplan-scheduling.self_enrollment.status_active",
selfEnrollmentStatusExpired: "i18n:govoplan-scheduling.self_enrollment.status_expired",
selfEnrollmentStatusExhausted: "i18n:govoplan-scheduling.self_enrollment.status_exhausted",
selfEnrollmentStatusRevoked: "i18n:govoplan-scheduling.self_enrollment.status_revoked",
resultsUnavailable: "i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18",
invitationHelp: "i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53",
save: "i18n:govoplan-scheduling.save.efc007a3",
@@ -1308,6 +1336,13 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
</Card>
{canManageSelected ? (
<SelfEnrollmentLinksCard
settings={settings}
request={selected}
disabled={saving} />
) : null}
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id || calendarCleanup?.status === "retry_required") ? (
<Card
title={I18N.calendarCoordination}
@@ -2283,6 +2318,198 @@ function localValue(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function SelfEnrollmentLinksCard({
settings,
request,
disabled
}: {
settings: ApiSettings;
request: SchedulingRequest;
disabled: boolean;
}) {
const { translateText } = usePlatformLanguage();
const defaultExpiry = useMemo(() => {
const candidate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
const deadline = request.deadline_at ? new Date(request.deadline_at) : null;
return localValue(deadline && deadline < candidate ? deadline : candidate);
}, [request.deadline_at]);
const [links, setLinks] = useState<SchedulingEnrollmentLink[]>([]);
const [expiresAt, setExpiresAt] = useState(defaultExpiry);
const [capacity, setCapacity] = useState(25);
const [allowAnonymous, setAllowAnonymous] = useState(true);
const [allowAuthenticated, setAllowAuthenticated] = useState(true);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [revokeTarget, setRevokeTarget] = useState<SchedulingEnrollmentLink | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
setLinks((await listSchedulingEnrollmentLinks(settings, request.id)).links);
setError("");
} catch (err) {
setError(errorMessage(err, translateText(I18N.selfEnrollmentLoadFailed)));
} finally {
setLoading(false);
}
}, [request.id, settings]);
useEffect(() => {
void load();
}, [load]);
async function createLink() {
let issuedLinkId: string | null = null;
setWorking(true);
setError("");
setSuccess("");
try {
const result = await createSchedulingEnrollmentLink(settings, request.id, {
expires_at: isoFromLocal(expiresAt),
max_enrollments: capacity,
allow_anonymous: allowAnonymous,
allow_authenticated: allowAuthenticated
});
issuedLinkId = result.link.id;
const absoluteUrl = result.action_url
? new URL(result.action_url, window.location.origin).toString()
: null;
if (!absoluteUrl || !navigator.clipboard) {
throw new Error(translateText(I18N.selfEnrollmentClipboardFailed));
}
await navigator.clipboard.writeText(absoluteUrl);
setSuccess(I18N.selfEnrollmentCopied);
await load();
} catch (err) {
if (issuedLinkId) {
try {
await revokeSchedulingEnrollmentLink(settings, request.id, issuedLinkId);
} catch {
// The list reload below surfaces the still-active link for explicit revocation.
}
}
setError(errorMessage(err, translateText(I18N.selfEnrollmentIssueFailed)));
await load();
} finally {
setWorking(false);
}
}
async function revokeLink() {
if (!revokeTarget) return;
const target = revokeTarget;
setRevokeTarget(null);
setWorking(true);
setError("");
try {
await revokeSchedulingEnrollmentLink(settings, request.id, target.id);
setSuccess(I18N.selfEnrollmentRevoked);
await load();
} catch (err) {
setError(errorMessage(err, translateText(I18N.selfEnrollmentRevokeFailed)));
} finally {
setWorking(false);
}
}
return (
<Card
title={I18N.selfEnrollmentLinks}
actions={<DocumentationHelpLink reference={{
topicId: "scheduling.public-self-enrollment",
documentationType: "user"
}} />}>
<p className="scheduling-capability-note">
{I18N.selfEnrollmentLinksHelp}
</p>
{error ? <DismissibleAlert tone="danger">{error}</DismissibleAlert> : null}
{success ? <DismissibleAlert tone="success">{success}</DismissibleAlert> : null}
<FormGrid columns={2} collapseAt="standard" className="">
<FormField label={I18N.selfEnrollmentExpiresAt}>
<DateTimeField
required
min={localValue(new Date())}
value={expiresAt}
disabled={disabled || working || request.status !== "collecting"}
onChange={setExpiresAt} />
</FormField>
<FormField label={I18N.selfEnrollmentMaximum}>
<input
type="number"
required
min={1}
max={10_000}
value={capacity}
disabled={disabled || working || request.status !== "collecting"}
onChange={(event) => setCapacity(Number(event.target.value))} />
</FormField>
</FormGrid>
<div className="scheduling-enrollment-modes">
<ToggleSwitch
label={I18N.selfEnrollmentAllowAnonymous}
checked={allowAnonymous}
disabled={disabled || working || request.status !== "collecting" || !request.allow_external_participants}
onChange={setAllowAnonymous} />
<ToggleSwitch
label={I18N.selfEnrollmentAllowAuthenticated}
checked={allowAuthenticated}
disabled={disabled || working || request.status !== "collecting"}
onChange={setAllowAuthenticated} />
</div>
<div className="scheduling-public-actions">
<Button
type="button"
variant="primary"
disabled={disabled || working || loading || request.status !== "collecting" || !expiresAt || capacity < 1 || (!allowAnonymous && !allowAuthenticated)}
disabledReason={request.status !== "collecting" ? I18N.selfEnrollmentOpenFirst : undefined}
onClick={() => void createLink()}>
<Copy aria-hidden="true" size={16} /> {I18N.selfEnrollmentIssueCopy}
</Button>
</div>
{loading ? <p className="scheduling-note">{I18N.selfEnrollmentLoadingLinks}</p> : links.length ? (
<div className="scheduling-enrollment-links">
{links.map((link) => (
<div className="scheduling-compact-row" key={link.id}>
<span>
<strong>{link.enrollment_count} / {link.max_enrollments}</strong>
<small>{formatDateTime(link.expires_at)} · {link.allow_anonymous ? translateText(I18N.selfEnrollmentModeAnonymous) : ""}{link.allow_anonymous && link.allow_authenticated ? " + " : ""}{link.allow_authenticated ? translateText(I18N.selfEnrollmentModeAuthenticated) : ""}</small>
</span>
<StatusBadge status={link.status} label={selfEnrollmentLinkStatusLabel(link.status)} />
<Button
type="button"
variant="danger"
disabled={disabled || working || link.status === "revoked"}
onClick={() => setRevokeTarget(link)}>
<Link2Off aria-hidden="true" size={16} /> {I18N.revokeLink}
</Button>
</div>
))}
</div>
) : <p className="scheduling-note">{I18N.selfEnrollmentNoLinks}</p>}
<ConfirmDialog
open={Boolean(revokeTarget)}
title={I18N.selfEnrollmentRevokeTitle}
message={I18N.selfEnrollmentRevokeMessage}
confirmLabel={I18N.revokeLink}
tone="danger"
busy={working}
onCancel={() => setRevokeTarget(null)}
onConfirm={() => void revokeLink()} />
</Card>
);
}
function selfEnrollmentLinkStatusLabel(status: SchedulingEnrollmentLink["status"]): string {
return {
active: I18N.selfEnrollmentStatusActive,
expired: I18N.selfEnrollmentStatusExpired,
revoked: I18N.selfEnrollmentStatusRevoked,
exhausted: I18N.selfEnrollmentStatusExhausted
}[status];
}
function addLocalMinutes(value: string, minutes: number): string {
if (!value) return "";
const date = new Date(value);
+72
View File
@@ -1,5 +1,41 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-scheduling.self_enrollment.access": "Open self-enrollment",
"i18n:govoplan-scheduling.self_enrollment.bind_account": "Bind this enrollment to my signed-in account",
"i18n:govoplan-scheduling.self_enrollment.bind_help": "Account binding requires confirmation and lets you manage this response from Scheduling.",
"i18n:govoplan-scheduling.self_enrollment.claim_anonymous": "Bind an earlier anonymous enrollment using its recovery proof",
"i18n:govoplan-scheduling.self_enrollment.expires": "Self-enrollment link expires",
"i18n:govoplan-scheduling.self_enrollment.invalid": "This self-enrollment link is invalid, expired, full, or the supplied details are incorrect.",
"i18n:govoplan-scheduling.self_enrollment.proof": "Anonymous recovery proof",
"i18n:govoplan-scheduling.self_enrollment.proof_help": "Keep this value privately. It is required to update or later bind an anonymous response and is never stored in clear text.",
"i18n:govoplan-scheduling.self_enrollment.remaining": "Enrollment places remaining",
"i18n:govoplan-scheduling.self_enrollment.submit": "Enroll and submit response",
"i18n:govoplan-scheduling.self_enrollment.saved": "Your enrollment and response have been recorded.",
"i18n:govoplan-scheduling.self_enrollment.participant_details": "Participant details",
"i18n:govoplan-scheduling.self_enrollment.links": "Public self-enrollment links",
"i18n:govoplan-scheduling.self_enrollment.links_help": "Reusable links are separate from participant invitations. Every link requires a capacity and expiry and is shown only once when copied.",
"i18n:govoplan-scheduling.self_enrollment.expires_at": "Expires at",
"i18n:govoplan-scheduling.self_enrollment.maximum": "Maximum enrollments",
"i18n:govoplan-scheduling.self_enrollment.allow_anonymous": "Allow anonymous enrollment with a recovery proof",
"i18n:govoplan-scheduling.self_enrollment.allow_authenticated": "Allow signed-in enrollment after account-binding confirmation",
"i18n:govoplan-scheduling.self_enrollment.issue_copy": "Issue and copy link",
"i18n:govoplan-scheduling.self_enrollment.open_first": "Open the request before issuing a self-enrollment link.",
"i18n:govoplan-scheduling.self_enrollment.loading_links": "Loading self-enrollment links…",
"i18n:govoplan-scheduling.self_enrollment.no_links": "No self-enrollment links have been issued.",
"i18n:govoplan-scheduling.self_enrollment.revoke_title": "Revoke self-enrollment link",
"i18n:govoplan-scheduling.self_enrollment.revoke_message": "This stops the reusable link immediately. Existing participant responses remain governed by the request policy.",
"i18n:govoplan-scheduling.self_enrollment.copied": "A new self-enrollment link was issued and copied. Its credential will not be shown again.",
"i18n:govoplan-scheduling.self_enrollment.revoked": "The self-enrollment link was revoked.",
"i18n:govoplan-scheduling.self_enrollment.load_failed": "Self-enrollment links could not be loaded.",
"i18n:govoplan-scheduling.self_enrollment.issue_failed": "The self-enrollment link could not be issued.",
"i18n:govoplan-scheduling.self_enrollment.revoke_failed": "The self-enrollment link could not be revoked.",
"i18n:govoplan-scheduling.self_enrollment.clipboard_failed": "The link was issued, but clipboard access is unavailable. The new link will be revoked automatically.",
"i18n:govoplan-scheduling.self_enrollment.mode_anonymous": "anonymous",
"i18n:govoplan-scheduling.self_enrollment.mode_authenticated": "signed in",
"i18n:govoplan-scheduling.self_enrollment.status_active": "Active",
"i18n:govoplan-scheduling.self_enrollment.status_expired": "Expired",
"i18n:govoplan-scheduling.self_enrollment.status_revoked": "Revoked",
"i18n:govoplan-scheduling.self_enrollment.status_exhausted": "Full",
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Calendar cleanup requires attention.",
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} tentative hold operations remain. Reconcile failed Calendar outbound changes if necessary, then repeat the original decision or cancellation action.",
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
@@ -182,6 +218,42 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
},
de: {
"i18n:govoplan-scheduling.self_enrollment.access": "Selbstanmeldung öffnen",
"i18n:govoplan-scheduling.self_enrollment.bind_account": "Diese Anmeldung mit meinem angemeldeten Konto verknüpfen",
"i18n:govoplan-scheduling.self_enrollment.bind_help": "Die Kontoverknüpfung muss bestätigt werden und ermöglicht die Verwaltung dieser Antwort in der Terminplanung.",
"i18n:govoplan-scheduling.self_enrollment.claim_anonymous": "Eine frühere anonyme Anmeldung mit ihrem Wiederherstellungsnachweis verknüpfen",
"i18n:govoplan-scheduling.self_enrollment.expires": "Link zur Selbstanmeldung läuft ab",
"i18n:govoplan-scheduling.self_enrollment.invalid": "Dieser Link zur Selbstanmeldung ist ungültig, abgelaufen oder vollständig belegt, oder die angegebenen Daten sind falsch.",
"i18n:govoplan-scheduling.self_enrollment.proof": "Wiederherstellungsnachweis für anonyme Anmeldung",
"i18n:govoplan-scheduling.self_enrollment.proof_help": "Bewahren Sie diesen Wert vertraulich auf. Er wird zum Aktualisieren oder späteren Verknüpfen einer anonymen Antwort benötigt und nie im Klartext gespeichert.",
"i18n:govoplan-scheduling.self_enrollment.remaining": "Verbleibende Anmeldeplätze",
"i18n:govoplan-scheduling.self_enrollment.submit": "Anmelden und Antwort senden",
"i18n:govoplan-scheduling.self_enrollment.saved": "Ihre Anmeldung und Antwort wurden gespeichert.",
"i18n:govoplan-scheduling.self_enrollment.participant_details": "Angaben zur teilnehmenden Person",
"i18n:govoplan-scheduling.self_enrollment.links": "Öffentliche Links zur Selbstanmeldung",
"i18n:govoplan-scheduling.self_enrollment.links_help": "Wiederverwendbare Links sind von persönlichen Einladungen getrennt. Jeder Link benötigt eine Kapazität und ein Ablaufdatum und wird beim Kopieren nur einmal angezeigt.",
"i18n:govoplan-scheduling.self_enrollment.expires_at": "Läuft ab am",
"i18n:govoplan-scheduling.self_enrollment.maximum": "Maximale Anmeldungen",
"i18n:govoplan-scheduling.self_enrollment.allow_anonymous": "Anonyme Anmeldung mit Wiederherstellungsnachweis zulassen",
"i18n:govoplan-scheduling.self_enrollment.allow_authenticated": "Anmeldung mit Konto nach Bestätigung der Verknüpfung zulassen",
"i18n:govoplan-scheduling.self_enrollment.issue_copy": "Link ausstellen und kopieren",
"i18n:govoplan-scheduling.self_enrollment.open_first": "Öffnen Sie die Anfrage, bevor Sie einen Link zur Selbstanmeldung ausstellen.",
"i18n:govoplan-scheduling.self_enrollment.loading_links": "Links zur Selbstanmeldung werden geladen …",
"i18n:govoplan-scheduling.self_enrollment.no_links": "Es wurden noch keine Links zur Selbstanmeldung ausgestellt.",
"i18n:govoplan-scheduling.self_enrollment.revoke_title": "Link zur Selbstanmeldung widerrufen",
"i18n:govoplan-scheduling.self_enrollment.revoke_message": "Der wiederverwendbare Link wird sofort deaktiviert. Vorhandene Antworten bleiben weiterhin durch die Richtlinie der Anfrage geregelt.",
"i18n:govoplan-scheduling.self_enrollment.copied": "Ein neuer Link zur Selbstanmeldung wurde ausgestellt und kopiert. Seine Zugangsdaten werden nicht erneut angezeigt.",
"i18n:govoplan-scheduling.self_enrollment.revoked": "Der Link zur Selbstanmeldung wurde widerrufen.",
"i18n:govoplan-scheduling.self_enrollment.load_failed": "Die Links zur Selbstanmeldung konnten nicht geladen werden.",
"i18n:govoplan-scheduling.self_enrollment.issue_failed": "Der Link zur Selbstanmeldung konnte nicht ausgestellt werden.",
"i18n:govoplan-scheduling.self_enrollment.revoke_failed": "Der Link zur Selbstanmeldung konnte nicht widerrufen werden.",
"i18n:govoplan-scheduling.self_enrollment.clipboard_failed": "Der Link wurde ausgestellt, aber die Zwischenablage ist nicht verfügbar. Der neue Link wird automatisch widerrufen.",
"i18n:govoplan-scheduling.self_enrollment.mode_anonymous": "anonym",
"i18n:govoplan-scheduling.self_enrollment.mode_authenticated": "angemeldet",
"i18n:govoplan-scheduling.self_enrollment.status_active": "Aktiv",
"i18n:govoplan-scheduling.self_enrollment.status_expired": "Abgelaufen",
"i18n:govoplan-scheduling.self_enrollment.status_revoked": "Widerrufen",
"i18n:govoplan-scheduling.self_enrollment.status_exhausted": "Vollständig belegt",
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Die Kalenderbereinigung erfordert Aufmerksamkeit.",
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} Vorgänge für vorläufige Reservierungen stehen noch aus. Gleichen Sie fehlgeschlagene ausgehende Kalenderänderungen bei Bedarf ab und wiederholen Sie anschließend die ursprüngliche Entscheidungs- oder Abbruchaktion.",
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
+7 -1
View File
@@ -9,6 +9,7 @@ import "./styles/scheduling.css";
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
const SchedulingEnrollmentPage = lazy(() => import("./features/scheduling/SchedulingEnrollmentPage"));
const scheduleRead = ["scheduling:schedule:read"];
const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
@@ -59,7 +60,7 @@ const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
export const schedulingModule: PlatformWebModule = {
id: "scheduling",
label: "Scheduling",
version: "0.1.11",
version: "0.1.18",
dependencies: ["poll"],
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations,
@@ -81,6 +82,11 @@ export const schedulingModule: PlatformWebModule = {
path: "/scheduling/public/:requestId/:token",
order: 10,
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
},
{
path: "/scheduling/enrol/:requestId/:token",
order: 11,
render: ({ settings, auth }) => createElement(SchedulingEnrollmentPage, { settings, auth })
}
],
uiCapabilities: {
+30
View File
@@ -403,6 +403,36 @@
max-width: 560px;
}
.scheduling-enrollment-confirmation {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 16px 0;
}
.scheduling-enrollment-confirmation span {
display: grid;
gap: 4px;
}
.scheduling-enrollment-confirmation small,
.scheduling-enrollment-links small {
color: var(--muted);
}
.scheduling-enrollment-modes,
.scheduling-enrollment-links {
display: grid;
gap: 10px;
margin-top: 14px;
}
.scheduling-enrollment-links .scheduling-compact-row > span:first-child {
display: grid;
flex: 1;
gap: 3px;
}
@media (max-width: 900px) {
.scheduling-workspace-layout {
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);