diff --git a/web/src/app.d.ts b/web/src/app.d.ts --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -8,6 +8,7 @@ deliberiUrl: string; sitesDomain: string; moderationServiceDid: string; + turnstileSiteKey: string; camoEnabled: boolean; }; } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -65,7 +65,9 @@ }; const signedIn = $derived(Boolean(auth.currentUser)); - const isAuthShell = $derived(page.url.pathname === "/login"); + const isAuthShell = $derived( + page.url.pathname === "/login" || page.url.pathname.startsWith("/signup") + ); // the welcome flow runs its own steps chrome, so the bar and footer step aside const isWelcomeShell = $derived(page.url.pathname === "/welcome"); const isMarketingShell = $derived( diff --git a/web/src/lib/api/signup.test.ts b/web/src/lib/api/signup.test.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/signup.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it, vi, type Mock } from "vitest"; +import { beginSignup, completeSignup, resendSignup } from "./signup"; + +const calledInit = (mock: Mock) => mock.mock.calls[0][1] as RequestInit; + +describe("beginSignup", () => { + it("posts email and turnstile token to the beginSignup nsid without auth", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("", { status: 200 })); + await beginSignup("https://deliberi.test", "alice@example.com", "turnstile-123", fetchMock); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.origin).toBe("https://deliberi.test"); + expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.beginSignup"); + expect(calledInit(fetchMock).method).toBe("POST"); + expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual({ + email: "alice@example.com", + turnstileToken: "turnstile-123" + }); + expect(calledInit(fetchMock).headers).not.toHaveProperty("authorization"); + }); + + it("surfaces the server's error message", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: "EmailAlreadyRegistered", message: "an account already exists for this email" }), { + status: 409, + headers: { "content-type": "application/json" } + }) + ); + await expect( + beginSignup("https://deliberi.test", "a@b.co", "t", fetchMock) + ).rejects.toThrow("an account already exists for this email"); + }); + + it("falls back to the status when the body is not an xrpc error", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("nope", { status: 409 })); + await expect( + beginSignup("https://deliberi.test", "a@b.co", "t", fetchMock) + ).rejects.toThrow(/could not begin signup: 409/); + }); +}); + +describe("resendSignup", () => { + it("posts the email to the resendSignup nsid without auth", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("", { status: 200 })); + await resendSignup("https://deliberi.test", "alice@example.com", fetchMock); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.origin).toBe("https://deliberi.test"); + expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.resendSignup"); + expect(calledInit(fetchMock).method).toBe("POST"); + expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual({ + email: "alice@example.com" + }); + expect(calledInit(fetchMock).headers).not.toHaveProperty("authorization"); + }); + + it("surfaces the server's message when nothing is pending", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: "NoPendingSignup", message: "there is no pending signup for this email; start again from the signup page" }), { + status: 404, + headers: { "content-type": "application/json" } + }) + ); + await expect(resendSignup("https://deliberi.test", "a@b.co", fetchMock)).rejects.toThrow( + "there is no pending signup for this email" + ); + }); +}); + +describe("completeSignup", () => { + it("posts token, username, and password and parses did/handle", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ did: "did:plc:x", handle: "alice.tngl.sh" }), { + status: 200, + headers: { "content-type": "application/json" } + }) + ); + const out = await completeSignup( + "https://deliberi.test", + "tok-123", + "alice", + "password1234", + fetchMock + ); + + expect(out).toEqual({ did: "did:plc:x", handle: "alice.tngl.sh" }); + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.completeSignup"); + expect(calledInit(fetchMock).method).toBe("POST"); + expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual({ + token: "tok-123", + username: "alice", + password: "password1234" + }); + expect(calledInit(fetchMock).headers).not.toHaveProperty("authorization"); + }); + + it("surfaces the server's error message", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: "InvalidCode", message: "invalid or expired verification link" }), { + status: 400, + headers: { "content-type": "application/json" } + }) + ); + await expect( + completeSignup("https://deliberi.test", "bad", "alice", "password1234", fetchMock) + ).rejects.toThrow("invalid or expired verification link"); + }); + + it("falls back to the status when the body is not an xrpc error", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("bad", { status: 400 })); + await expect( + completeSignup("https://deliberi.test", "bad", "alice", "password1234", fetchMock) + ).rejects.toThrow(/could not complete signup: 400/); + }); + + it("surfaces the error code when the payload omits the message", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ error: "InvalidCode" }), { + status: 400, + headers: { "content-type": "application/json" } + }) + ); + await expect( + completeSignup("https://deliberi.test", "bad", "alice", "password1234", fetchMock) + ).rejects.toThrow("InvalidCode"); + }); + + it("rejects a 2xx payload missing the handle", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ did: "did:plc:x" }), { + status: 200, + headers: { "content-type": "application/json" } + }) + ); + await expect( + completeSignup("https://deliberi.test", "tok", "alice", "password1234", fetchMock) + ).rejects.toThrow(/invalid response/); + }); + + it("rejects an empty 2xx body instead of leaking a parse error", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("", { status: 200 })); + await expect( + completeSignup("https://deliberi.test", "tok", "alice", "password1234", fetchMock) + ).rejects.toThrow(/invalid response/); + }); +}); diff --git a/web/src/lib/api/signup.ts b/web/src/lib/api/signup.ts new file mode 100644 --- /dev/null +++ b/web/src/lib/api/signup.ts @@ -0,0 +1,73 @@ +import { buildUrl, toResponseError } from "./_request"; + +const BEGIN_SIGNUP = "org.tangled.temp.account.beginSignup"; +const RESEND_SIGNUP = "org.tangled.temp.account.resendSignup"; +const COMPLETE_SIGNUP = "org.tangled.temp.account.completeSignup"; + +const errorMessage = async (response: Response, fallback: string): Promise => { + const err = await toResponseError(response); + if (err.description) return err.description; + // "XRPCError" is toResponseError's sentinel for non-JSON bodies, not a service code + if (err.error && err.error !== "XRPCError") return err.error; + return fallback; +}; + +// open endpoints: no service auth, the turnstile challenge guards beginSignup +export const beginSignup = async ( + serviceUrl: string, + email: string, + turnstileToken: string, + fetchFn: typeof globalThis.fetch = fetch +): Promise => { + const response = await fetchFn(buildUrl(serviceUrl, BEGIN_SIGNUP), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ email, turnstileToken }) + }); + if (!response.ok) { + throw new Error(await errorMessage(response, `could not begin signup: ${response.status}`)); + } +}; + +export const resendSignup = async ( + serviceUrl: string, + email: string, + fetchFn: typeof globalThis.fetch = fetch +): Promise => { + const response = await fetchFn(buildUrl(serviceUrl, RESEND_SIGNUP), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ email }) + }); + if (!response.ok) { + throw new Error(await errorMessage(response, `could not resend signup: ${response.status}`)); + } +}; + +export const completeSignup = async ( + serviceUrl: string, + token: string, + username: string, + password: string, + fetchFn: typeof globalThis.fetch = fetch +): Promise<{ did: string; handle: string }> => { + const response = await fetchFn(buildUrl(serviceUrl, COMPLETE_SIGNUP), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ token, username, password }) + }); + if (!response.ok) { + throw new Error( + await errorMessage(response, `could not complete signup: ${response.status}`) + ); + } + // a successful signup must return both fields; anything else is a broken + // response, and the pages must not render "Account created" for it + const body: unknown = await response.json().catch(() => null); + const did = (body as { did?: unknown } | null)?.did; + const handle = (body as { handle?: unknown } | null)?.handle; + if (typeof did !== "string" || typeof handle !== "string") { + throw new Error("could not complete signup: the service returned an invalid response"); + } + return { did, handle }; +}; diff --git a/web/src/lib/server/config.ts b/web/src/lib/server/config.ts --- a/web/src/lib/server/config.ts +++ b/web/src/lib/server/config.ts @@ -18,6 +18,7 @@ camoSecret: string; avatarSecret: string; moderationServiceDid: string; + turnstileSiteKey: string; }; export type PublicWebConfig = Pick< @@ -28,6 +29,7 @@ | "deliberiUrl" | "sitesDomain" | "moderationServiceDid" + | "turnstileSiteKey" > & { /** camo has a secret, so markup can route images through it */ camoEnabled: boolean; @@ -46,6 +48,7 @@ AVATAR_URL?: string; AVATAR_SHARED_SECRET?: string; MODERATION_SERVICE_DID?: string; + TURNSTILE_SITE_KEY?: string; }; export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ @@ -58,7 +61,8 @@ avatarUrl: cleanUrl(values.AVATAR_URL, "https://avatar.tangled.sh"), camoSecret: values.CAMO_SHARED_SECRET?.trim() ?? "", avatarSecret: values.AVATAR_SHARED_SECRET?.trim() ?? "", - moderationServiceDid: values.MODERATION_SERVICE_DID?.trim() ?? "" + moderationServiceDid: values.MODERATION_SERVICE_DID?.trim() ?? "", + turnstileSiteKey: values.TURNSTILE_SITE_KEY?.trim() ?? "" }); export const getConfig = (): WebConfig => resolveConfig(env as WebConfigEnv); @@ -74,6 +78,7 @@ deliberiUrl: config.deliberiUrl, sitesDomain: config.sitesDomain, moderationServiceDid: config.moderationServiceDid, + turnstileSiteKey: config.turnstileSiteKey, camoEnabled: config.camoSecret !== "" }; }; diff --git a/web/src/routes/signup/+page.svelte b/web/src/routes/signup/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/signup/+page.svelte @@ -0,0 +1,151 @@ + + + + Sign up · Tangled + + +
+
+ + tangled + +

