@semics-tech/emdash-esign 0.0.0-bootstrap.0 → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -2
  3. package/dist/index.d.mts +5 -0
  4. package/dist/index.mjs +346 -0
  5. package/dist/manifest.json +354 -0
  6. package/dist/plugin.d.mts +7 -0
  7. package/dist/plugin.mjs +38 -0
  8. package/dist/ts/astro/lib/call.d.ts +56 -0
  9. package/dist/ts/astro/lib/call.js +138 -0
  10. package/dist/ts/integration/index.d.ts +32 -0
  11. package/dist/ts/integration/index.js +49 -0
  12. package/dist/ts/ui/index.d.ts +6 -0
  13. package/dist/ts/ui/index.js +35 -0
  14. package/emdash-plugin.jsonc +144 -0
  15. package/package.json +70 -10
  16. package/src/astro/SelfServeBlock.astro +84 -0
  17. package/src/astro/SignPage.astro +53 -0
  18. package/src/astro/SignedCopyPage.astro +48 -0
  19. package/src/astro/SigningCeremony.astro +264 -0
  20. package/src/astro/SnapshotBlocks.astro +35 -0
  21. package/src/astro/SnapshotInline.astro +28 -0
  22. package/src/astro/VerifyDocument.astro +70 -0
  23. package/src/astro/VerifyPage.astro +41 -0
  24. package/src/astro/index.js +21 -0
  25. package/src/astro/lib/assets.ts +156 -0
  26. package/src/astro/lib/call.ts +205 -0
  27. package/src/astro/lib/csp.ts +57 -0
  28. package/src/astro/lib/csrf.ts +33 -0
  29. package/src/astro/lib/markdown.ts +363 -0
  30. package/src/astro/lib/self-serve.ts +149 -0
  31. package/src/astro/lib/sign.ts +421 -0
  32. package/src/astro/self-serve-endpoint.ts +11 -0
  33. package/src/astro/virtual.d.ts +10 -0
  34. package/src/client/ceremony.d.ts +31 -0
  35. package/src/client/ceremony.js +218 -0
  36. package/src/client/index.ts +5 -0
  37. package/src/client/pad.d.ts +24 -0
  38. package/src/client/pad.js +232 -0
  39. package/src/client/verify.d.ts +3 -0
  40. package/src/client/verify.js +88 -0
  41. package/src/i18n/ar.ts +132 -0
  42. package/src/i18n/en.ts +136 -0
  43. package/src/i18n/index.ts +121 -0
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Browser helpers for the `/verify` page (SU-FR-02, ES-US-6). The implementation is plain
3
+ * JavaScript (`verify.js`) because the page inlines its exact text under a hashed CSP.
4
+ */
5
+ export { enhanceVerify, sha256Hex } from "./verify.js";
@@ -0,0 +1,24 @@
1
+ // Types for pad.js, which is plain JavaScript so the page can inline its exact text (CSP hash).
2
+ export type Point = [number, number];
3
+ export interface PadSize {
4
+ w: number;
5
+ h: number;
6
+ }
7
+
8
+ export function padPoint(
9
+ event: { clientX: number; clientY: number },
10
+ rect: { left: number; top: number; width: number; height: number },
11
+ size: PadSize,
12
+ ): Point;
13
+
14
+ export function createPad(size: PadSize): {
15
+ start(x: number, y: number): void;
16
+ move(x: number, y: number): void;
17
+ end(): void;
18
+ undo(): void;
19
+ clear(): void;
20
+ strokes(): Point[][];
21
+ value(): string;
22
+ };
23
+
24
+ export function enhancePads(root: Document): void;
@@ -0,0 +1,232 @@
1
+ // @ts-check
2
+ /**
3
+ * The signature pad (SU-FR-05). Progressive enhancement for the `[data-esign-pad-slot]` in
4
+ * <SigningCeremony>: without this script the signer types their name; with it they may also draw.
5
+ *
6
+ * The drawing is kept as vectors, never pixels: `{ v: 1, w, h, strokes }` where each stroke is a
7
+ * list of whole-pixel `[x, y]` points in pad coordinates (y down), written as JSON to the hidden
8
+ * `signatureStrokes` input, followed by a bubbling `input` event. The canvas is only a view of those
9
+ * strokes; nothing reads it back. Pointer events cover mouse, touch and pen. Clear and Undo are real
10
+ * buttons, so they work from the keyboard; the typed name stays the accessible way to sign.
11
+ *
12
+ * Plain JavaScript, no imports: the page inlines this file's exact text and allows it by its
13
+ * SHA-256 in the Content-Security-Policy (src/astro/lib/csp.ts), so it must not be transformed.
14
+ * No HTML is ever built from strings; look comes from classes in the ceremony stylesheet.
15
+ */
16
+
17
+ /** At most this many strokes and points, so the JSON always fits `public/submit` (64 strokes, 32 KB). */
18
+ const MAX_STROKES = 64;
19
+ const MAX_POINTS = 1500;
20
+
21
+ /**
22
+ * @typedef {[number, number]} Point
23
+ * @typedef {{ w: number; h: number }} PadSize
24
+ */
25
+
26
+ /**
27
+ * A pointer's position in pad coordinates, clamped to the pad.
28
+ * @param {{ clientX: number; clientY: number }} event
29
+ * @param {{ left: number; top: number; width: number; height: number }} rect the canvas's box on screen
30
+ * @param {PadSize} size
31
+ * @returns {Point}
32
+ */
33
+ export function padPoint(event, rect, size) {
34
+ const clamp = (/** @type {number} */ n, /** @type {number} */ max) => Math.min(max, Math.max(0, n));
35
+ return [
36
+ Math.round(clamp(((event.clientX - rect.left) * size.w) / rect.width, size.w)),
37
+ Math.round(clamp(((event.clientY - rect.top) * size.h) / rect.height, size.h)),
38
+ ];
39
+ }
40
+
41
+ /**
42
+ * The strokes drawn so far.
43
+ * @param {PadSize} size
44
+ */
45
+ export function createPad(size) {
46
+ /** @type {Point[][]} */
47
+ const strokes = [];
48
+ /** @type {Point[] | undefined} */
49
+ let current;
50
+ let points = 0;
51
+ const round = (/** @type {number} */ n) => Math.round(n);
52
+ return {
53
+ /** Begins a stroke; ignored once the stroke limit is reached. */
54
+ start(/** @type {number} */ x, /** @type {number} */ y) {
55
+ if (strokes.length >= MAX_STROKES) return;
56
+ current = [[round(x), round(y)]];
57
+ strokes.push(current);
58
+ points++;
59
+ },
60
+ /** Extends the current stroke; repeated points and points over the limit are dropped. */
61
+ move(/** @type {number} */ x, /** @type {number} */ y) {
62
+ if (!current || points >= MAX_POINTS) return;
63
+ const last = current[current.length - 1];
64
+ const point = /** @type {Point} */ ([round(x), round(y)]);
65
+ if (last && last[0] === point[0] && last[1] === point[1]) return;
66
+ current.push(point);
67
+ points++;
68
+ },
69
+ end() {
70
+ current = undefined;
71
+ },
72
+ undo() {
73
+ const removed = strokes.pop();
74
+ if (removed) points -= removed.length;
75
+ current = undefined;
76
+ },
77
+ clear() {
78
+ strokes.length = 0;
79
+ points = 0;
80
+ current = undefined;
81
+ },
82
+ strokes: () => strokes,
83
+ /** The JSON for the hidden input, or "" when nothing is drawn. */
84
+ value: () => (strokes.length === 0 ? "" : JSON.stringify({ v: 1, w: size.w, h: size.h, strokes })),
85
+ };
86
+ }
87
+
88
+ /** The pad's own coordinate space; the canvas scales to its container. */
89
+ const SIZE = { w: 600, h: 200 };
90
+
91
+ /**
92
+ * @param {Document} doc
93
+ * @param {string} tag
94
+ * @param {Record<string, string>} attrs
95
+ * @param {string} [text]
96
+ */
97
+ function make(doc, tag, attrs, text) {
98
+ const el = doc.createElement(tag);
99
+ for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
100
+ if (text) el.textContent = text;
101
+ return el;
102
+ }
103
+
104
+ /**
105
+ * Adds a pad to one slot.
106
+ * @param {HTMLElement} slot
107
+ */
108
+ function enhanceSlot(slot) {
109
+ const doc = slot.ownerDocument;
110
+ const input = /** @type {HTMLInputElement | null} */ (slot.querySelector('input[name="signatureStrokes"]'));
111
+ if (!input || slot.querySelector("[data-esign-pad]")) return;
112
+ const pad = createPad(SIZE);
113
+ // The pad's messages in the page's language, rendered by the component (T102): no text of its own.
114
+ /** @type {Record<string, string>} */
115
+ let m = {};
116
+ try {
117
+ m = JSON.parse(slot.dataset.messages ?? "{}");
118
+ } catch {
119
+ // No messages: the pad shows their keys.
120
+ }
121
+ const say = (/** @type {string} */ key, /** @type {number} */ count = 0) =>
122
+ (m[key] ?? key).replace("{count}", new Intl.NumberFormat(doc.documentElement.lang || undefined).format(count));
123
+ const rules = new Intl.PluralRules(doc.documentElement.lang || undefined);
124
+
125
+ const wrap = make(doc, "div", { class: "semics-pad", "data-esign-pad": "" });
126
+ const canvas = /** @type {HTMLCanvasElement} */ (
127
+ make(doc, "canvas", {
128
+ class: "semics-pad-canvas",
129
+ role: "img",
130
+ "aria-label": say("canvas"),
131
+ "data-testid": "sign-pad",
132
+ })
133
+ );
134
+ canvas.width = SIZE.w;
135
+ canvas.height = SIZE.h;
136
+ const undo = make(doc, "button", { type: "button", "data-esign-pad-undo": "" }, say("undo"));
137
+ const clear = make(doc, "button", { type: "button", "data-esign-pad-clear": "" }, say("clear"));
138
+ const status = make(doc, "p", { class: "semics-note", "aria-live": "polite", "data-esign-pad-status": "" });
139
+ const bar = make(doc, "div", { class: "semics-pad-bar" });
140
+ bar.append(undo, clear);
141
+ const label = make(doc, "p", { class: "semics-note" }, say("intro"));
142
+ wrap.append(label, canvas, bar, status);
143
+ slot.insertBefore(wrap, input);
144
+
145
+ const ctx = canvas.getContext("2d");
146
+ const paint = () => {
147
+ const strokes = pad.strokes();
148
+ if (ctx) {
149
+ ctx.clearRect(0, 0, SIZE.w, SIZE.h);
150
+ ctx.lineWidth = 2.5;
151
+ ctx.lineCap = "round";
152
+ ctx.lineJoin = "round";
153
+ ctx.strokeStyle = getComputedStyle(canvas).color;
154
+ ctx.fillStyle = ctx.strokeStyle;
155
+ for (const stroke of strokes) {
156
+ const first = stroke[0];
157
+ if (!first) continue;
158
+ if (stroke.length === 1) {
159
+ ctx.beginPath();
160
+ ctx.arc(first[0], first[1], 1.5, 0, Math.PI * 2);
161
+ ctx.fill();
162
+ continue;
163
+ }
164
+ ctx.beginPath();
165
+ ctx.moveTo(first[0], first[1]);
166
+ for (const p of stroke.slice(1)) ctx.lineTo(p[0], p[1]);
167
+ ctx.stroke();
168
+ }
169
+ }
170
+ const empty = strokes.length === 0;
171
+ undo.toggleAttribute("disabled", empty);
172
+ clear.toggleAttribute("disabled", empty);
173
+ };
174
+ const publish = (/** @type {string} */ note) => {
175
+ paint();
176
+ input.value = pad.value();
177
+ status.textContent = note;
178
+ input.dispatchEvent(new Event("input", { bubbles: true }));
179
+ };
180
+
181
+ /** @type {number | undefined} */
182
+ let activePointer;
183
+ const at = (/** @type {PointerEvent} */ e) => padPoint(e, canvas.getBoundingClientRect(), SIZE);
184
+ canvas.addEventListener("pointerdown", (e) => {
185
+ if (activePointer !== undefined || (e.pointerType === "mouse" && e.button !== 0)) return;
186
+ activePointer = e.pointerId;
187
+ try {
188
+ canvas.setPointerCapture(e.pointerId);
189
+ } catch {
190
+ // A pointer the browser no longer tracks: the stroke still draws while it stays over the pad.
191
+ }
192
+ e.preventDefault();
193
+ pad.start(...at(e));
194
+ paint();
195
+ });
196
+ canvas.addEventListener("pointermove", (e) => {
197
+ if (e.pointerId !== activePointer) return;
198
+ e.preventDefault();
199
+ // Coalesced events keep fast strokes smooth.
200
+ for (const c of e.getCoalescedEvents?.() ?? [e]) pad.move(...at(c));
201
+ paint();
202
+ });
203
+ const finish = (/** @type {PointerEvent} */ e) => {
204
+ if (e.pointerId !== activePointer) return;
205
+ activePointer = undefined;
206
+ pad.end();
207
+ const count = pad.strokes().length;
208
+ const form = `drawn.${rules.select(count)}`;
209
+ publish(say(m[form] === undefined ? "drawn.other" : form, count));
210
+ };
211
+ canvas.addEventListener("pointerup", finish);
212
+ canvas.addEventListener("pointercancel", finish);
213
+ undo.addEventListener("click", () => {
214
+ pad.undo();
215
+ publish(say("undone"));
216
+ });
217
+ clear.addEventListener("click", () => {
218
+ pad.clear();
219
+ publish(say("cleared"));
220
+ });
221
+ paint();
222
+ }
223
+
224
+ /**
225
+ * Wires every pad slot on the page.
226
+ * @param {Document} root
227
+ */
228
+ export function enhancePads(root) {
229
+ for (const slot of root.querySelectorAll("[data-esign-pad-slot]")) enhanceSlot(/** @type {HTMLElement} */ (slot));
230
+ }
231
+
232
+ if (typeof document !== "undefined") enhancePads(document);
@@ -0,0 +1,3 @@
1
+ // Types for verify.js, which is plain JavaScript so the page can inline its exact text (CSP hash).
2
+ export function sha256Hex(bytes: ArrayBuffer): Promise<string>;
3
+ export function enhanceVerify(root: Pick<Document, "querySelectorAll" | "getElementById">): void;
@@ -0,0 +1,88 @@
1
+ // @ts-check
2
+ /**
3
+ * Progressive enhancement for the `/verify` page (SU-FR-02, ES-US-6). The form works without this
4
+ * script (the visitor pastes the SHA-256). With it, choosing or dropping the signed PDF hashes it
5
+ * in the browser with WebCrypto, fills the fingerprint field and submits the form. Only the hash
6
+ * is ever posted: the file control has no `name`, and this script has no network code at all.
7
+ *
8
+ * Plain JavaScript, no imports: the page inlines this file's exact text and allows it by its
9
+ * SHA-256 in the Content-Security-Policy (src/astro/lib/csp.ts), so it must not be transformed.
10
+ * Text only ever goes in through `textContent` and `value`.
11
+ */
12
+
13
+ /**
14
+ * Lower-case hex SHA-256 of `bytes`.
15
+ * @param {ArrayBuffer} bytes
16
+ * @returns {Promise<string>}
17
+ */
18
+ export async function sha256Hex(bytes) {
19
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
20
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
21
+ }
22
+
23
+ /**
24
+ * Hashes `chosen` into `target`, says what happened in `status`, and submits `form`.
25
+ * @param {{ name?: string; arrayBuffer(): Promise<ArrayBuffer> }} chosen
26
+ * @param {{ value: string }} target
27
+ * @param {{ textContent: string | null } | null} status
28
+ * @param {{ requestSubmit(): void } | null} form
29
+ * @param {Record<string, string>} m the page's messages (keys without `verify.`, T102)
30
+ */
31
+ async function hashInto(chosen, target, status, form, m) {
32
+ /** @param {string} key @param {string} [name] */
33
+ const say = (key, name = "") => {
34
+ if (status) status.textContent = (m[key] ?? key).replace("{name}", name);
35
+ };
36
+ say("reading");
37
+ let hash;
38
+ try {
39
+ hash = await sha256Hex(await chosen.arrayBuffer());
40
+ } catch {
41
+ say("unreadable");
42
+ return;
43
+ }
44
+ target.value = hash;
45
+ say("done", chosen.name || (m.theFile ?? "theFile"));
46
+ form?.requestSubmit();
47
+ }
48
+
49
+ /**
50
+ * Wires every `input[data-esign-file]` (and each `[data-esign-drop]` zone) to the text input named
51
+ * by the file input's `data-target` id.
52
+ * @param {Pick<Document, "querySelectorAll" | "getElementById">} root
53
+ */
54
+ export function enhanceVerify(root) {
55
+ for (const file of /** @type {NodeListOf<HTMLInputElement>} */ (root.querySelectorAll("input[data-esign-file]"))) {
56
+ file.hidden = false;
57
+ const target = /** @type {HTMLInputElement | null} */ (root.getElementById(file.dataset.target ?? ""));
58
+ const status = root.getElementById(file.dataset.status ?? "");
59
+ const form = file.closest("form");
60
+ if (!target) continue;
61
+ /** @type {Record<string, string>} */
62
+ let m = {};
63
+ try {
64
+ m = JSON.parse(file.dataset.messages ?? "{}");
65
+ } catch {
66
+ // No messages: the status line shows their keys.
67
+ }
68
+ file.addEventListener("change", async () => {
69
+ const chosen = file.files?.[0];
70
+ if (chosen) await hashInto(chosen, target, status, form, m);
71
+ });
72
+ for (const zone of /** @type {NodeListOf<HTMLElement>} */ (root.querySelectorAll("[data-esign-drop]"))) {
73
+ zone.addEventListener("dragover", (event) => {
74
+ event.preventDefault();
75
+ zone.classList.toggle("is-over", true);
76
+ });
77
+ zone.addEventListener("dragleave", () => zone.classList.toggle("is-over", false));
78
+ zone.addEventListener("drop", async (event) => {
79
+ event.preventDefault();
80
+ zone.classList.toggle("is-over", false);
81
+ const dropped = /** @type {DragEvent} */ (event).dataTransfer?.files?.[0];
82
+ if (dropped) await hashInto(dropped, target, status, form, m);
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ if (typeof document !== "undefined") enhanceVerify(document);
package/src/i18n/ar.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type { Messages } from "./index.ts";
2
+
3
+ /** Arabic (right to left). Plural forms follow `Intl.PluralRules("ar")`: zero, one, two, few, many, other. */
4
+ export const ar: Messages = {
5
+ "error.SIGNING_TOKEN_INVALID": "رابط التوقيع هذا غير صالح. تأكد من أنك فتحت أحدث رابط في بريدك الإلكتروني.",
6
+ "error.SIGNING_TOKEN_EXPIRED": "انتهت صلاحية رابط التوقيع هذا. اطلب من المرسل رابطًا جديدًا.",
7
+ "error.ENVELOPE_CLOSED": "لم يعد من الممكن توقيع هذا المستند.",
8
+ "error.ENVELOPE_NOT_FOUND": "تعذّر العثور على هذا المستند.",
9
+ "error.RATE_LIMITED": "محاولات كثيرة جدًا. يُرجى الانتظار بضع دقائق ثم المحاولة مرة أخرى.",
10
+ "error.TOKEN_IN_QUERY": "هذا الرابط غير صالح. افتح الرابط من بريدك الإلكتروني.",
11
+ "error.SESSION_REQUIRED": "يُرجى إعادة تحميل الصفحة والمحاولة مرة أخرى.",
12
+ "error.BAD_REQUEST": "يُرجى مراجعة النموذج والمحاولة مرة أخرى.",
13
+ "error.SIGNING_OUT_OF_TURN": "يجب أن يوقّع موقّع آخر أولًا. ستصلك رسالة بالبريد الإلكتروني عندما يحين دورك.",
14
+ "error.OTP_REQUIRED": "أدخل الرمز الذي أرسلناه إلى بريدك الإلكتروني قبل التوقيع.",
15
+ "error.OTP_INVALID": "هذا الرمز غير صحيح. راجع أحدث رسالة وحاول مرة أخرى.",
16
+ "error.OTP_EXPIRED": "انتهت صلاحية هذا الرمز. اطلب رمزًا جديدًا.",
17
+ "error.OTP_LOCKED": "رموز خاطئة كثيرة جدًا. اطلب رمزًا جديدًا.",
18
+ "error.OTP_WAIT": "أُرسل رمز قبل لحظات. يُرجى الانتظار قبل طلب رمز آخر.",
19
+ "error.OTP_SEND_FAILED": "تعذّر علينا إرسال رمز بالبريد الإلكتروني. يُرجى المحاولة مرة أخرى بعد قليل.",
20
+ "error.STROKES_TOO_LARGE": "توقيعك المرسوم مفصّل أكثر من اللازم. امسحه ووقّع مرة أخرى بشكل أبسط.",
21
+ "error.INTERNAL": "حدث خطأ ما. يُرجى المحاولة مرة أخرى.",
22
+
23
+ "sign.title": "توقيع مستند",
24
+ "sign.closed.completed": "تم توقيع هذا المستند.",
25
+ "sign.closed.declined": "رُفض هذا المستند.",
26
+ "sign.closed.other": "لم يعد من الممكن توقيع هذا المستند.",
27
+ "sign.download": "نزّل ملف PDF الموقّع",
28
+ "sign.preparingShort": "جارٍ تجهيز ملف PDF الموقّع؛ أعد التحميل بعد دقيقة.",
29
+ "sign.preparing": "جارٍ تجهيز ملف PDF الموقّع. أعد تحميل هذه الصفحة بعد دقيقة للحصول على رابط التنزيل.",
30
+ "sign.return": "العودة إلى الموقع",
31
+ "sign.waiting.titled": "لقد وقّعت «{title}». ننتظر بقية الموقّعين.",
32
+ "sign.waiting.untitled": "لقد وقّعت هذا المستند. ننتظر بقية الموقّعين.",
33
+ "sign.waiting.email": "عندما يوقّع الجميع، ستصلك رسالة بالبريد الإلكتروني فيها رابط إلى النسخة الموقّعة.",
34
+ "sign.thanks": "شكرًا لك. سُجّل توقيعك.",
35
+ "sign.othersToSign":
36
+ "ما زال على آخرين أن يوقّعوا. عندما يوقّع الجميع، ستصلك رسالة بالبريد الإلكتروني فيها رابط إلى النسخة الموقّعة.",
37
+ "sign.declined": "لقد رفضت التوقيع. أُبلغ المرسل بذلك.",
38
+ "sign.signingAs": "التوقيع باسم {name}",
39
+ "sign.signingAsRole": "التوقيع باسم {name} ({role})",
40
+ "sign.document": "المستند المطلوب توقيعه",
41
+ "sign.pdf.intro": "المستند ملف PDF: {name} ({pages}، {size}). اقرأه قبل أن توقّع.",
42
+ "sign.pdf.pages.one": "صفحة واحدة",
43
+ "sign.pdf.pages.two": "صفحتان",
44
+ "sign.pdf.pages.few": "{count} صفحات",
45
+ "sign.pdf.pages.other": "{count} صفحة",
46
+ "sign.pdf.kb": "{n} كيلوبايت",
47
+ "sign.pdf.mb": "{n} ميغابايت",
48
+ "sign.pdf.links": "{open} أو {download}",
49
+ "sign.pdf.open": "افتح ملف PDF",
50
+ "sign.pdf.download": "نزّله",
51
+ "sign.pdf.unavailable": "لا يمكن عرض ملف PDF هذا الآن، لذا لا يمكن توقيعه بعد. يُرجى المحاولة لاحقًا.",
52
+ "sign.pdf.fingerprint": "بصمة SHA-256 لملف PDF الذي توقّعه: {hash}",
53
+ "sign.exact.summary": "النص الدقيق الذي توقّعه",
54
+ "sign.exact.fingerprint": "بصمة SHA-256: {hash}",
55
+ "sign.otp.label": "رمز البريد الإلكتروني",
56
+ "sign.otp.intro": "للتوقيع، أدخل أولًا الرمز الذي نرسله إلى بريدك الإلكتروني.",
57
+ "sign.otp.sent": "أرسلنا إليك رمزًا بالبريد الإلكتروني. تنتهي صلاحيته بعد 10 دقائق.",
58
+ "sign.otp.send": "أرسل إليّ رمزًا بالبريد الإلكتروني",
59
+ "sign.otp.code": "الرمز",
60
+ "sign.otp.hint": "الرمز المكوّن من 6 أرقام في الرسالة التي أرسلناها إليك.",
61
+ "sign.otp.verify": "تحقق من الرمز",
62
+ "sign.details": "بياناتك",
63
+ "sign.required": "{label} (مطلوب)",
64
+ "sign.signature": "توقيعك",
65
+ "sign.typedName": "اكتب اسمك الكامل للتوقيع",
66
+ "sign.agree": "أقصد أن يكون التوقيع أعلاه توقيعي على هذا المستند.",
67
+ "sign.submit": "وقّع المستند",
68
+ "sign.decline.label": "أو ارفض التوقيع (السبب اختياري)",
69
+ "sign.decline.button": "رفض",
70
+
71
+ "ceremony.missing": "للتوقيع، يُرجى {items}.",
72
+ "ceremony.separator": "، ",
73
+ "ceremony.need.typedName": "كتابة اسمك الكامل",
74
+ "ceremony.need.consent": "تحديد مربع الموافقة",
75
+ "ceremony.need.agree": "تأكيد أنك تقصد التوقيع",
76
+ "ceremony.need.field": "تعبئة {label}",
77
+ "ceremony.need.fields": "الحقول المطلوبة",
78
+ "ceremony.download": "نزّل ملف PDF الموقّع",
79
+ "ceremony.slow": "يستغرق تجهيز ملف PDF الموقّع وقتًا أطول من المعتاد. أعد تحميل هذه الصفحة بعد بضع دقائق.",
80
+
81
+ "pad.canvas": "مساحة رسم التوقيع. اختياري: ارسم بإصبعك أو بقلم أو بالفأرة. كتابة اسمك تكفي للتوقيع.",
82
+ "pad.intro": "اختياري: ارسم توقيعك أدناه. كتابة اسمك تبقى مطلوبة.",
83
+ "pad.undo": "تراجع",
84
+ "pad.clear": "مسح",
85
+ "pad.drawn.zero": "رُسم التوقيع: لا خطوط.",
86
+ "pad.drawn.one": "رُسم التوقيع: خط واحد.",
87
+ "pad.drawn.two": "رُسم التوقيع: خطان.",
88
+ "pad.drawn.few": "رُسم التوقيع: {count} خطوط.",
89
+ "pad.drawn.other": "رُسم التوقيع: {count} خطًا.",
90
+ "pad.undone": "أُزيل آخر خط.",
91
+ "pad.cleared": "مُسح التوقيع.",
92
+
93
+ "signed.title": "نسختك الموقّعة",
94
+ "signed.unsigned": "لم يُوقّع هذا المستند بعد. {link}.",
95
+ "signed.openLink": "افتح صفحة التوقيع",
96
+
97
+ "verify.title": "تحقق من مستند موقّع",
98
+ "verify.intro":
99
+ "تحقق من أن ملف PDF الموقّع هو الذي أصدرناه وأن سجل التدقيق الخاص به سليم. اختر ملف PDF أو أفلته هنا، أو أدخل بصمة SHA-256 الخاصة به (الظاهرة في صفحة الشهادة). يبقى الملف على جهازك: تُرسل بصمته فقط. يؤكد هذا السجل فقط؛ ولا يحدد هوية أي شخص.",
100
+ "verify.file": "ملف PDF الموقّع",
101
+ "verify.sha": "بصمة SHA-256 للمستند",
102
+ "verify.button": "تحقق",
103
+ "verify.issued": "صدر هنا مستند موقّع بهذه البصمة.",
104
+ "verify.issuedOn": "صدر هنا مستند موقّع بهذه البصمة واكتمل في {date}.",
105
+ "verify.chain.ok": "سجل التدقيق الخاص به سليم.",
106
+ "verify.chain.bad": "تحذير: سجل التدقيق الخاص به لا يجتاز التحقق.",
107
+ "verify.chain.long": "سجل التدقيق الخاص به أطول من أن يُتحقق منه عبر الإنترنت.",
108
+ "verify.none": "لم يُعثر على مستند موقّع بهذه البصمة.",
109
+ "verify.retention": "تُحذف السجلات الموقّعة عند انتهاء مدة الاحتفاظ بها، لذا قد لا يظهر مستند وُقّع منذ زمن طويل.",
110
+ "verify.error.BAD_REQUEST": "أدخل الأحرف الست عشرية الأربعة والستين لبصمة SHA-256 الخاصة بالمستند.",
111
+ "verify.reading": "جارٍ حساب البصمة في متصفحك. لا يُرفع الملف.",
112
+ "verify.unreadable": "تعذّرت قراءة هذا الملف. الصق بصمة SHA-256 الخاصة به بدلًا من ذلك.",
113
+ "verify.done": "حُسبت بصمة {name} في متصفحك. جارٍ التحقق.",
114
+ "verify.theFile": "الملف",
115
+
116
+ "selfServe.heading": "وقّع هذا المستند",
117
+ "selfServe.unavailable": "هذا النموذج غير متاح الآن.",
118
+ "selfServe.sent": "تحقق من بريدك الإلكتروني. أرسلنا رابطًا إلى العنوان الذي أدخلته؛ افتحه لقراءة المستند وتوقيعه.",
119
+ "selfServe.name": "اسمك",
120
+ "selfServe.email": "بريدك الإلكتروني",
121
+ "selfServe.honeypot": "اترك هذا الحقل فارغًا",
122
+ "selfServe.needsJs": "يستخدم هذا النموذج تحققًا أمنيًا يتطلب JavaScript.",
123
+ "selfServe.note": "سنرسل إليك رابطًا للتوقيع بالبريد الإلكتروني. لا يستطيع التوقيع إلا من يستطيع فتح تلك الرسالة.",
124
+ "selfServe.button": "أرسل إليّ الرابط",
125
+ "selfServe.error.TURNSTILE_FAILED": "لم ينجح التحقق الأمني. يُرجى المحاولة مرة أخرى.",
126
+ "selfServe.error.RATE_LIMITED": "محاولات كثيرة جدًا. يُرجى الانتظار بضع دقائق ثم المحاولة مرة أخرى.",
127
+ "selfServe.error.SELF_SERVE_FULL": "بلغ هذا النموذج حدّه لهذا اليوم. يُرجى المحاولة غدًا.",
128
+ "selfServe.error.BAD_REQUEST": "يُرجى مراجعة اسمك وبريدك الإلكتروني والبيانات الأخرى، ثم المحاولة مرة أخرى.",
129
+ "selfServe.error.TEMPLATE_NOT_FOUND": "هذا النموذج غير متاح الآن.",
130
+ "selfServe.error.SITE_URL_MISSING": "هذا النموذج غير متاح الآن.",
131
+ "selfServe.error.INTERNAL": "حدث خطأ ما. يُرجى المحاولة لاحقًا.",
132
+ };
package/src/i18n/en.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * English, the source language of the E-sign UI kit (T102, SU-NFR-06). Every other language in this
3
+ * folder must have exactly these keys (a test checks), with the same `{placeholders}`. A key ending in
4
+ * `.one` / `.other` is a plural: other languages may add the `Intl.PluralRules` forms they need
5
+ * (`.zero`, `.two`, `.few`, `.many`). The consent wording is the site's own setting, not a message here.
6
+ * See `docs/i18n.md`.
7
+ */
8
+ export const en = {
9
+ "error.SIGNING_TOKEN_INVALID": "This signing link is not valid. Check you opened the latest link from your email.",
10
+ "error.SIGNING_TOKEN_EXPIRED": "This signing link has expired. Ask the sender for a new one.",
11
+ "error.ENVELOPE_CLOSED": "This document can no longer be signed.",
12
+ "error.ENVELOPE_NOT_FOUND": "This document could not be found.",
13
+ "error.RATE_LIMITED": "Too many attempts. Please wait a few minutes and try again.",
14
+ "error.TOKEN_IN_QUERY": "This link is not valid. Open the link from your email.",
15
+ "error.SESSION_REQUIRED": "Please reload the page and try again.",
16
+ "error.BAD_REQUEST": "Please check the form and try again.",
17
+ "error.SIGNING_OUT_OF_TURN": "Another signer has to sign first. You will get an email when it is your turn.",
18
+ "error.OTP_REQUIRED": "Enter the code we emailed you before signing.",
19
+ "error.OTP_INVALID": "That code is not right. Check the latest email and try again.",
20
+ "error.OTP_EXPIRED": "That code has expired. Ask for a new code.",
21
+ "error.OTP_LOCKED": "Too many wrong codes. Ask for a new code.",
22
+ "error.OTP_WAIT": "A code was sent a moment ago. Please wait before asking for another.",
23
+ "error.OTP_SEND_FAILED": "We could not email a code. Please try again shortly.",
24
+ "error.STROKES_TOO_LARGE": "Your drawn signature is too detailed. Clear it and sign again more simply.",
25
+ "error.INTERNAL": "Something went wrong. Please try again.",
26
+
27
+ "sign.title": "Sign a document",
28
+ "sign.closed.completed": "This document has been signed.",
29
+ "sign.closed.declined": "This document was declined.",
30
+ "sign.closed.other": "This document can no longer be signed.",
31
+ "sign.download": "Download the signed PDF",
32
+ "sign.preparingShort": "The signed PDF is being prepared; reload in a minute.",
33
+ "sign.preparing": "The signed PDF is being prepared. Reload this page in a minute for the download link.",
34
+ "sign.return": "Return to the site",
35
+ "sign.waiting.titled": 'You have signed "{title}". We are waiting for the other signers.',
36
+ "sign.waiting.untitled": "You have signed this document. We are waiting for the other signers.",
37
+ "sign.waiting.email": "When everyone has signed, you will get an email with a link to the signed copy.",
38
+ "sign.thanks": "Thank you. Your signature has been recorded.",
39
+ "sign.othersToSign":
40
+ "Other people still have to sign. When everyone has signed, you will get an email with a link to the signed copy.",
41
+ "sign.declined": "You declined to sign. The sender has been told.",
42
+ "sign.signingAs": "Signing as {name}",
43
+ "sign.signingAsRole": "Signing as {name} ({role})",
44
+ "sign.document": "Document to sign",
45
+ "sign.pdf.intro": "The document is a PDF file: {name} ({pages}, {size}). Read it before you sign.",
46
+ "sign.pdf.pages.one": "{count} page",
47
+ "sign.pdf.pages.other": "{count} pages",
48
+ "sign.pdf.kb": "{n} KB",
49
+ "sign.pdf.mb": "{n} MB",
50
+ "sign.pdf.links": "{open} or {download}",
51
+ "sign.pdf.open": "Open the PDF",
52
+ "sign.pdf.download": "download it",
53
+ "sign.pdf.unavailable": "This PDF cannot be shown right now, so it cannot be signed yet. Please try again later.",
54
+ "sign.pdf.fingerprint": "SHA-256 fingerprint of the PDF file you are signing: {hash}",
55
+ "sign.exact.summary": "Exact text you are signing",
56
+ "sign.exact.fingerprint": "SHA-256 fingerprint: {hash}",
57
+ "sign.otp.label": "Email code",
58
+ "sign.otp.intro": "To sign, first enter the code we email to you.",
59
+ "sign.otp.sent": "We emailed you a code. It expires in 10 minutes.",
60
+ "sign.otp.send": "Email me a code",
61
+ "sign.otp.code": "Code",
62
+ "sign.otp.hint": "The 6-digit code from the email we sent you.",
63
+ "sign.otp.verify": "Verify code",
64
+ "sign.details": "Your details",
65
+ "sign.required": "{label} (required)",
66
+ "sign.signature": "Your signature",
67
+ "sign.typedName": "Type your full name to sign",
68
+ "sign.agree": "I intend the signature above to be my signature on this document.",
69
+ "sign.submit": "Sign document",
70
+ "sign.decline.label": "Or decline to sign (optional reason)",
71
+ "sign.decline.button": "Decline",
72
+
73
+ "ceremony.missing": "To sign, please {items}.",
74
+ "ceremony.separator": ", ",
75
+ "ceremony.need.typedName": "type your full name",
76
+ "ceremony.need.consent": "tick the consent box",
77
+ "ceremony.need.agree": "confirm you intend to sign",
78
+ "ceremony.need.field": "fill in {label}",
79
+ "ceremony.need.fields": "the required fields",
80
+ "ceremony.download": "Download the signed PDF",
81
+ "ceremony.slow": "The signed PDF is taking longer than usual. Reload this page in a few minutes.",
82
+
83
+ "pad.canvas":
84
+ "Signature drawing area. Optional: draw with a finger, pen or mouse. Typing your name is enough to sign.",
85
+ "pad.intro": "Optional: draw your signature below. Your typed name is still required.",
86
+ "pad.undo": "Undo",
87
+ "pad.clear": "Clear",
88
+ "pad.drawn.one": "Signature drawn: {count} stroke.",
89
+ "pad.drawn.other": "Signature drawn: {count} strokes.",
90
+ "pad.undone": "Last stroke removed.",
91
+ "pad.cleared": "Signature cleared.",
92
+
93
+ "signed.title": "Your signed copy",
94
+ "signed.unsigned": "This document has not been signed yet. {link}.",
95
+ "signed.openLink": "Open the signing page",
96
+
97
+ "verify.title": "Verify a signed document",
98
+ "verify.intro":
99
+ "Check that a signed PDF is the one we issued and that its audit trail is intact. Choose or drop the PDF, or enter its SHA-256 fingerprint (shown on the certificate page). The file stays on your device: only its fingerprint is sent. This confirms the record only; it does not identify anyone.",
100
+ "verify.file": "Signed PDF",
101
+ "verify.sha": "Document SHA-256",
102
+ "verify.button": "Verify",
103
+ "verify.issued": "A signed document with this fingerprint was issued here.",
104
+ "verify.issuedOn": "A signed document with this fingerprint was issued here and completed on {date}.",
105
+ "verify.chain.ok": "Its audit trail is intact.",
106
+ "verify.chain.bad": "Warning: its audit trail does not verify.",
107
+ "verify.chain.long": "Its audit trail is too long to check online.",
108
+ "verify.none": "No signed document with that fingerprint was found.",
109
+ "verify.retention":
110
+ "Signed records are deleted when their retention period ends, so a document signed long ago may no longer be listed.",
111
+ "verify.error.BAD_REQUEST": "Enter the 64 hexadecimal characters of the document's SHA-256.",
112
+ "verify.reading": "Fingerprinting in your browser. The file is not uploaded.",
113
+ "verify.unreadable": "Could not read that file. Paste its SHA-256 instead.",
114
+ "verify.done": "Fingerprinted {name} in your browser. Checking.",
115
+ "verify.theFile": "the file",
116
+
117
+ "selfServe.heading": "Sign this document",
118
+ "selfServe.unavailable": "This form is not available right now.",
119
+ "selfServe.sent":
120
+ "Check your email. We have sent a link to the address you gave; open it to read and sign the document.",
121
+ "selfServe.name": "Your name",
122
+ "selfServe.email": "Your email",
123
+ "selfServe.honeypot": "Leave this empty",
124
+ "selfServe.needsJs": "This form uses a security check that needs JavaScript.",
125
+ "selfServe.note": "We will email you a link to sign. Only someone who can open that email can sign.",
126
+ "selfServe.button": "Email me the link",
127
+ "selfServe.error.TURNSTILE_FAILED": "The security check did not pass. Please try again.",
128
+ "selfServe.error.RATE_LIMITED": "Too many attempts. Please wait a few minutes and try again.",
129
+ "selfServe.error.SELF_SERVE_FULL": "This form has reached its limit for today. Please try again tomorrow.",
130
+ "selfServe.error.BAD_REQUEST": "Please check your name, email address and the other details, then try again.",
131
+ "selfServe.error.TEMPLATE_NOT_FOUND": "This form is not available right now.",
132
+ "selfServe.error.SITE_URL_MISSING": "This form is not available right now.",
133
+ "selfServe.error.INTERNAL": "Something went wrong. Please try again later.",
134
+ };
135
+
136
+ export type MessageKey = keyof typeof en;