Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 2 additions & 31 deletions apps/site/src/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* confirmed sends the mail again; a confirmed address answers the same as a new one, so the form
* never tells whether an address is on the list.
*/
import { parseNotify, parseRequest, parseWaitlist, SubmissionError } from "@openclapper/home-vocab";
import { parseRequest, parseWaitlist, SubmissionError } from "@openclapper/home-vocab";
import { HttpError, json, readJson, type Router } from "./http.ts";
import { sendConfirmation } from "./mail.ts";
import { verifyTurnstile, TurnstileError } from "./turnstile.ts";
Expand All @@ -27,7 +27,7 @@ const fieldError = (err: unknown) => {
throw err;
};

async function guard(ctx: Ctx, body: Record<string, unknown>, action: "waitlist" | "request" | "notify") {
async function guard(ctx: Ctx, body: Record<string, unknown>, action: "waitlist" | "request") {
try {
await verifyTurnstile(body.turnstile, action, ctx.env, ctx.request.headers.get("cf-connecting-ip"), ctx.local);
} catch (err) {
Expand Down Expand Up @@ -71,35 +71,6 @@ export function registerForms(router: Router<Ctx>) {
return json({ ok: true });
});

/**
* The short page's form: an email to be told when the contribution phase starts. The same double opt-in
* as the sign-up, on the same table, as an updates-only row with an empty about-you block.
*/
router.on("POST", "/api/notify", async (ctx) => {
const { env, request } = ctx;
const body = await readJson<Record<string, unknown>>(request);
let n;
try { n = parseNotify(body); } catch (err) { return fieldError(err); }
await guard(ctx, body, "notify");
const now = new Date();
const existing = await env.DB.prepare("SELECT id, confirmed_at, token_sent_at FROM waitlist WHERE email = ?1").bind(n.email).first<{ id: string; confirmed_at: string | null; token_sent_at: string | null }>();
if (existing?.confirmed_at) return json({ ok: true });
if (existing && existing.token_sent_at && now.getTime() - Date.parse(existing.token_sent_at) < RESEND_AFTER_MS) return json({ ok: true });
const t = token();
if (existing) {
await env.DB.prepare("UPDATE waitlist SET updates = 1, token = ?2, token_sent_at = ?3 WHERE id = ?1").bind(existing.id, t, now.toISOString()).run();
} else {
await env.DB.prepare("INSERT INTO waitlist (id, email, tester, updates, created_at, token, token_sent_at) VALUES (?1, ?2, 0, 1, ?3, ?4, ?3)").bind(crypto.randomUUID(), n.email, now.toISOString(), t).run();
}
try {
await sendConfirmation(env.EMAIL, env, n.email, t, { tester: false, updates: true });
} catch (err) {
console.error("confirmation mail failed", err);
throw new HttpError(502, "the confirmation mail could not be sent; try again in a few minutes");
}
return json({ ok: true });
});

/** The link in the mail. Always ends on the page: confirmed, or expired/unknown (the page says to sign up again). */
router.on("GET", "/api/waitlist/confirm/:token", async ({ env }, { token: t }) => {
const back = (state: "yes" | "no") => Response.redirect(`${env.SITE_URL.replace(/\/$/, "")}/?confirmed=${state}#waitlist`, 303);
Expand Down
20 changes: 1 addition & 19 deletions apps/site/test/site.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,24 +159,6 @@ describe("waiting list", () => {
});
});

describe("notify (the short page)", () => {
beforeEach(() => { siteverify = passFor("notify"); });
test("an email alone: an updates-only row with an empty about-you block, the same double opt-in", async () => {
await json(call(base, "POST", "/api/notify", { body: { email: "soon@example.test", turnstile: "tok" } }), 200);
const row = await env.DB.prepare("SELECT tester, updates, years, systems, confirmed_at FROM waitlist WHERE email = 'soon@example.test'").first<{ tester: number; updates: number; years: string | null; systems: string; confirmed_at: string | null }>();
expect([row!.tester, row!.updates, row!.years, row!.systems, row!.confirmed_at]).toEqual([0, 1, null, "[]", null]);
expect(sent).toHaveLength(1);
expect(sent[0].subject).toContain("OpenClapper updates");
const link = sent[0].text.match(/https:\S+/)![0];
expect((await call(base, "GET", new URL(link).pathname)).headers.get("location")).toBe(`${SITE}/?confirmed=yes#waitlist`);
await json(call(base, "POST", "/api/notify", { body: { email: "soon@example.test", turnstile: "tok" } }), 200);
expect(sent).toHaveLength(1);
expect((await json(call(base, "POST", "/api/notify", { body: { email: "nope", turnstile: "tok" } }), 400)).field).toBe("email");
siteverify = passFor("waitlist");
await json(call(base, "POST", "/api/notify", { body: { email: "x@example.test", turnstile: "tok" } }), 403);
});
});

describe("feature requests", () => {
beforeEach(() => { siteverify = passFor("request"); });
test("a submission stores the person once and every entry in order", async () => {
Expand Down Expand Up @@ -259,7 +241,7 @@ describe("admin", () => {
describe("routes", () => {
test("every registered route is exercised above (add a test when you add a route)", () => {
const tested = new Set([
"POST /api/waitlist", "GET /api/waitlist/confirm/:token", "POST /api/notify", "POST /api/requests",
"POST /api/waitlist", "GET /api/waitlist/confirm/:token", "POST /api/requests",
"GET /admin/api/whoami", "GET /admin/api/waitlist", "GET /admin/api/waitlist.csv", "PATCH /admin/api/waitlist/:id", "DELETE /admin/api/waitlist/:id",
"GET /admin/api/requests", "PATCH /admin/api/requests/:id", "DELETE /admin/api/requests/:id",
]);
Expand Down
4 changes: 1 addition & 3 deletions apps/site/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#FFFFFF" />
<title>OpenClapper · an open-source tool for organising film productions</title>
<meta name="description" content="OpenClapper is a project for an open-source, self-hostable toolset for organising film productions. Experimentation phase; the alpha is the next step. Leave your email for one mail when it starts." />
<meta name="description" content="OpenClapper is a project for an open-source, self-hostable toolset for organising film productions. Experimentation phase; the alpha is the next step." />
<meta property="og:title" content="OpenClapper" />
<meta property="og:description" content="An open-source, self-hostable tool for organising film productions." />
<meta property="og:type" content="website" />
Expand All @@ -25,8 +25,6 @@
document.documentElement.setAttribute("data-theme", o === "sun" || o === "set" ? o : mq && mq.matches ? "set" : "sun");
})();
</script>
<!-- Turnstile (the bot check on both forms) is the one script from another origin; the CSP in public/_headers allows it. -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" defer></script>
</head>
<body>
<div id="app"></div>
Expand Down
71 changes: 4 additions & 67 deletions apps/site/ui/src/Teaser.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,13 @@
/**
* The short page until the alpha (issue #8): one centred card with the logo, the goal, where the
* project stands (with the link to the code), and one email field for a single mail when the alpha
* starts. Strings from teaser.ts, English and German.
* The short page until the alpha (issue #8): one centred card with the logo, the goal and where the
* project stands, with the link to the code. No form for now (issue #12): the confirmation mail of the
* double opt-in needs a sending provider first. Strings from teaser.ts, English and German.
*/
import { LIMITS } from "@openclapper/home-vocab";
import { Banner, Button, Field, IconButton, Input, THEME_CYCLE, THEME_ICON, THEME_LABEL, useTheme } from "@openclapper/home-ui";
import { useEffect, useState } from "preact/hooks";
import { ApiError, post } from "./api.ts";
import { Button, IconButton, THEME_CYCLE, THEME_ICON, THEME_LABEL, useTheme } from "@openclapper/home-ui";
import { useT } from "./teaser.ts";
import { useTurnstile } from "./turnstile.ts";

export const CORE = "https://github.com/OpenClapper-dev/core";

/** The confirmation link lands on /?confirmed=yes|no#waitlist; the state is read once and the URL cleaned. */
function useConfirmed(): "yes" | "no" | null {
const [state] = useState<"yes" | "no" | null>(() => {
const v = new URLSearchParams(location.search).get("confirmed");
return v === "yes" || v === "no" ? v : null;
});
useEffect(() => { if (state) history.replaceState(null, "", `${location.pathname}${location.hash}`); }, []);
return state;
}

/** Language and theme, the two buttons on both pages. */
export function Controls() {
const [theme, setTheme] = useTheme();
Expand All @@ -34,52 +20,8 @@ export function Controls() {
);
}

/** The one form: an email, the bot check, the double opt-in. */
function Notify({ confirmed }: { confirmed: "yes" | "no" | null }) {
const [t] = useT();
const [email, setEmail] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [done, setDone] = useState<string | null>(null);
const ts = useTurnstile("notify");
const submit = async (e: Event) => {
e.preventDefault();
if (!ts.token) { setErrors({ turnstile: t.botWait }); return; }
setBusy(true);
setErrors({});
try {
await post("/api/notify", { email, turnstile: ts.token });
setDone(email);
setEmail("");
} catch (err) {
const a = err as ApiError;
setErrors({ [a.field ?? "form"]: a.message });
} finally {
setBusy(false);
ts.reset();
}
};
if (done) return <Banner kind="ok" title={t.doneTitle} role="status">{t.done(done)}</Banner>;
return (
<form class="notify" id="waitlist" onSubmit={submit} noValidate>
{confirmed === "yes" && <Banner kind="ok" title={t.confirmedTitle} role="status">{t.confirmed}</Banner>}
{confirmed === "no" && <Banner kind="warn" title={t.expiredTitle} role="alert">{t.expired}</Banner>}
<div class="notify__row">
<Field label={t.email} required error={errors.email}>
{(id, invalid) => <Input id={id} invalid={invalid} type="email" autoComplete="email" required maxLength={LIMITS.email} value={email} onInput={(e) => setEmail(e.currentTarget.value)} />}
</Field>
<Button type="submit" loading={busy}>{t.submit}</Button>
</div>
<div class="turnstile" ref={ts.ref} />
{(errors.turnstile || errors.form || ts.failed) && <Banner kind="danger" title={errors.form ?? errors.turnstile ?? t.botFailed} role="alert">{ts.failed && !errors.form ? t.reload : undefined}</Banner>}
<p class="notify__fine">{t.fine} <a href="/privacy">{t.privacy}</a></p>
</form>
);
}

export function Teaser() {
const [t] = useT();
const confirmed = useConfirmed();
return (
<div class="t">
<Controls />
Expand All @@ -97,11 +39,6 @@ export function Teaser() {
<h2>{t.stateH}</h2>
<p>{t.state1} <a href={CORE} rel="noopener">GitHub</a>{t.state2}</p>
</section>
<section class="card__sec">
<h2>{t.notifyH}</h2>
<p>{t.notifyP}</p>
<Notify confirmed={confirmed} />
</section>
</main>
<footer class="foot">
<span>{t.license}</span>
Expand Down
6 changes: 2 additions & 4 deletions apps/site/ui/src/privacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useT } from "./teaser.ts";

const CONTACT = "hello@openclapper.org";

/** The short page's privacy page, in the chosen language: one form, one address stored. */
/** The short page's privacy page, in the chosen language: no form, nothing stored. */
function Privacy() {
const [t] = useT();
const p = t.pp;
Expand All @@ -22,11 +22,9 @@ function Privacy() {
<h1>{p.title}</h1>
<p class="lede">{p.lede}</p>
<h2>{p.hostingH}</h2><p>{p.hosting}</p>
<h2>{p.botH}</h2><p>{p.bot}</p>
<h2>{p.listH}</h2><p>{p.list1}</p><p>{p.list2}</p>
<h2>{p.storageH}</h2><p>{p.storage}</p>
<h2>{p.rightsH}</h2>
<p>{p.rights1} <a href={`mailto:${CONTACT}`}>{CONTACT}</a>. {p.rights2}</p>
<p>{p.rights1} <a href={`mailto:${CONTACT}`}>{CONTACT}</a>.</p>
<p class="meta">{p.responsible}</p>
</article>
</main>
Expand Down
10 changes: 0 additions & 10 deletions apps/site/ui/src/teaser.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,12 @@
.card { width: 100%; max-width: 560px; display: flex; flex-direction: column; align-items: center; text-align: center; gap: var(--space-4); padding: var(--space-8) var(--space-8) var(--space-6); background: var(--surface); border: var(--bw) solid var(--border-subtle); border-radius: var(--radius-lg); box-shadow: var(--elev-2); }
.card__sec { width: 100%; display: flex; flex-direction: column; gap: var(--space-2); padding-top: var(--space-5); border-top: var(--bw) solid var(--border-subtle); text-align: left; }
.card__sec p { color: var(--text-dim); font-size: var(--text-body); }
.card__sec .notify { margin-top: var(--space-2); }

/* The three goals as statements: a heavy lead with an accent bar on the left, one sentence under it. */
.goals { list-style: none; margin: var(--space-2) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-3); }
.goal { display: flex; flex-direction: column; gap: var(--space-1); padding: var(--space-1) 0 var(--space-1) var(--space-4); border-left: var(--bw-selected) solid var(--accent); }
.goal h3 { font: var(--weight-heavy) var(--text-heading)/var(--lh-tight) var(--font-sans); letter-spacing: -0.01em; }

/* The one form: email and button in a row, the bot check below. */
.notify { display: flex; flex-direction: column; gap: var(--space-4); }
.notify__row { display: grid; grid-template-columns: 1fr auto; gap: var(--space-3); align-items: end; }
.notify__row .f-btn { height: var(--control-md); }
.notify__fine { font-size: var(--text-caption); color: var(--text-dim); }
.turnstile { min-height: 65px; max-width: 100%; overflow-x: auto; }

.foot { margin-top: auto; display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-3) var(--space-4); padding: var(--space-6) var(--space-4); color: var(--text-dim); font-size: var(--text-small); text-align: center; }
.foot a { color: var(--text-dim); }

Expand All @@ -41,8 +33,6 @@
@media (max-width: 760px) {
.t { padding: 64px var(--space-4) 0; }
.card { padding: var(--space-6) var(--space-4) var(--space-5); }
.notify__row { grid-template-columns: 1fr; }
.notify__row .f-btn { width: 100%; }
.pp { padding: var(--space-4) var(--space-4) var(--space-10); }
}

Loading
Loading