feat: add governed public self-enrollment links
This commit is contained in:
@@ -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\}/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user