Commit feb95eb0 authored by Ategon's avatar Ategon Committed by Grant
Browse files

Add sign up system connected to programming.dev

parent a70568d0
Loading
Loading
Loading
Loading
Loading
+15 −4
Original line number Diff line number Diff line
@@ -3,13 +3,16 @@ import { type DialogData } from "../../contexts/DialogContext";
import { useCallback, useState } from "react";
import { RECOMMENDED_INSTANCES } from "../../lib/constants";
import { Button } from "../core/Button";
import { ProgrammingDevSignup } from "./ProgrammingDevSignup";

export const Normal = ({
  state,
}: {
  state: DialogData<"LOGIN_INFO"> & { mode: "NORMAL" };
}) => {
  const [step, setStep] = useState<"WELCOME" | "NEW">("WELCOME");
  const [step, setStep] = useState<
    "WELCOME" | "NEW" | "PROGRAMMING_DEV_SIGNUP"
  >("WELCOME");

  const WelcomeStep = useCallback(
    () => (
@@ -62,7 +65,7 @@ export const Normal = ({
        </p>
      </>
    ),
    []
    [],
  );

  const NewStep = useCallback(
@@ -96,9 +99,15 @@ export const Normal = ({
        <strong>Return to this page after registering an account</strong>
      </>
    ),
    []
    [],
  );

  if (step === "PROGRAMMING_DEV_SIGNUP") {
    return (
      <ProgrammingDevSignup loginUrl={state.url} onBack={() => setStep("NEW")} />
    );
  }

  return (
    <>
      <ModalBody>
@@ -108,7 +117,9 @@ export const Normal = ({
      <ModalFooter>
        {step === "WELCOME" && (
          <>
            <Button onPress={() => setStep("NEW")}>I'm new here</Button>
            <Button onPress={() => setStep("PROGRAMMING_DEV_SIGNUP")}>
              I'm new here
            </Button>
            <Button as={Link} href={state.url}>
              I'm already a part of the Fediverse
            </Button>
+549 −0
Original line number Diff line number Diff line
import { Alert, Input, Link, ModalBody, ModalFooter, Spinner } from "@heroui/react";
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "../core/Button";

type SignupView =
  | "FORM"
  | "REGISTERING"
  | "WAITING_ACCOUNT_CREATION"
  | "SENDING_HANDOFF"
  | "WAITING_HANDOFF_REPLY"
  | "REDIRECTING"
  | "ERROR"
  | "TIMED_OUT";

type MetadataResponse = {
  success: true;
  instance: {
    captchaEnabled: boolean;
    registrationMode?: string;
    requireEmailVerification: boolean;
  };
  handoff: {
    enabled: boolean;
    account?: string;
  };
};

type Captcha = {
  uuid: string;
  png: string;
  wav?: string;
};

type CaptchaResponse = {
  success: true;
  captcha: Captcha;
};

type RegisterResponse = {
  success: true;
  state: "ACCOUNT_READY" | "WAITING_ACCOUNT_CREATION";
};

type CheckResponse =
  | {
      success: true;
      state: "WAITING_ACCOUNT_CREATION" | "WAITING_HANDOFF_REPLY";
    }
  | {
      success: true;
      state: "HANDOFF_READY";
      redirect: string;
    };

type ErrorResponse = {
  success: false;
  error?: string;
  error_message?: string;
};

const POLL_INTERVALS_MS = [3_000, 5_000, 10_000, 20_000, 30_000];
const POLL_TIMEOUT_MS = 15 * 60 * 1_000;

export const ProgrammingDevSignup = ({
  loginUrl,
  onBack,
}: {
  loginUrl: string;
  onBack: () => void;
}) => {
  const [view, setView] = useState<SignupView>("FORM");
  const [metadata, setMetadata] = useState<MetadataResponse>();
  const [captcha, setCaptcha] = useState<Captcha>();
  const [loadingMetadata, setLoadingMetadata] = useState(true);
  const [loadingCaptcha, setLoadingCaptcha] = useState(false);
  const [error, setError] = useState<string>();
  const [registered, setRegistered] = useState(false);

  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [passwordVerify, setPasswordVerify] = useState("");
  const [captchaAnswer, setCaptchaAnswer] = useState("");

  const passwordRef = useRef(password);
  const pollTimer = useRef<number | undefined>(undefined);
  const pollAttempt = useRef(0);
  const pollStartedAt = useRef<number | undefined>(undefined);

  useEffect(() => {
    passwordRef.current = password;
  }, [password]);

  const clearPollTimer = useCallback(() => {
    if (typeof pollTimer.current !== "undefined") {
      window.clearTimeout(pollTimer.current);
      pollTimer.current = undefined;
    }
  }, []);

  const blockingProblem = useMemo(() => {
    if (!metadata) return undefined;
    if (metadata.handoff.enabled !== true || !metadata.handoff.account) {
      return "Fediverse Auth handoff is not available right now.";
    }
    if (metadata.instance.registrationMode !== "RequireApplication") {
      return "programming.dev changed its registration flow, so automatic signup is paused.";
    }
    if (metadata.instance.requireEmailVerification) {
      return "programming.dev now requires email verification, so this signup flow needs to be updated first.";
    }
    if (!metadata.instance.captchaEnabled) {
      return "programming.dev changed its captcha settings, so automatic signup is paused.";
    }
    return undefined;
  }, [metadata]);

  const reloadCaptcha = useCallback(async () => {
    setLoadingCaptcha(true);
    setError(undefined);

    try {
      const data = await requestJson<CaptchaResponse>(
        "/api/signup/programming-dev/captcha",
      );
      setCaptcha(data.captcha);
      setCaptchaAnswer("");
    } catch (e) {
      setError(getErrorMessage(e, "Could not load captcha."));
      setView("ERROR");
    } finally {
      setLoadingCaptcha(false);
    }
  }, []);

  useEffect(() => {
    let cancelled = false;

    const load = async () => {
      setLoadingMetadata(true);
      setError(undefined);

      try {
        const data = await requestJson<MetadataResponse>(
          "/api/signup/programming-dev/metadata",
        );
        if (cancelled) return;

        setMetadata(data);
        if (data.instance.captchaEnabled) {
          await reloadCaptcha();
        }
      } catch (e) {
        if (!cancelled) {
          setError(getErrorMessage(e, "Could not load signup metadata."));
          setView("ERROR");
        }
      } finally {
        if (!cancelled) setLoadingMetadata(false);
      }
    };

    void load();

    return () => {
      cancelled = true;
      clearPollTimer();
      void fetch("/api/signup/programming-dev", { method: "DELETE" });
    };
  }, [clearPollTimer, reloadCaptcha]);

  const pollCheck = useCallback(async () => {
    clearPollTimer();

    if (!pollStartedAt.current) pollStartedAt.current = Date.now();

    if (Date.now() - pollStartedAt.current > POLL_TIMEOUT_MS) {
      setPassword("");
      setView("TIMED_OUT");
      return;
    }

    if (!passwordRef.current) {
      setView("TIMED_OUT");
      return;
    }

    setView((current) =>
      current === "WAITING_ACCOUNT_CREATION" ||
      current === "WAITING_HANDOFF_REPLY"
        ? current
        : "SENDING_HANDOFF",
    );

    try {
      const data = await requestJson<CheckResponse>(
        "/api/signup/programming-dev/check",
        {
          method: "POST",
          body: JSON.stringify({ password: passwordRef.current }),
        },
      );

      if (data.state === "HANDOFF_READY") {
        clearPollTimer();
        setView("REDIRECTING");
        window.location.assign(data.redirect);
        return;
      }

      setView(data.state);

      const attempt = pollAttempt.current++;
      const delay =
        attempt < POLL_INTERVALS_MS.length
          ? POLL_INTERVALS_MS[attempt]
          : 60_000;
      pollTimer.current = window.setTimeout(() => void pollCheck(), delay);
    } catch (e) {
      setError(getErrorMessage(e, "Could not check the account yet."));
      setView("ERROR");
    }
  }, [clearPollTimer]);

  const startPolling = useCallback(() => {
    pollAttempt.current = 0;
    pollStartedAt.current = Date.now();
    void pollCheck();
  }, [pollCheck]);

  const cancelSignup = useCallback(() => {
    clearPollTimer();
    void fetch("/api/signup/programming-dev", { method: "DELETE" });
    onBack();
  }, [clearPollTimer, onBack]);

  const submitRegister = useCallback(
    async (event: FormEvent<HTMLFormElement>) => {
      event.preventDefault();

      if (blockingProblem) {
        setError(blockingProblem);
        setView("ERROR");
        return;
      }

      if (password !== passwordVerify) {
        setError("Passwords do not match.");
        setView("ERROR");
        return;
      }

      if (!captcha) {
        setError("Captcha is not ready yet.");
        setView("ERROR");
        return;
      }

      setView("REGISTERING");
      setError(undefined);

      try {
        const data = await requestJson<RegisterResponse>(
          "/api/signup/programming-dev/register",
          {
            method: "POST",
            body: JSON.stringify({
              username,
              email: email || undefined,
              password,
              passwordVerify,
              captchaUuid: captcha.uuid,
              captchaAnswer,
            }),
          },
        );

        setRegistered(true);
        setView(
          data.state === "ACCOUNT_READY"
            ? "SENDING_HANDOFF"
            : "WAITING_ACCOUNT_CREATION",
        );
        startPolling();
      } catch (e) {
        setError(getErrorMessage(e, "Could not create the account."));
        setView("ERROR");
        void reloadCaptcha();
      }
    },
    [
      blockingProblem,
      captcha,
      captchaAnswer,
      email,
      password,
      passwordVerify,
      reloadCaptcha,
      startPolling,
      username,
    ],
  );

  const resumePolling = useCallback(() => {
    if (!password) {
      setError("Enter the password for the programming.dev account to keep checking.");
      return;
    }

    setError(undefined);
    setView("SENDING_HANDOFF");
    startPolling();
  }, [password, startPolling]);

  const busy =
    view === "REGISTERING" ||
    view === "SENDING_HANDOFF" ||
    view === "WAITING_ACCOUNT_CREATION" ||
    view === "WAITING_HANDOFF_REPLY" ||
    view === "REDIRECTING";

  return (
    <>
      <ModalBody className="gap-4">
        <div className="flex flex-col gap-1">
          <h1 className="text-2xl font-bold">Create a Fediverse account</h1>
          <p className="text-sm text-default-600">
            Canvas will create a programming.dev account, then login through
            Fediverse Auth.
          </p>
        </div>

        {loadingMetadata && (
          <div className="flex items-center gap-2 text-sm text-default-600">
            <Spinner size="sm" /> Loading programming.dev signup...
          </div>
        )}

        {error && <Alert color="danger" title="Signup error" description={error} />}
        {blockingProblem && !error && (
          <Alert color="warning" title="Signup unavailable" description={blockingProblem} />
        )}

        {(view === "FORM" || (!registered && view === "ERROR") || view === "REGISTERING") && (
          <form className="flex flex-col gap-3" onSubmit={submitRegister}>
            <Input
              label="Username"
              value={username}
              onValueChange={setUsername}
              minLength={3}
              maxLength={50}
              isRequired
              isDisabled={busy}
              autoComplete="username"
            />
            <Input
              label="Email"
              type="email"
              value={email}
              onValueChange={setEmail}
              isDisabled={busy}
              autoComplete="email"
            />
            <Input
              label="Password"
              type="password"
              value={password}
              onValueChange={setPassword}
              minLength={10}
              isRequired
              isDisabled={busy}
              autoComplete="new-password"
            />
            <Input
              label="Confirm password"
              type="password"
              value={passwordVerify}
              onValueChange={setPasswordVerify}
              minLength={10}
              isRequired
              isDisabled={busy}
              autoComplete="new-password"
            />

            <div className="flex flex-col gap-2">
              <div className="flex items-center gap-3">
                {captcha ? (
                  <img
                    className="h-24 w-auto max-w-full"
                    src={`data:image/png;base64,${captcha.png}`}
                    alt="programming.dev captcha"
                  />
                ) : (
                  <div className="flex h-14 min-w-40 items-center justify-center rounded border border-default-200 text-sm text-default-500">
                    {loadingCaptcha ? "Loading captcha..." : "Captcha unavailable"}
                  </div>
                )}
                <Button
                  type="button"
                  onPress={reloadCaptcha}
                  isLoading={loadingCaptcha}
                  isDisabled={busy}
                >
                  Reload
                </Button>
                {captcha?.wav && (
                  <Link href={`data:audio/wav;base64,${captcha.wav}`} target="_blank">
                    Audio
                  </Link>
                )}
              </div>
              <Input
                label="Captcha answer"
                value={captchaAnswer}
                onValueChange={setCaptchaAnswer}
                isRequired
                isDisabled={busy || !captcha}
                autoComplete="off"
              />
            </div>

            <div className="flex justify-end gap-2 pt-1">
              <Button type="button" onPress={cancelSignup}>
                Sign up with another instance
              </Button>
              <Button
                type="submit"
                color="primary"
                isLoading={view === "REGISTERING"}
                isDisabled={Boolean(blockingProblem) || loadingMetadata || loadingCaptcha}
              >
                Create account
              </Button>
            </div>
          </form>
        )}

        {busy && view !== "REGISTERING" && (
          <div className="flex items-center gap-3">
            <Spinner size="sm" />
            <strong>{getBusyTitle(view)}</strong>
          </div>
        )}

        {view === "ERROR" && registered && (
          <div className="flex flex-col gap-3">
            <Input
              label="Password"
              type="password"
              value={password}
              onValueChange={setPassword}
              isRequired
              autoComplete="current-password"
            />
            <div className="flex justify-end gap-2">
              <Button onPress={cancelSignup}>Cancel</Button>
              <Button color="primary" onPress={resumePolling}>
                Retry handoff
              </Button>
            </div>
          </div>
        )}

        {view === "TIMED_OUT" && (
          <div className="flex flex-col gap-3">
            <Alert
              color="warning"
              title="Still waiting"
              description="Canvas has been checking for 15 minutes. Enter the programming.dev password again to resume polling."
            />
            <Input
              label="Password"
              type="password"
              value={password}
              onValueChange={setPassword}
              isRequired
              autoComplete="current-password"
            />
            <div className="flex justify-end gap-2">
              <Button onPress={cancelSignup}>Cancel</Button>
              <Button color="primary" onPress={resumePolling}>
                Resume waiting
              </Button>
            </div>
          </div>
        )}
      </ModalBody>

      <ModalFooter className={busy ? "justify-end" : "justify-between"}>
        {!busy && (
          <Button as={Link} href={loginUrl}>
            I already have an account
          </Button>
        )}
        {busy && <Button onPress={cancelSignup}>Cancel</Button>}
      </ModalFooter>
    </>
  );
};

function getBusyTitle(view: SignupView): string {
  switch (view) {
    case "WAITING_ACCOUNT_CREATION":
      return "Waiting for account creation...";
    case "SENDING_HANDOFF":
      return "Sending Fediverse Auth handoff...";
    case "WAITING_HANDOFF_REPLY":
      return "Waiting for Fediverse Auth reply...";
    case "REDIRECTING":
      return "Redirecting...";
    default:
      return "Working...";
  }
}



async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
  const headers = new Headers(init.headers);
  if (init.body && !headers.has("Content-Type")) {
    headers.set("Content-Type", "application/json");
  }

  const response = await fetch(url, {
    ...init,
    credentials: "same-origin",
    headers,
  });
  const data = (await response.json().catch(() => ({}))) as T | ErrorResponse;

  if (!response.ok || (isObject(data) && data.success === false)) {
    throw new Error(getErrorMessage(data, response.statusText));
  }

  return data as T;
}

function getErrorMessage(error: unknown, fallback: string): string {
  if (error instanceof Error && error.message) return error.message;
  if (isObject(error)) {
    const message = error.error_message || error.error;
    if (typeof message === "string" && message) return message;
  }
  return fallback;
}

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}
+2 −0
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import { OpenIDController } from "../../controllers/OpenIDController";
import { prisma } from "../../lib/prisma";
import { AuthEndpoints } from "./auth";
import { CanvasEndpoints } from "./canvas";
import { ProgrammingDevSignupEndpoints } from "./programming_dev_signup";
import { ReportEndpoints } from "./reports";
import SentryRouter from "./sentry";
import { UserEndpoints } from "./user";
@@ -20,6 +21,7 @@ app.use(SentryRouter);
app.use(new AuthEndpoints().router);

app.use("/reports", new ReportEndpoints().router);
app.use("/signup/programming-dev", new ProgrammingDevSignupEndpoints().router);
app.use("/canvas", new CanvasEndpoints().router);
app.use("/user", new UserEndpoints().router);
app.use("/share", ShareEndpoints);
+693 −0

File added.

Preview size limit exceeded, changes collapsed.

+8 −0
Original line number Diff line number Diff line
@@ -5,6 +5,14 @@ import session from "express-session";
declare module "express-session" {
  interface SessionData {
    user: AuthSession;
    programmingDevSignup?: {
      username: string;
      registeredAt: string;
      handoffActorHandle?: string;
      handoffActorId?: number;
      handoffActorActorId?: string;
      handoffMessageSentAt?: string;
    };
  }
}