This commit is contained in:
Felix Klement committed 2026-10-05 21:04:51 +02:00
1 parent efecc500c4
commit cd45c5d98f
24 files changed
+689 -281

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
---
/** A form's answer, as a toast in the lower right. Framed like the menu, on night. Driven by
scripts/send-form.ts, which finds it by the id of the form it answers. */
interface Props {
for: string;
labels: { sending: string; sent: string; failed: string; close: string };
}
const { for: formId, labels } = Astro.props;
---
<div
class="toast hud-frame"
data-toast={formId}
data-label-sending={labels.sending}
data-label-sent={labels.sent}
data-label-failed={labels.failed}
>
<div class="toast__head">
<p class="toast__label"><span data-toast-label></span><span class="toast__dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></p>
<button class="toast__close" type="button" data-toast-close>{labels.close}</button>
</div>
<p class="toast__message" role="status" aria-live="polite" data-toast-message></p>
<span class="toast__timer" aria-hidden="true" data-toast-timer></span>
</div>
<style>
/* Toast: a night panel fixed in the lower right. The menu's corner crosses frame it; their arms
reach out onto the page, so they take the menu's line colour, not the night one. */
.toast {
--frame-color: var(--hud-line);
position: fixed;
right: var(--gutter);
bottom: var(--gutter);
z-index: 20;
width: min(26rem, calc(100% - 2 * var(--gutter)));
display: grid;
gap: var(--space-3);
padding: var(--space-4) var(--space-5) var(--space-5);
background: var(--night);
color: var(--ink-on-night);
/* Closed: lowered and slightly small, grown from its corner. Opening pops it up; closing
just sinks back, quicker and without the overshoot. */
opacity: 0;
visibility: hidden;
transform: translateY(var(--space-5)) scale(0.94);
transform-origin: bottom right;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease),
visibility 0s linear var(--dur-fast);
}
.toast[data-open] {
opacity: 1;
visibility: visible;
transform: none;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-slow) var(--ease-pop);
}
.toast__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.toast__label,
.toast__close {
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
line-height: 1;
letter-spacing: var(--track-caps);
text-transform: uppercase;
}
.toast__label { color: var(--ink-on-night); }
/* While sending, three dots after the label light up one after another. */
.toast__dots { display: none; }
.toast[data-state='sending'] .toast__dots { display: inline; }
.toast__dots span { animation: calc(4 * var(--dur-slow)) steps(1, end) infinite; }
.toast__dots span:nth-child(1) { animation-name: toast-dot-1; }
.toast__dots span:nth-child(2) { animation-name: toast-dot-2; }
.toast__dots span:nth-child(3) { animation-name: toast-dot-3; }
.toast__close {
color: var(--ink-muted-on-night);
padding: var(--space-2);
margin: calc(-1 * var(--space-2));
transition: color var(--dur-fast) var(--ease);
}
.toast__close:hover { color: var(--ink-on-night); }
.toast__close:focus-visible { outline-color: var(--ink-on-night); }
.toast__message {
font-size: var(--step-small);
line-height: var(--leading-small);
color: var(--ink-muted-on-night);
}
.toast__message:empty { display: none; }
/* A hairline counts down the time until the toast leaves, longer for an error, which has more
to read. Pointer or focus pauses it. */
.toast__timer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--hud-line-width);
background: var(--ink-muted-on-night);
transform-origin: left;
transform: scaleX(0);
}
.toast[data-open][data-state='sent'] .toast__timer { animation: toast-timer var(--dur-toast) linear forwards; }
.toast[data-open][data-state='failed'] .toast__timer { animation: toast-timer var(--dur-toast-error) linear forwards; }
.toast:hover .toast__timer,
.toast:focus-within .toast__timer { animation-play-state: paused; }
@keyframes toast-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } }
/* One shared cycle in four beats: none, one, two, three dots. */
@keyframes toast-dot-1 { 0% { opacity: 0; } 25%, 100% { opacity: 1; } }
@keyframes toast-dot-2 { 0% { opacity: 0; } 50%, 100% { opacity: 1; } }
@keyframes toast-dot-3 { 0% { opacity: 0; } 75%, 100% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.toast, .toast[data-open] { transition: none; transform: none; }
.toast__dots span { animation: none; }
/* The timer still runs, since it dismisses the toast, but it is not drawn. */
.toast__timer { visibility: hidden; }
}
</style>
+7 -196
View File
@@ -1,4 +1,5 @@
---
import Toast from '../Toast.astro';
import { t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
@@ -18,6 +19,7 @@ const f = ui.contact.form;
<form
class="contact__form"
id="contact-form"
action="/contact"
method="post"
lang={lang}
@@ -55,21 +57,7 @@ const f = ui.contact.form;
</form>
</div>
<!-- The form's answer, as a toast in the lower right. Framed like the menu, on night. -->
<div
class="toast hud-frame"
data-toast
data-label-sending={f.toast.sending}
data-label-sent={f.toast.sent}
data-label-failed={f.toast.failed}
>
<div class="toast__head">
<p class="toast__label"><span data-toast-label></span><span class="toast__dots" aria-hidden="true"><span>.</span><span>.</span><span>.</span></span></p>
<button class="toast__close" type="button" data-toast-close>{f.toast.close}</button>
</div>
<p class="toast__message" role="status" aria-live="polite" data-toast-message></p>
<span class="toast__timer" aria-hidden="true" data-toast-timer></span>
</div>
<Toast for="contact-form" labels={{ ...f.toast }} />
</section>
<style>
@@ -118,105 +106,6 @@ const f = ui.contact.form;
.contact__form[aria-busy='true'] .button { opacity: 0.6; cursor: progress; }
/* Toast: a night panel fixed in the lower right. The menu's corner crosses frame it; their arms
reach out onto the page, so they take the menu's line colour, not the night one. */
.toast {
--frame-color: var(--hud-line);
position: fixed;
right: var(--gutter);
bottom: var(--gutter);
z-index: 20;
width: min(26rem, calc(100% - 2 * var(--gutter)));
display: grid;
gap: var(--space-3);
padding: var(--space-4) var(--space-5) var(--space-5);
background: var(--night);
color: var(--ink-on-night);
/* Closed: lowered and slightly small, grown from its corner. Opening pops it up; closing
just sinks back, quicker and without the overshoot. */
opacity: 0;
visibility: hidden;
transform: translateY(var(--space-5)) scale(0.94);
transform-origin: bottom right;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease),
visibility 0s linear var(--dur-fast);
}
.toast[data-open] {
opacity: 1;
visibility: visible;
transform: none;
transition:
opacity var(--dur-fast) var(--ease),
transform var(--dur-slow) var(--ease-pop);
}
.toast__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.toast__label,
.toast__close {
font-family: var(--font-mono);
font-size: var(--step-micro);
font-weight: var(--weight-medium);
line-height: 1;
letter-spacing: var(--track-caps);
text-transform: uppercase;
}
.toast__label { color: var(--ink-on-night); }
/* While sending, three dots after the label light up one after another. */
.toast__dots { display: none; }
.toast[data-state='sending'] .toast__dots { display: inline; }
.toast__dots span { animation: calc(4 * var(--dur-slow)) steps(1, end) infinite; }
.toast__dots span:nth-child(1) { animation-name: toast-dot-1; }
.toast__dots span:nth-child(2) { animation-name: toast-dot-2; }
.toast__dots span:nth-child(3) { animation-name: toast-dot-3; }
.toast__close {
color: var(--ink-muted-on-night);
padding: var(--space-2);
margin: calc(-1 * var(--space-2));
transition: color var(--dur-fast) var(--ease);
}
.toast__close:hover { color: var(--ink-on-night); }
.toast__close:focus-visible { outline-color: var(--ink-on-night); }
.toast__message {
font-size: var(--step-small);
line-height: var(--leading-small);
color: var(--ink-muted-on-night);
}
.toast__message:empty { display: none; }
/* A hairline counts down the time until the toast leaves, longer for an error, which has more
to read. Pointer or focus pauses it. */
.toast__timer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: var(--hud-line-width);
background: var(--ink-muted-on-night);
transform-origin: left;
transform: scaleX(0);
}
.toast[data-open][data-state='sent'] .toast__timer { animation: toast-timer var(--dur-toast) linear forwards; }
.toast[data-open][data-state='failed'] .toast__timer { animation: toast-timer var(--dur-toast-error) linear forwards; }
.toast:hover .toast__timer,
.toast:focus-within .toast__timer { animation-play-state: paused; }
@keyframes toast-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } }
/* One shared cycle in four beats: none, one, two, three dots. */
@keyframes toast-dot-1 { 0% { opacity: 0; } 25%, 100% { opacity: 1; } }
@keyframes toast-dot-2 { 0% { opacity: 0; } 50%, 100% { opacity: 1; } }
@keyframes toast-dot-3 { 0% { opacity: 0; } 75%, 100% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.toast, .toast[data-open] { transition: none; transform: none; }
.toast__dots span { animation: none; }
/* The timer still runs, since it dismisses the toast, but it is not drawn. */
.toast__timer { visibility: hidden; }
}
@media (min-width: 48rem) {
.contact__text { grid-column: 1 / span 5; }
.contact__form { grid-column: 7 / span 6; }
@@ -228,87 +117,9 @@ const f = ui.contact.form;
</style>
<script>
// The form sends in place and answers in a toast. Every send carries the answer to a
// proof-of-work challenge (ALTCHA), worked out in the background from the moment the visitor
// first touches the form. Without scripting the server refuses the post and redirects to
// /contact/error, which points to the email address.
import { solveChallenge, type Challenge } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2';
// Sends in place and answers in the toast; see scripts/send-form.ts. Without scripting the
// server refuses the post and redirects to /contact/error, which points to the email address.
import { sendInPlace } from '../../scripts/send-form';
const form = document.querySelector<HTMLFormElement>('[data-contact]');
const toast = document.querySelector<HTMLElement>('[data-toast]');
if (form && toast) {
const label = toast.querySelector<HTMLElement>('[data-toast-label]')!;
const message = toast.querySelector<HTMLElement>('[data-toast-message]')!;
const timer = toast.querySelector<HTMLElement>('[data-toast-timer]')!;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
// One challenge per message: fetched and solved once, spent by the send that carries it.
let proof: Promise<string | null> | null = null;
const solve = async (): Promise<string | null> => {
try {
const res = await fetch('/contact/challenge', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const challenge: Challenge = await res.json();
const solution = await solveChallenge({ challenge, deriveKey });
return solution ? btoa(JSON.stringify({ challenge, solution })) : null;
} catch {
return null;
}
};
const prepare = () => (proof ??= solve());
form.addEventListener('focusin', prepare);
const show = (state: 'sending' | 'sent' | 'failed', text: string) => {
toast.dataset.state = state;
toast.setAttribute('data-open', '');
// Restart the countdown for a second message in a row.
timer.style.animation = 'none';
void timer.offsetWidth;
timer.style.animation = '';
label.textContent = toast.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] ?? '';
// Set on the next frame, so the now visible live region announces it.
requestAnimationFrame(() => { message.textContent = text; });
};
const hide = () => {
if (toast.contains(document.activeElement)) submit.focus();
toast.removeAttribute('data-open');
};
timer.addEventListener('animationend', hide);
close.addEventListener('click', hide);
toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (form.getAttribute('aria-busy') === 'true') return;
form.setAttribute('aria-busy', 'true');
show('sending', '');
let outcome = 'failed';
const altcha = await prepare();
proof = null;
if (altcha) {
const body = new URLSearchParams(new FormData(form) as unknown as Record<string, string>);
body.set('altcha', altcha);
try {
const res = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body,
});
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
}
}
if (outcome === 'sent') {
form.reset();
show('sent', form.dataset.sent ?? '');
} else {
show('failed', (outcome === 'invalid' ? form.dataset.invalid : form.dataset.failed) ?? '');
}
form.removeAttribute('aria-busy');
});
}
if (form) sendInPlace(form);
</script>
+25 -1
View File
@@ -1,4 +1,5 @@
---
import Toast from '../Toast.astro';
import { localePath, t, type Lang } from '../../i18n/ui';
interface Props { lang: Lang }
const { lang } = Astro.props;
@@ -59,7 +60,18 @@ const ridges = Array.from({ length: rows }, (_, r) => {
<p class="shero__lead">{s.lead}</p>
</div>
<form class="signup hud-frame" id="notify" action="/notify" method="post" lang={lang} aria-labelledby="notify-title">
<form
class="signup hud-frame"
id="notify"
action="/notify"
method="post"
lang={lang}
aria-labelledby="notify-title"
data-notify
data-sent={n.status.sent}
data-invalid={n.status.invalid}
data-failed={n.status.failed}
>
<div class="signup__head">
<h2 id="notify-title" class="signup__title">{n.title}</h2>
<p class="signup__body">{n.body}</p>
@@ -79,6 +91,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
<label for="notify-consent">{n.consent} <a href={localePath(lang, '/privacy')}>{n.privacy}</a></label>
</div>
<button class="button signup__submit" type="submit">{n.submit}</button>
<noscript><p class="signup__status">{n.noscript}</p></noscript>
</form>
<!-- Status strip: the menu bar's HUD language, set along the foot of the hero. -->
@@ -92,6 +105,8 @@ const ridges = Array.from({ length: rows }, (_, r) => {
))}
</dl>
</div>
<Toast for="notify" labels={n.toast} />
</section>
<style>
@@ -199,6 +214,7 @@ const ridges = Array.from({ length: rows }, (_, r) => {
color: var(--ink-muted);
}
.signup__consent input { width: 1.1em; height: 1.1em; margin: 0.15em 0 0; accent-color: var(--paper); }
.signup__status { font-size: var(--step-small); line-height: var(--leading-small); }
.signup__consent a { color: var(--ink); text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
/* Readout: stacked on small screens, one strip of cells on wide ones. */
@@ -262,3 +278,11 @@ const ridges = Array.from({ length: rows }, (_, r) => {
.readout__cell--head { place-content: center; text-align: center; }
}
</style>
<script>
// Sends in place and answers in the toast, like the contact form; see scripts/send-form.ts.
// Without scripting the server refuses the post and redirects to /notify/error.
import { sendInPlace } from '../../scripts/send-form';
const form = document.querySelector<HTMLFormElement>('[data-notify]');
if (form) sendInPlace(form);
</script>
+48
View File
@@ -270,6 +270,30 @@ const en = {
consent: 'CipherCell may store my email address to notify me about the CipherVerifier alpha. I can withdraw this at any time.',
privacy: 'Privacy policy',
submit: 'Register',
noscript: 'The form needs JavaScript. Without it, write to office@ciphercell.de.',
toast: { sending: 'Registering', sent: 'Link sent', failed: 'Not registered', close: 'Close' },
status: {
sent: 'Check your inbox. We sent a link to confirm your address.',
invalid: 'Please give a valid email address and agree to the notice.',
failed: 'Registration did not go through. Please try again, or write to office@ciphercell.de.',
},
pages: {
confirm: {
title: 'Confirm your registration.',
body: 'One click, and your address is registered for the CipherVerifier alpha. One email when it is released. Nothing else.',
submit: 'Confirm registration',
},
confirmed: {
title: 'Registered.',
body: 'Your address is confirmed. You will hear from us once, when the CipherVerifier alpha is released.',
link: 'Back to CipherVerifier',
},
error: {
title: 'Not registered.',
body: 'The link has expired or is incomplete, or the registration did not go through. Register again, or write to office@ciphercell.de.',
link: 'Register again',
},
},
},
track: {
title: 'Release path.',
@@ -586,6 +610,30 @@ const de: typeof en = {
consent: 'CipherCell darf meine E-Mail-Adresse speichern, um mich über die Alpha von CipherVerifier zu benachrichtigen. Ich kann dies jederzeit widerrufen.',
privacy: 'Datenschutzerklärung',
submit: 'Registrieren',
noscript: 'Das Formular braucht JavaScript. Ohne schreiben Sie an office@ciphercell.de.',
toast: { sending: 'Wird registriert', sent: 'Link gesendet', failed: 'Nicht registriert', close: 'Schließen' },
status: {
sent: 'Bitte sehen Sie in Ihr Postfach. Wir haben Ihnen einen Link zur Bestätigung geschickt.',
invalid: 'Bitte geben Sie eine gültige E-Mail-Adresse an und stimmen Sie dem Hinweis zu.',
failed: 'Die Registrierung ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
},
pages: {
confirm: {
title: 'Registrierung bestätigen.',
body: 'Ein Klick, und Ihre Adresse ist für die Alpha von CipherVerifier registriert. Eine E-Mail, sobald sie erscheint. Sonst nichts.',
submit: 'Registrierung bestätigen',
},
confirmed: {
title: 'Registriert.',
body: 'Ihre Adresse ist bestätigt. Sie hören einmal von uns, sobald die Alpha von CipherVerifier erscheint.',
link: 'Zurück zu CipherVerifier',
},
error: {
title: 'Nicht registriert.',
body: 'Der Link ist abgelaufen oder unvollständig, oder die Registrierung ist nicht angekommen. Registrieren Sie sich erneut oder schreiben Sie an office@ciphercell.de.',
link: 'Erneut registrieren',
},
},
},
track: {
title: 'Weg zum Release.',
+45
View File
@@ -0,0 +1,45 @@
---
import Base from '../layouts/Base.astro';
import { localePath, t, type Lang } from '../i18n/ui';
interface Props { lang: Lang; page: 'confirm' | 'confirmed' | 'error' }
const { lang, page } = Astro.props;
const p = t(lang).verifier.soon.notify.pages;
const r = p[page];
const product = localePath(lang, '/cipherverifier');
---
<Base lang={lang} title={r.title} description={r.body} noindex>
<section class="section">
<div class="container grid">
<h1 class="display result__title">{r.title}</h1>
<div class="result__body">
<p class="muted">{r.body}</p>
{page === 'confirm' ? (
// The token travels in the link's query; the script copies it into the post. The
// button, not the link, confirms: mail scanners open links but do not press buttons.
<form action="/notify/verify" method="post" data-confirm>
<input type="hidden" name="lang" value={lang} />
<input type="hidden" name="t" value="" />
<button class="button" type="submit">{p.confirm.submit}</button>
</form>
) : (
<a class="link" href={page === 'error' ? `${product}#notify` : product}>{'link' in r ? r.link : ''}</a>
)}
</div>
</div>
</section>
</Base>
<script>
const form = document.querySelector<HTMLFormElement>('[data-confirm]');
const field = form?.querySelector<HTMLInputElement>('[name="t"]');
if (field) field.value = new URLSearchParams(location.search).get('t') ?? '';
</script>
<style>
.result__title { grid-column: 1 / -1; }
.result__body { grid-column: 1 / -1; display: grid; gap: var(--space-5); max-width: var(--measure); justify-items: start; }
@media (min-width: 48rem) {
.result__title { grid-column: 1 / span 8; }
.result__body { grid-column: 1 / span 5; }
}
</style>
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="confirm" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="confirmed" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../../_NotifyPage.astro';
---
<NotifyPage lang="de" page="error" />
+1 -1
View File
@@ -18,7 +18,7 @@ const ui = t('de');
<p>Wenn Sie uns per E-Mail oder auf anderem Wege kontaktieren, speichern wir Ihre übermittelten personenbezogenen Daten (z. B. Name, E-Mail-Adresse, Inhalt der Nachricht), um Ihre Anfrage zu bearbeiten. Diese Daten werden ausschließlich für die Kommunikation mit Ihnen verwendet und nicht an Dritte weitergegeben. Rechtsgrundlage für die Verarbeitung der Daten ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Vertragsverhältnisses) oder Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Bearbeitung der Anfrage).</p>
<h2>Benachrichtigung zur CipherVerifier-Alpha</h2>
<p>Wenn Sie sich für die Benachrichtigung zur Alpha von CipherVerifier eintragen, speichern wir Ihre E-Mail-Adresse und den Zeitpunkt Ihrer Einwilligung, um Ihnen mitzuteilen, wann die erste Alpha veröffentlicht ist. Wir nutzen sie für nichts anderes und geben sie nicht an Dritte weiter. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a> widerrufen; wir löschen Ihre Adresse dann. Nach dem Versand der Benachrichtigung löschen wir sie ebenfalls.</p>
<p>Wenn Sie sich für die Benachrichtigung zur Alpha von CipherVerifier eintragen, speichern wir Ihre E-Mail-Adresse und den Zeitpunkt Ihrer Einwilligung, um Ihnen mitzuteilen, wann die erste Alpha veröffentlicht ist. Die Registrierung erfolgt im Double-Opt-in-Verfahren: Wir senden Ihnen zunächst eine E-Mail mit einem Bestätigungslink und speichern Ihre Adresse erst, wenn Sie bestätigt haben. Bestätigen Sie nicht innerhalb von 7 Tagen, verfällt der Link und es wird nichts gespeichert. Wir nutzen sie für nichts anderes und geben sie nicht an Dritte weiter. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a> widerrufen; wir löschen Ihre Adresse dann. Nach dem Versand der Benachrichtigung löschen wir sie ebenfalls.</p>
<h2>Eingebettete LinkedIn-Beiträge</h2>
<p>Einige Seiten unter Einblicke können einen Beitrag von LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Irland) anzeigen. Beim Aufruf der Seite wird der Beitrag nicht geladen. Erst wenn Sie auf die Schaltfläche zum Laden klicken, verbindet sich Ihr Browser mit den Servern von LinkedIn; diese erhalten Ihre IP-Adresse und die aufgerufene Seite und können Cookies setzen. Sind Sie bei LinkedIn angemeldet, kann LinkedIn den Besuch Ihrem Konto zuordnen. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie mit diesem Klick erteilen. Zur Verarbeitung durch LinkedIn siehe dessen Datenschutzrichtlinie unter <a href="https://de.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p>
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="confirm" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="confirmed" />
+4
View File
@@ -0,0 +1,4 @@
---
import NotifyPage from '../_NotifyPage.astro';
---
<NotifyPage lang="en" page="error" />
+1 -1
View File
@@ -18,7 +18,7 @@ const ui = t('en');
<p>If you contact us by email or other means, we will store the personal data you provide (e.g. name, email address, content of the message) in order to process your request. This data will be used exclusively for communication with you and will not be passed on to third parties. The legal basis for processing the data is Art. 6 para. 1 lit. b GDPR (initiation of a contractual relationship) or Art. 6 para. 1 lit. f GDPR (legitimate interest in processing the request).</p>
<h2>CipherVerifier alpha notification</h2>
<p>If you register for the CipherVerifier alpha notification, we store your email address and the time of your consent in order to tell you when the first alpha is released. We use it for nothing else and do not pass it on to third parties. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR. You can withdraw your consent at any time by email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>; we then delete your address. We also delete it once the notification has been sent.</p>
<p>If you register for the CipherVerifier alpha notification, we store your email address and the time of your consent in order to tell you when the first alpha is released. Registration is double opt-in: we first send you an email with a link to confirm it, and only store your address once you have confirmed. If you do not confirm within 7 days, the link expires and nothing is kept. We use it for nothing else and do not pass it on to third parties. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR. You can withdraw your consent at any time by email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>; we then delete your address. We also delete it once the notification has been sent.</p>
<h2>Embedded LinkedIn posts</h2>
<p>Some pages in Insights can show a post from LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Ireland). The post is not loaded when you open the page. Only when you click the button to load it does your browser connect to LinkedIn's servers, which receive your IP address and the page you are on and may set cookies; if you are logged in to LinkedIn, it can link the visit to your account. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR, given by that click. For LinkedIn's processing, see its privacy policy at <a href="https://www.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p>
+34
View File
@@ -0,0 +1,34 @@
/**
* Proof of work for the site's forms (ALTCHA). From the moment a visitor first focuses a form,
* a signed challenge is fetched from /contact/challenge and solved in the background; the send
* then carries the answer as the field `altcha`. One challenge per send: the server refuses a
* second use, so every send starts a new one.
*/
import { solveChallenge, type Challenge } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2';
async function solve(): Promise<string | null> {
try {
const res = await fetch('/contact/challenge', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const challenge: Challenge = await res.json();
const solution = await solveChallenge({ challenge, deriveKey });
return solution ? btoa(JSON.stringify({ challenge, solution })) : null;
} catch {
return null;
}
}
/** Starts solving on the form's first focus. `take()` waits for the answer and hands it over once. */
export function proofOfWork(form: HTMLFormElement) {
let proof: Promise<string | null> | null = null;
const prepare = () => (proof ??= solve());
form.addEventListener('focusin', prepare);
return {
async take(): Promise<string | null> {
const answer = await prepare();
proof = null;
return answer;
},
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Sends a form in place and answers in its toast (components/Toast.astro, found by the form's id).
* Every send carries the answer to a proof-of-work challenge (scripts/altcha.ts), worked out in
* the background from the moment the visitor first touches the form. The texts for the answer
* come from the form: data-sent, data-invalid, data-failed.
*/
import { proofOfWork } from './altcha';
type State = 'sending' | 'sent' | 'failed';
export function sendInPlace(form: HTMLFormElement) {
const toast = document.querySelector<HTMLElement>(`[data-toast="${form.id}"]`);
if (!toast) return;
const label = toast.querySelector<HTMLElement>('[data-toast-label]')!;
const message = toast.querySelector<HTMLElement>('[data-toast-message]')!;
const timer = toast.querySelector<HTMLElement>('[data-toast-timer]')!;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
const pow = proofOfWork(form);
const show = (state: State, text: string) => {
toast.dataset.state = state;
toast.setAttribute('data-open', '');
// Restart the countdown for a second message in a row.
timer.style.animation = 'none';
void timer.offsetWidth;
timer.style.animation = '';
label.textContent = toast.dataset[`label${state[0].toUpperCase()}${state.slice(1)}`] ?? '';
// Set on the next frame, so the now visible live region announces it.
requestAnimationFrame(() => { message.textContent = text; });
};
const hide = () => {
if (toast.contains(document.activeElement)) submit.focus();
toast.removeAttribute('data-open');
};
timer.addEventListener('animationend', hide);
close.addEventListener('click', hide);
toast.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (form.getAttribute('aria-busy') === 'true') return;
form.setAttribute('aria-busy', 'true');
show('sending', '');
let outcome = 'failed';
const altcha = await pow.take();
if (altcha) {
const body = new URLSearchParams(new FormData(form) as unknown as Record<string, string>);
body.set('altcha', altcha);
try {
const res = await fetch(form.action, { method: 'POST', headers: { Accept: 'application/json' }, body });
outcome = (await res.json()).outcome ?? 'failed';
} catch {
outcome = 'failed';
}
}
if (outcome === 'sent') {
form.reset();
show('sent', form.dataset.sent ?? '');
} else {
show('failed', (outcome === 'invalid' ? form.dataset.invalid : form.dataset.failed) ?? '');
}
form.removeAttribute('aria-busy');
});
}