@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.
- package/LICENSE +21 -0
- package/README.md +104 -2
- package/dist/index.d.mts +5 -0
- package/dist/index.mjs +346 -0
- package/dist/manifest.json +354 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.mjs +38 -0
- package/dist/ts/astro/lib/call.d.ts +56 -0
- package/dist/ts/astro/lib/call.js +138 -0
- package/dist/ts/integration/index.d.ts +32 -0
- package/dist/ts/integration/index.js +49 -0
- package/dist/ts/ui/index.d.ts +6 -0
- package/dist/ts/ui/index.js +35 -0
- package/emdash-plugin.jsonc +144 -0
- package/package.json +70 -10
- package/src/astro/SelfServeBlock.astro +84 -0
- package/src/astro/SignPage.astro +53 -0
- package/src/astro/SignedCopyPage.astro +48 -0
- package/src/astro/SigningCeremony.astro +264 -0
- package/src/astro/SnapshotBlocks.astro +35 -0
- package/src/astro/SnapshotInline.astro +28 -0
- package/src/astro/VerifyDocument.astro +70 -0
- package/src/astro/VerifyPage.astro +41 -0
- package/src/astro/index.js +21 -0
- package/src/astro/lib/assets.ts +156 -0
- package/src/astro/lib/call.ts +205 -0
- package/src/astro/lib/csp.ts +57 -0
- package/src/astro/lib/csrf.ts +33 -0
- package/src/astro/lib/markdown.ts +363 -0
- package/src/astro/lib/self-serve.ts +149 -0
- package/src/astro/lib/sign.ts +421 -0
- package/src/astro/self-serve-endpoint.ts +11 -0
- package/src/astro/virtual.d.ts +10 -0
- package/src/client/ceremony.d.ts +31 -0
- package/src/client/ceremony.js +218 -0
- package/src/client/index.ts +5 -0
- package/src/client/pad.d.ts +24 -0
- package/src/client/pad.js +232 -0
- package/src/client/verify.d.ts +3 -0
- package/src/client/verify.js +88 -0
- package/src/i18n/ar.ts +132 -0
- package/src/i18n/en.ts +136 -0
- package/src/i18n/index.ts +121 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Injected at `/sign/[token]` by the integration (SU-FR-01). The one place a token may sit in a URL:
|
|
3
|
+
// this page's path. It sends `Referrer-Policy: no-referrer`, is never cached, and passes the token
|
|
4
|
+
// to the core only in POST bodies. A plain form post works without JavaScript. Its CSP allows only
|
|
5
|
+
// the ceremony's own hashed script and style (SU-NFR-02).
|
|
6
|
+
import { pluginId } from "virtual:semics-esign/config";
|
|
7
|
+
import { pickLocale, translator } from "../i18n/index.ts";
|
|
8
|
+
import { ceremonyCsp } from "./lib/csp";
|
|
9
|
+
import { crossSiteResponse, isCrossSiteRequest } from "./lib/csrf";
|
|
10
|
+
import { ceremonyRedirect, handleSign } from "./lib/sign";
|
|
11
|
+
import SigningCeremony from "./SigningCeremony.astro";
|
|
12
|
+
|
|
13
|
+
export const prerender = false;
|
|
14
|
+
|
|
15
|
+
// Sign and decline are form posts: refuse one another site made (CSRF, T034).
|
|
16
|
+
if (isCrossSiteRequest(Astro.request, Astro.url)) return crossSiteResponse();
|
|
17
|
+
|
|
18
|
+
Astro.response.headers.set("Referrer-Policy", "no-referrer");
|
|
19
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
20
|
+
Astro.response.headers.set("Content-Security-Policy", await ceremonyCsp());
|
|
21
|
+
|
|
22
|
+
const token = typeof Astro.params.token === "string" ? Astro.params.token : "";
|
|
23
|
+
const form = Astro.request.method === "POST" ? await Astro.request.formData().catch(() => new FormData()) : null;
|
|
24
|
+
// The page's language is the site's (Astro i18n), else British English (T102).
|
|
25
|
+
const locale = pickLocale(Astro.currentLocale);
|
|
26
|
+
const { t, lang, dir } = translator(locale);
|
|
27
|
+
const view = await handleSign({ locals: Astro.locals, url: Astro.url, request: Astro.request, pluginId }, token, form);
|
|
28
|
+
// The ceremony ended for an integration's envelope: back to its site with the receipt in the URL
|
|
29
|
+
// fragment (protocol §8.2), never a query string. The redirect keeps no-referrer and no-store.
|
|
30
|
+
const back = form ? ceremonyRedirect(view) : undefined;
|
|
31
|
+
if (back) {
|
|
32
|
+
return new Response(null, {
|
|
33
|
+
status: 303,
|
|
34
|
+
headers: { Location: back, "Referrer-Policy": "no-referrer", "Cache-Control": "private, no-store" },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
---
|
|
38
|
+
<!doctype html>
|
|
39
|
+
<html lang={lang} dir={dir}>
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="utf-8" />
|
|
42
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
43
|
+
<meta name="robots" content="noindex" />
|
|
44
|
+
<meta name="referrer" content="no-referrer" />
|
|
45
|
+
<title>{t("sign.title")}</title>
|
|
46
|
+
</head>
|
|
47
|
+
<body>
|
|
48
|
+
<main class="semics-esign semics-sign" data-testid="sign-page" data-state={view.kind}>
|
|
49
|
+
<h1>{view.kind === "ready" ? view.session.envelope?.title ?? t("sign.title") : t("sign.title")}</h1>
|
|
50
|
+
<SigningCeremony view={view} action={Astro.url.pathname} token={token} pluginId={pluginId} locale={locale} />
|
|
51
|
+
</main>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Injected at `/signed/[token]` by the integration (ES-US-6): the stable "get my signed copy" page the
|
|
3
|
+
// completion email links to. Like `/sign/[token]` it is the one place a token may sit in a URL: the
|
|
4
|
+
// path. It sends `Referrer-Policy: no-referrer`, is never cached, and POSTs the token to `public/session`
|
|
5
|
+
// (never logged), which issues a fresh short-lived download link on every visit.
|
|
6
|
+
import { pluginId } from "virtual:semics-esign/config";
|
|
7
|
+
import { pickLocale, segments, translator } from "../i18n/index.ts";
|
|
8
|
+
import { ceremonyCsp } from "./lib/csp";
|
|
9
|
+
import { handleSignedCopy } from "./lib/sign";
|
|
10
|
+
import SigningCeremony from "./SigningCeremony.astro";
|
|
11
|
+
|
|
12
|
+
export const prerender = false;
|
|
13
|
+
|
|
14
|
+
Astro.response.headers.set("Referrer-Policy", "no-referrer");
|
|
15
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
16
|
+
Astro.response.headers.set("Content-Security-Policy", await ceremonyCsp());
|
|
17
|
+
|
|
18
|
+
const token = typeof Astro.params.token === "string" ? Astro.params.token : "";
|
|
19
|
+
const locale = pickLocale(Astro.currentLocale);
|
|
20
|
+
const { t, lang, dir } = translator(locale);
|
|
21
|
+
const view = await handleSignedCopy({ locals: Astro.locals, url: Astro.url, request: Astro.request, pluginId }, token);
|
|
22
|
+
---
|
|
23
|
+
<!doctype html>
|
|
24
|
+
<html lang={lang} dir={dir}>
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8" />
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
28
|
+
<meta name="robots" content="noindex" />
|
|
29
|
+
<meta name="referrer" content="no-referrer" />
|
|
30
|
+
<title>{t("signed.title")}</title>
|
|
31
|
+
</head>
|
|
32
|
+
<body>
|
|
33
|
+
<main class="semics-esign semics-signed-copy" data-testid="signed-copy-page" data-state={view.kind}>
|
|
34
|
+
<h1>{view.kind === "closed" && view.title ? view.title : t("signed.title")}</h1>
|
|
35
|
+
{
|
|
36
|
+
view.kind === "unsigned" ? (
|
|
37
|
+
<p data-testid="signed-copy-unsigned">
|
|
38
|
+
{segments(t("signed.unsigned")).map((s) =>
|
|
39
|
+
s.slot === "link" ? <a href={`/sign/${encodeURIComponent(token)}`}>{t("signed.openLink")}</a> : s.text,
|
|
40
|
+
)}
|
|
41
|
+
</p>
|
|
42
|
+
) : (
|
|
43
|
+
<SigningCeremony view={view} action={Astro.url.pathname} token={token} pluginId={pluginId} locale={locale} />
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
</main>
|
|
47
|
+
</body>
|
|
48
|
+
</html>
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
---
|
|
2
|
+
// <SigningCeremony> (SU-FR-03..08, SU-NFR-01..03): the document, the signer's fields, the typed
|
|
3
|
+
// signature (and the optional drawn signature pad, T031), the consent and intent boxes, decline, and
|
|
4
|
+
// the completion screen. Give it the view from `handleSign` (lib/sign.ts).
|
|
5
|
+
//
|
|
6
|
+
// Works without JavaScript: both forms post to `action`. The inlined client script (allowed by
|
|
7
|
+
// hash, see lib/csp.ts: a page rendering this must send `ceremonyCsp()`) keeps Sign disabled
|
|
8
|
+
// until the form is complete and, after signing, polls `public/session` for the signed PDF.
|
|
9
|
+
//
|
|
10
|
+
// The snapshot is rendered only through the parsed tree (lib/markdown.ts) and `{…}` text, never
|
|
11
|
+
// as HTML. Its exact hashed text is offered verbatim beside its SHA-256.
|
|
12
|
+
//
|
|
13
|
+
// An uploaded PDF (E-sign Pro, T094) is offered as links to open or download it, beside the SHA-256 of the
|
|
14
|
+
// file, and never embedded: the core serves it with `Content-Security-Policy: sandbox`, under which browsers'
|
|
15
|
+
// built-in PDF viewers do not run in a frame, and this page's CSP allows no frames. Without a link (a core that
|
|
16
|
+
// cannot serve the PDF) the page says so and offers no signing form.
|
|
17
|
+
import { clientMessages, pickLocale, segments, translator } from "../i18n/index.ts";
|
|
18
|
+
import { CEREMONY_CSS, CEREMONY_SCRIPT, PAD_SCRIPT } from "./lib/assets";
|
|
19
|
+
import { ESIGN_PLUGIN_ID } from "./lib/call";
|
|
20
|
+
import { parseSnapshot } from "./lib/markdown";
|
|
21
|
+
import { fieldName, pdfDocumentView, type SignView, sessionFields, signMessage } from "./lib/sign";
|
|
22
|
+
import SnapshotBlocks from "./SnapshotBlocks.astro";
|
|
23
|
+
|
|
24
|
+
interface Props {
|
|
25
|
+
view: SignView;
|
|
26
|
+
/** Where both forms post: the page that calls `handleSign`. */
|
|
27
|
+
action: string;
|
|
28
|
+
/** The signing token, for the signed-PDF poll (sent in a POST body only). */
|
|
29
|
+
token: string;
|
|
30
|
+
/** The core's plugin id, when it is not the default slug. */
|
|
31
|
+
pluginId?: string;
|
|
32
|
+
/** The page's language (T102); else `Astro.currentLocale`, else British English. */
|
|
33
|
+
locale?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { view, action, token, pluginId = ESIGN_PLUGIN_ID } = Astro.props;
|
|
37
|
+
const locale = pickLocale(Astro.props.locale, Astro.currentLocale);
|
|
38
|
+
const { t, lang, dir } = translator(locale);
|
|
39
|
+
// The client scripts' messages, in this language: they hold no text of their own.
|
|
40
|
+
const messages = JSON.stringify(clientMessages(locale, "ceremony."));
|
|
41
|
+
const padMessages = JSON.stringify(clientMessages(locale, "pad."));
|
|
42
|
+
// A refusal's message in the page's language, by its code (a view's own `message` is English).
|
|
43
|
+
const refusal = (code: string | undefined, fallback?: string) => (code ? signMessage(code, locale) : (fallback ?? ""));
|
|
44
|
+
const ready = view.kind === "ready" ? view : undefined;
|
|
45
|
+
const session = ready?.session;
|
|
46
|
+
const blocks = session ? parseSnapshot(session.snapshot ?? "") : [];
|
|
47
|
+
const fields = session ? sessionFields(session) : [];
|
|
48
|
+
const pdfDoc = session ? pdfDocumentView(session, locale) : undefined;
|
|
49
|
+
const canSign = session?.otp !== "required" && (!pdfDoc || pdfDoc.url !== undefined);
|
|
50
|
+
const bad = (name: string) => (ready?.fields ?? []).includes(name) || undefined;
|
|
51
|
+
// A field to fix is described by the message that says why (WCAG 3.3.1).
|
|
52
|
+
const fix = (name: string) => (bad(name) && ready?.message ? { "aria-invalid": "true", "aria-describedby": "esign-error" } : {});
|
|
53
|
+
// The email-code step (T091) carries its own messages, beside the code box, and both of its forms come
|
|
54
|
+
// back to it (`#esign-otp`), so after a post the next Tab is in the step, not at the top of a long document.
|
|
55
|
+
const needsCode = session?.otp === "required";
|
|
56
|
+
const otpAction = `${action}#esign-otp`;
|
|
57
|
+
const badCode = ["OTP_INVALID", "OTP_EXPIRED", "OTP_LOCKED", "OTP_REQUIRED"].includes(ready?.errorCode ?? "") ? "true" : undefined;
|
|
58
|
+
const pdfPending =
|
|
59
|
+
(view.kind === "signed" || (view.kind === "closed" && view.status === "completed")) &&
|
|
60
|
+
!view.download &&
|
|
61
|
+
view.pdf === "pending";
|
|
62
|
+
const doneLink = (view.kind === "signed" || view.kind === "closed") && view.download ? view.download : undefined;
|
|
63
|
+
---
|
|
64
|
+
<div class="semics-esign semics-ceremony" lang={lang} dir={dir} data-messages={messages} data-testid="signing-ceremony" data-state={view.kind}>
|
|
65
|
+
{view.kind === "error" && <p role="alert" class="semics-error" data-testid="sign-error" data-code={view.code}>{refusal(view.code, view.message)}</p>}
|
|
66
|
+
{
|
|
67
|
+
view.kind === "closed" && (
|
|
68
|
+
<section data-testid="sign-closed" data-status={view.status} {...(pdfPending ? { "data-esign-poll": "", "data-token": token, "data-plugin": pluginId } : {})}>
|
|
69
|
+
<p>
|
|
70
|
+
{view.status === "completed"
|
|
71
|
+
? t("sign.closed.completed")
|
|
72
|
+
: view.status === "declined"
|
|
73
|
+
? t("sign.closed.declined")
|
|
74
|
+
: t("sign.closed.other")}
|
|
75
|
+
</p>
|
|
76
|
+
{(doneLink || pdfPending) && (
|
|
77
|
+
<p data-esign-pdf aria-live="polite">
|
|
78
|
+
{doneLink ? <a href={doneLink}>{t("sign.download")}</a> : t("sign.preparingShort")}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
{view.returnUrl && <p><a href={view.returnUrl} rel="noreferrer">{t("sign.return")}</a></p>}
|
|
82
|
+
</section>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
{
|
|
86
|
+
view.kind === "waiting" && (
|
|
87
|
+
<section data-testid="sign-waiting">
|
|
88
|
+
<p>{view.title ? t("sign.waiting.titled", { title: view.title }) : t("sign.waiting.untitled")}</p>
|
|
89
|
+
<p>{t("sign.waiting.email")}</p>
|
|
90
|
+
</section>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
{
|
|
94
|
+
view.kind === "signed" && view.waiting && (
|
|
95
|
+
<section data-testid="sign-done" data-waiting>
|
|
96
|
+
<p>{t("sign.thanks")}</p>
|
|
97
|
+
<p>{t("sign.othersToSign")}</p>
|
|
98
|
+
{view.returnUrl && <p><a href={view.returnUrl} rel="noreferrer">{t("sign.return")}</a></p>}
|
|
99
|
+
</section>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
{
|
|
103
|
+
view.kind === "signed" && !view.waiting && (
|
|
104
|
+
<section data-testid="sign-done" {...(pdfPending ? { "data-esign-poll": "", "data-token": token, "data-plugin": pluginId } : {})}>
|
|
105
|
+
<p>{t("sign.thanks")}</p>
|
|
106
|
+
<p data-esign-pdf aria-live="polite">
|
|
107
|
+
{doneLink ? <a href={doneLink}>{t("sign.download")}</a> : t("sign.preparing")}
|
|
108
|
+
</p>
|
|
109
|
+
{view.returnUrl && <p><a href={view.returnUrl} rel="noreferrer">{t("sign.return")}</a></p>}
|
|
110
|
+
</section>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
{
|
|
114
|
+
view.kind === "declined" && (
|
|
115
|
+
<section data-testid="sign-declined">
|
|
116
|
+
<p>{t("sign.declined")}</p>
|
|
117
|
+
{view.returnUrl && <p><a href={view.returnUrl} rel="noreferrer">{t("sign.return")}</a></p>}
|
|
118
|
+
</section>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
{
|
|
122
|
+
ready && session && (
|
|
123
|
+
<>
|
|
124
|
+
{ready.message && !needsCode && <p role="alert" id="esign-error" class="semics-error" data-testid="sign-error" data-code={ready.errorCode}>{refusal(ready.errorCode, ready.message)}</p>}
|
|
125
|
+
<p>
|
|
126
|
+
{segments(
|
|
127
|
+
session.signer?.as
|
|
128
|
+
? t("sign.signingAsRole", { role: session.signer.as })
|
|
129
|
+
: t("sign.signingAs"),
|
|
130
|
+
).map((s) => (s.slot === "name" ? <strong>{session.signer?.name}</strong> : s.text))}
|
|
131
|
+
</p>
|
|
132
|
+
{pdfDoc ? (
|
|
133
|
+
<section aria-label={t("sign.document")} data-testid="sign-pdf">
|
|
134
|
+
<p>
|
|
135
|
+
{segments(t("sign.pdf.intro", { pages: pdfDoc.pagesLabel, size: pdfDoc.size })).map((s) =>
|
|
136
|
+
s.slot === "name" ? <strong>{pdfDoc.name}</strong> : s.text,
|
|
137
|
+
)}
|
|
138
|
+
</p>
|
|
139
|
+
{pdfDoc.url ? (
|
|
140
|
+
<p>
|
|
141
|
+
{segments(t("sign.pdf.links")).map((s) =>
|
|
142
|
+
s.slot === "open" ? (
|
|
143
|
+
<a href={pdfDoc.url} target="_blank" rel="noopener noreferrer" data-testid="sign-pdf-open">{t("sign.pdf.open")}</a>
|
|
144
|
+
) : s.slot === "download" ? (
|
|
145
|
+
<a href={pdfDoc.url} download={pdfDoc.name} data-testid="sign-pdf-download">{t("sign.pdf.download")}</a>
|
|
146
|
+
) : (
|
|
147
|
+
s.text
|
|
148
|
+
),
|
|
149
|
+
)}
|
|
150
|
+
</p>
|
|
151
|
+
) : (
|
|
152
|
+
<p role="alert" class="semics-error" data-testid="sign-pdf-unavailable">{t("sign.pdf.unavailable")}</p>
|
|
153
|
+
)}
|
|
154
|
+
<p>
|
|
155
|
+
{segments(t("sign.pdf.fingerprint")).map((s) =>
|
|
156
|
+
s.slot === "hash" ? <span class="semics-hash">{session.envelope?.docSha256}</span> : s.text,
|
|
157
|
+
)}
|
|
158
|
+
</p>
|
|
159
|
+
</section>
|
|
160
|
+
) : (
|
|
161
|
+
<section aria-label={t("sign.document")} data-testid="sign-document">
|
|
162
|
+
<div class="semics-doc"><SnapshotBlocks blocks={blocks} /></div>
|
|
163
|
+
<details data-testid="sign-exact">
|
|
164
|
+
<summary>{t("sign.exact.summary")}</summary>
|
|
165
|
+
<p>
|
|
166
|
+
{segments(t("sign.exact.fingerprint")).map((s) =>
|
|
167
|
+
s.slot === "hash" ? <span class="semics-hash">{session.envelope?.docSha256}</span> : s.text,
|
|
168
|
+
)}
|
|
169
|
+
</p>
|
|
170
|
+
<pre>{session.snapshot}</pre>
|
|
171
|
+
</details>
|
|
172
|
+
</section>
|
|
173
|
+
)}
|
|
174
|
+
{
|
|
175
|
+
session.otp === "required" && (
|
|
176
|
+
<section id="esign-otp" data-testid="sign-otp" aria-label={t("sign.otp.label")}>
|
|
177
|
+
<p>{t("sign.otp.intro")}</p>
|
|
178
|
+
{ready.message && <p role="alert" id="esign-error" class="semics-error" data-testid="sign-error" data-code={ready.errorCode}>{refusal(ready.errorCode, ready.message)}</p>}
|
|
179
|
+
{ready.otpSent && <p role="status" data-testid="sign-otp-sent">{t("sign.otp.sent")}</p>}
|
|
180
|
+
<form method="post" action={otpAction}>
|
|
181
|
+
<button type="submit" name="intent" value="otp-send">{t("sign.otp.send")}</button>
|
|
182
|
+
</form>
|
|
183
|
+
<form method="post" action={otpAction}>
|
|
184
|
+
<div class="semics-field">
|
|
185
|
+
<label for="esign-code">{t("sign.otp.code")}</label>
|
|
186
|
+
<p id="esign-code-hint" class="semics-note">{t("sign.otp.hint")}</p>
|
|
187
|
+
<input
|
|
188
|
+
id="esign-code"
|
|
189
|
+
name="code"
|
|
190
|
+
type="text"
|
|
191
|
+
inputmode="numeric"
|
|
192
|
+
autocomplete="one-time-code"
|
|
193
|
+
pattern="[0-9]{6}"
|
|
194
|
+
maxlength="6"
|
|
195
|
+
required
|
|
196
|
+
aria-describedby={ready.message ? "esign-code-hint esign-error" : "esign-code-hint"}
|
|
197
|
+
aria-invalid={badCode || undefined}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
<button type="submit" name="intent" value="otp-verify">{t("sign.otp.verify")}</button>
|
|
201
|
+
</form>
|
|
202
|
+
</section>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
{canSign && <form method="post" action={action} data-esign-sign-form data-consent-version={session.consent?.version}>
|
|
206
|
+
{
|
|
207
|
+
fields.length > 0 && (
|
|
208
|
+
<fieldset>
|
|
209
|
+
<legend>{t("sign.details")}</legend>
|
|
210
|
+
{fields.map((field) => (
|
|
211
|
+
<div class="semics-field">
|
|
212
|
+
<label for={`esign-field-${field.key}`}>{field.required ? t("sign.required", { label: field.label }) : field.label}</label>
|
|
213
|
+
<input
|
|
214
|
+
id={`esign-field-${field.key}`}
|
|
215
|
+
name={fieldName(field.key)}
|
|
216
|
+
type="text"
|
|
217
|
+
maxlength="500"
|
|
218
|
+
data-esign-field
|
|
219
|
+
data-label={field.label}
|
|
220
|
+
required={field.required || undefined}
|
|
221
|
+
{...fix(fieldName(field.key))}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
))}
|
|
225
|
+
</fieldset>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
<fieldset>
|
|
229
|
+
<legend>{t("sign.signature")}</legend>
|
|
230
|
+
<div class="semics-field">
|
|
231
|
+
<label for="esign-name">{t("sign.typedName")}</label>
|
|
232
|
+
<input id="esign-name" name="typedName" type="text" autocomplete="name" required maxlength="200" {...fix("typedName")} />
|
|
233
|
+
</div>
|
|
234
|
+
<div data-esign-pad-slot data-messages={padMessages} data-testid="sign-pad-slot">
|
|
235
|
+
<slot name="pad" />
|
|
236
|
+
<input type="hidden" name="signatureStrokes" value="" />
|
|
237
|
+
{bad("signature") && <p class="semics-error">{refusal(ready.errorCode, ready.message)}</p>}
|
|
238
|
+
</div>
|
|
239
|
+
</fieldset>
|
|
240
|
+
<div class="semics-check">
|
|
241
|
+
<input id="esign-consent" name="consent" type="checkbox" required {...fix("consent")} />
|
|
242
|
+
<label for="esign-consent">{session.consent?.wording}</label>
|
|
243
|
+
</div>
|
|
244
|
+
<div class="semics-check">
|
|
245
|
+
<input id="esign-agree" name="agree" type="checkbox" required {...fix("agree")} />
|
|
246
|
+
<label for="esign-agree">{t("sign.agree")}</label>
|
|
247
|
+
</div>
|
|
248
|
+
<p id="esign-missing" class="semics-note" data-esign-missing data-testid="sign-missing" aria-live="polite" hidden></p>
|
|
249
|
+
<button type="submit" name="intent" value="sign" data-esign-sign aria-describedby="esign-missing">{t("sign.submit")}</button>
|
|
250
|
+
</form>}
|
|
251
|
+
<form method="post" action={action} class="semics-decline">
|
|
252
|
+
<div class="semics-field">
|
|
253
|
+
<label for="esign-reason">{t("sign.decline.label")}</label>
|
|
254
|
+
<input id="esign-reason" name="reason" type="text" maxlength="500" />
|
|
255
|
+
</div>
|
|
256
|
+
<button type="submit" name="intent" value="decline" data-esign-decline>{t("sign.decline.button")}</button>
|
|
257
|
+
</form>
|
|
258
|
+
</>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
</div>
|
|
262
|
+
<style is:inline set:html={CEREMONY_CSS}></style>
|
|
263
|
+
<script is:inline type="module" set:html={CEREMONY_SCRIPT}></script>
|
|
264
|
+
<script is:inline type="module" set:html={PAD_SCRIPT}></script>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Block nodes of a parsed snapshot (lib/markdown.ts): only these elements are ever produced.
|
|
3
|
+
import type { Block } from "./lib/markdown";
|
|
4
|
+
import SnapshotInline from "./SnapshotInline.astro";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
blocks: Block[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { blocks } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
{
|
|
13
|
+
blocks.map((block) => {
|
|
14
|
+
switch (block.type) {
|
|
15
|
+
case "heading": {
|
|
16
|
+
const Heading = `h${block.level}` as "h2";
|
|
17
|
+
return <Heading><SnapshotInline nodes={block.children} /></Heading>;
|
|
18
|
+
}
|
|
19
|
+
case "paragraph":
|
|
20
|
+
return <p><SnapshotInline nodes={block.children} /></p>;
|
|
21
|
+
case "list":
|
|
22
|
+
return block.ordered ? (
|
|
23
|
+
<ol start={block.start}>{block.items.map((item) => <li><SnapshotInline nodes={item} /></li>)}</ol>
|
|
24
|
+
) : (
|
|
25
|
+
<ul>{block.items.map((item) => <li><SnapshotInline nodes={item} /></li>)}</ul>
|
|
26
|
+
);
|
|
27
|
+
case "quote":
|
|
28
|
+
return <blockquote><Astro.self blocks={block.children} /></blockquote>;
|
|
29
|
+
case "code":
|
|
30
|
+
return <pre><code>{block.text}</code></pre>;
|
|
31
|
+
default:
|
|
32
|
+
return <hr />;
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Inline nodes of a parsed snapshot (lib/markdown.ts). Text goes in only through `{…}`, which
|
|
3
|
+
// escapes; the one attribute from data is a link's href, already limited to http(s)/mailto.
|
|
4
|
+
import type { Inline } from "./lib/markdown";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
nodes: Inline[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { nodes } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
{
|
|
13
|
+
nodes.map((node) =>
|
|
14
|
+
node.type === "text" ? (
|
|
15
|
+
node.text
|
|
16
|
+
) : node.type === "break" ? (
|
|
17
|
+
<br />
|
|
18
|
+
) : node.type === "code" ? (
|
|
19
|
+
<code>{node.text}</code>
|
|
20
|
+
) : node.type === "strong" ? (
|
|
21
|
+
<strong><Astro.self nodes={node.children} /></strong>
|
|
22
|
+
) : node.type === "em" ? (
|
|
23
|
+
<em><Astro.self nodes={node.children} /></em>
|
|
24
|
+
) : (
|
|
25
|
+
<a href={node.href} rel="noopener noreferrer nofollow"><Astro.self nodes={node.children} /></a>
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
// <VerifyDocument> (SU-FR-02, ES-US-6): check a signed PDF's SHA-256 against the issued records.
|
|
3
|
+
// Works without JavaScript (paste the fingerprint). With scripts, choosing or dropping the PDF hashes
|
|
4
|
+
// it in the browser with WebCrypto and posts only the hash: the file control has no `name` and the
|
|
5
|
+
// script has no network code. A page rendering this sends `verifyCsp()` (lib/csp.ts).
|
|
6
|
+
import { clientMessages, pickLocale, segments, translator } from "../i18n/index.ts";
|
|
7
|
+
import { VERIFY_CSS, VERIFY_SCRIPT } from "./lib/assets";
|
|
8
|
+
import { signMessage, type VerifyView } from "./lib/sign";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
view: VerifyView;
|
|
12
|
+
/** Where the form posts. */
|
|
13
|
+
action?: string;
|
|
14
|
+
/** The page's language (T102); else `Astro.currentLocale`, else British English. */
|
|
15
|
+
locale?: string;
|
|
16
|
+
}
|
|
17
|
+
const { view, action = "/verify" } = Astro.props;
|
|
18
|
+
const locale = pickLocale(Astro.props.locale, Astro.currentLocale);
|
|
19
|
+
const { t, lang, dir } = translator(locale);
|
|
20
|
+
const messages = JSON.stringify(clientMessages(locale, "verify."));
|
|
21
|
+
const error =
|
|
22
|
+
view.kind === "error" ? (view.code === "BAD_REQUEST" ? t("verify.error.BAD_REQUEST") : signMessage(view.code, locale)) : "";
|
|
23
|
+
---
|
|
24
|
+
<div class="semics-esign semics-verify" lang={lang} dir={dir} data-testid="verify-document" data-state={view.kind}>
|
|
25
|
+
<p>{t("verify.intro")}</p>
|
|
26
|
+
<form method="post" action={action}>
|
|
27
|
+
<div class="semics-drop" data-esign-drop>
|
|
28
|
+
<label for="esign-file">{t("verify.file")}</label><br />
|
|
29
|
+
<input id="esign-file" type="file" accept="application/pdf,.pdf" aria-describedby="esign-file-status" data-esign-file data-target="esign-sha256" data-status="esign-file-status" data-messages={messages} hidden />
|
|
30
|
+
<p id="esign-file-status" class="semics-note" role="status" aria-live="polite"></p>
|
|
31
|
+
</div>
|
|
32
|
+
<p>
|
|
33
|
+
<label for="esign-sha256">{t("verify.sha")}</label><br />
|
|
34
|
+
<input id="esign-sha256" name="sha256" type="text" inputmode="text" autocomplete="off" spellcheck="false" required pattern="[0-9a-fA-F]{64}" size="70" value={view.kind === "result" ? view.sha256 : ""} />
|
|
35
|
+
</p>
|
|
36
|
+
<button type="submit">{t("verify.button")}</button>
|
|
37
|
+
</form>
|
|
38
|
+
{view.kind === "error" && <p role="alert" class="semics-error" data-testid="verify-error">{error}</p>}
|
|
39
|
+
{
|
|
40
|
+
view.kind === "result" && (
|
|
41
|
+
<section class="semics-result" data-testid="verify-result" data-matched={String(view.data.matched)} aria-live="polite">
|
|
42
|
+
{view.data.matched ? (
|
|
43
|
+
<>
|
|
44
|
+
<p>
|
|
45
|
+
{view.data.completedAt
|
|
46
|
+
? segments(t("verify.issuedOn")).map((s) =>
|
|
47
|
+
s.slot === "date" ? <time datetime={view.data.completedAt}>{view.data.completedAt}</time> : s.text,
|
|
48
|
+
)
|
|
49
|
+
: t("verify.issued")}
|
|
50
|
+
</p>
|
|
51
|
+
<p data-testid="verify-chain">
|
|
52
|
+
{view.data.chainVerified === true
|
|
53
|
+
? t("verify.chain.ok")
|
|
54
|
+
: view.data.chainVerified === false
|
|
55
|
+
? t("verify.chain.bad")
|
|
56
|
+
: t("verify.chain.long")}
|
|
57
|
+
</p>
|
|
58
|
+
</>
|
|
59
|
+
) : (
|
|
60
|
+
<>
|
|
61
|
+
<p>{t("verify.none")}</p>
|
|
62
|
+
<p data-testid="verify-retention">{t("verify.retention")}</p>
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
65
|
+
</section>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
<style is:inline set:html={VERIFY_CSS}></style>
|
|
69
|
+
<script is:inline type="module" set:html={VERIFY_SCRIPT}></script>
|
|
70
|
+
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Injected at `/verify` by the integration (SU-FR-02, ES-US-6). Works without JavaScript: the visitor
|
|
3
|
+
// pastes the signed PDF's SHA-256; with scripts, they choose or drop the PDF and it is fingerprinted
|
|
4
|
+
// in the browser (never uploaded). Its CSP allows only the page's own hashed script and style.
|
|
5
|
+
import { pluginId } from "virtual:semics-esign/config";
|
|
6
|
+
import { pickLocale, translator } from "../i18n/index.ts";
|
|
7
|
+
import { verifyCsp } from "./lib/csp";
|
|
8
|
+
import { crossSiteResponse, isCrossSiteRequest } from "./lib/csrf";
|
|
9
|
+
import { handleVerify } from "./lib/sign";
|
|
10
|
+
import VerifyDocument from "./VerifyDocument.astro";
|
|
11
|
+
|
|
12
|
+
export const prerender = false;
|
|
13
|
+
|
|
14
|
+
// A cross-site post would spend the visitor's per-IP verify allowance (CSRF, T034).
|
|
15
|
+
if (isCrossSiteRequest(Astro.request, Astro.url)) return crossSiteResponse();
|
|
16
|
+
|
|
17
|
+
Astro.response.headers.set("Referrer-Policy", "no-referrer");
|
|
18
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
19
|
+
Astro.response.headers.set("Content-Security-Policy", await verifyCsp());
|
|
20
|
+
|
|
21
|
+
const form = Astro.request.method === "POST" ? await Astro.request.formData().catch(() => new FormData()) : null;
|
|
22
|
+
const locale = pickLocale(Astro.currentLocale);
|
|
23
|
+
const { t, lang, dir } = translator(locale);
|
|
24
|
+
const view = await handleVerify({ locals: Astro.locals, url: Astro.url, request: Astro.request, pluginId }, form);
|
|
25
|
+
---
|
|
26
|
+
<!doctype html>
|
|
27
|
+
<html lang={lang} dir={dir}>
|
|
28
|
+
<head>
|
|
29
|
+
<meta charset="utf-8" />
|
|
30
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
31
|
+
<meta name="robots" content="noindex" />
|
|
32
|
+
<meta name="referrer" content="no-referrer" />
|
|
33
|
+
<title>{t("verify.title")}</title>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<main data-testid="verify-page">
|
|
37
|
+
<h1>{t("verify.title")}</h1>
|
|
38
|
+
<VerifyDocument view={view} locale={locale} />
|
|
39
|
+
</main>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Astro entry for the e-sign UI. Auto-wired through componentsEntry. The pages themselves are
|
|
2
|
+
// injected by `esignIntegration()`; the one portable-text block is `esign-request` (a self-serve template, E-sign Pro).
|
|
3
|
+
import SelfServeBlock from "./SelfServeBlock.astro";
|
|
4
|
+
|
|
5
|
+
export const blockComponents = { "esign-request": SelfServeBlock };
|
|
6
|
+
|
|
7
|
+
// The only way UI code calls the core (in-process public routes, fetch fallback).
|
|
8
|
+
export { callPublicRoute, ESIGN_PLUGIN_ID } from "./lib/call.ts";
|
|
9
|
+
// A page that renders <SigningCeremony> sends `ceremonyCsp()` as its Content-Security-Policy.
|
|
10
|
+
export { ceremonyCsp, verifyCsp } from "./lib/csp.ts";
|
|
11
|
+
export { parseSnapshot, safeHref } from "./lib/markdown.ts";
|
|
12
|
+
// After a sign or decline, `ceremonyRedirect(view)` is the 303 target carrying the receipt (T062).
|
|
13
|
+
export {
|
|
14
|
+
ceremonyRedirect,
|
|
15
|
+
handleSign,
|
|
16
|
+
handleSignedCopy,
|
|
17
|
+
handleVerify,
|
|
18
|
+
normaliseStrokes,
|
|
19
|
+
receiptReturnUrl,
|
|
20
|
+
} from "./lib/sign.ts";
|
|
21
|
+
export { SelfServeBlock };
|