+ tightly-knit social coding. +

+
+ + {#if sent} +
+

Check your email

+

+ A verification email was sent to {sentEmail}. +

+

+ Click the link in it to complete your signup. +

+ + {#if resent} +

Email sent again.

+ {:else} + + + {/if} + + +
+ {:else} +
+
+ + +

+ We'll email you a link to pick your username and password. +

+
+ + {#if turnstileSiteKey} +
+ (turnstileToken = token)} + onExpire={() => (turnstileToken = "")} + /> +
+ {:else} + + {/if} + + {#if submit.error} + + {/if} + + + + +

+ Already have an account? + Log in +

+ {/if} +
diff --git a/web/src/lib/components/ui/Input.svelte b/web/src/lib/components/ui/Input.svelte --- a/web/src/lib/components/ui/Input.svelte +++ b/web/src/lib/components/ui/Input.svelte @@ -21,6 +21,8 @@ iconLeft?: Component; iconRight?: Component; suffix?: string; + /** classes for the suffix element; override the default muted text, e.g. a gray chip */ + suffixClass?: string; element?: HTMLInputElement; class?: string; /** Turn the field into a token editor: it still takes a draft, but what's committed lands @@ -64,6 +66,7 @@ iconLeft, iconRight, suffix, + suffixClass, element = $bindable(), class: className, rich = false, @@ -133,7 +136,7 @@ {...rest} /> {#if suffix} - {suffix} + {suffix} {/if} {#if loading} diff --git a/web/src/lib/components/ui/Turnstile.svelte b/web/src/lib/components/ui/Turnstile.svelte new file mode 100644 --- /dev/null +++ b/web/src/lib/components/ui/Turnstile.svelte @@ -0,0 +1,92 @@ + + + + + +{#if failed} + +{:else} +
+{/if} diff --git a/web/src/routes/signup/verify/+page.svelte b/web/src/routes/signup/verify/+page.svelte new file mode 100644 --- /dev/null +++ b/web/src/routes/signup/verify/+page.svelte @@ -0,0 +1,172 @@ + + + + Complete signup · Tangled + + +
+
+ + tangled + +

+ tightly-knit social coding. +

+
+ + {#if done} +
+

Account created

+

+ Your handle is {done.handle}. +

+

+ Sign in to get started — you'll just need to enter your password once more. +

+ +
+ {:else if missingToken} +
+

Invalid link

+

+ This signup link is missing its verification token. +

+ +
+ {:else} +
+
+ + + +

+ Lowercase letters, digits, and hyphens; 4-63 characters. +

+
+ +
+ + +
+ + + + {#if submit.error} + + Get a new link + {/if} + + + + +

+ Changed your mind? + Sign up again +

+ {/if} +