From 48218eea106150f490a9a460cf5387e04d64fefa Mon Sep 17 00:00:00 2001
From: shin-core <153108882+shin-core@users.noreply.github.com>
Date: Mon, 20 Jul 2026 16:36:45 +1000
Subject: [PATCH] feat(ui): add the self-serve signup entry surface
---
apps/loopover-ui/src/routeTree.gen.ts | 21 +++
apps/loopover-ui/src/routes/signup.test.tsx | 59 +++++++++
apps/loopover-ui/src/routes/signup.tsx | 137 ++++++++++++++++++++
3 files changed, 217 insertions(+)
create mode 100644 apps/loopover-ui/src/routes/signup.test.tsx
create mode 100644 apps/loopover-ui/src/routes/signup.tsx
diff --git a/apps/loopover-ui/src/routeTree.gen.ts b/apps/loopover-ui/src/routeTree.gen.ts
index f8e14f1205..69eacae0d7 100644
--- a/apps/loopover-ui/src/routeTree.gen.ts
+++ b/apps/loopover-ui/src/routeTree.gen.ts
@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as SignupRouteImport } from './routes/signup'
import { Route as RoadmapRouteImport } from './routes/roadmap'
import { Route as MinersRouteImport } from './routes/miners'
import { Route as MaintainersRouteImport } from './routes/maintainers'
@@ -87,6 +88,11 @@ import { Route as AppAnalyticsRouteImport } from './routes/app.analytics'
import { Route as ApiOpRouteImport } from './routes/api.$op'
import { Route as ReposOwnerRepoQualityRouteImport } from './routes/repos.$owner.$repo.quality'
+const SignupRoute = SignupRouteImport.update({
+ id: '/signup',
+ path: '/signup',
+ getParentRoute: () => rootRouteImport,
+} as any)
const RoadmapRoute = RoadmapRouteImport.update({
id: '/roadmap',
path: '/roadmap',
@@ -502,6 +508,7 @@ export interface FileRoutesByFullPath {
'/maintainers': typeof MaintainersRoute
'/miners': typeof MinersRoute
'/roadmap': typeof RoadmapRoute
+ '/signup': typeof SignupRoute
'/api/$op': typeof ApiOpRoute
'/app/analytics': typeof AppAnalyticsRoute
'/app/audit': typeof AppAuditRoute
@@ -578,6 +585,7 @@ export interface FileRoutesByTo {
'/maintainers': typeof MaintainersRoute
'/miners': typeof MinersRoute
'/roadmap': typeof RoadmapRoute
+ '/signup': typeof SignupRoute
'/api/$op': typeof ApiOpRoute
'/app/analytics': typeof AppAnalyticsRoute
'/app/audit': typeof AppAuditRoute
@@ -658,6 +666,7 @@ export interface FileRoutesById {
'/maintainers': typeof MaintainersRoute
'/miners': typeof MinersRoute
'/roadmap': typeof RoadmapRoute
+ '/signup': typeof SignupRoute
'/api/$op': typeof ApiOpRoute
'/app/analytics': typeof AppAnalyticsRoute
'/app/audit': typeof AppAuditRoute
@@ -739,6 +748,7 @@ export interface FileRouteTypes {
| '/maintainers'
| '/miners'
| '/roadmap'
+ | '/signup'
| '/api/$op'
| '/app/analytics'
| '/app/audit'
@@ -815,6 +825,7 @@ export interface FileRouteTypes {
| '/maintainers'
| '/miners'
| '/roadmap'
+ | '/signup'
| '/api/$op'
| '/app/analytics'
| '/app/audit'
@@ -894,6 +905,7 @@ export interface FileRouteTypes {
| '/maintainers'
| '/miners'
| '/roadmap'
+ | '/signup'
| '/api/$op'
| '/app/analytics'
| '/app/audit'
@@ -974,11 +986,19 @@ export interface RootRouteChildren {
MaintainersRoute: typeof MaintainersRoute
MinersRoute: typeof MinersRoute
RoadmapRoute: typeof RoadmapRoute
+ SignupRoute: typeof SignupRoute
ReposOwnerRepoQualityRoute: typeof ReposOwnerRepoQualityRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/signup': {
+ id: '/signup'
+ path: '/signup'
+ fullPath: '/signup'
+ preLoaderRoute: typeof SignupRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/roadmap': {
id: '/roadmap'
path: '/roadmap'
@@ -1687,6 +1707,7 @@ const rootRouteChildren: RootRouteChildren = {
MaintainersRoute: MaintainersRoute,
MinersRoute: MinersRoute,
RoadmapRoute: RoadmapRoute,
+ SignupRoute: SignupRoute,
ReposOwnerRepoQualityRoute: ReposOwnerRepoQualityRoute,
}
export const routeTree = rootRouteImport
diff --git a/apps/loopover-ui/src/routes/signup.test.tsx b/apps/loopover-ui/src/routes/signup.test.tsx
new file mode 100644
index 0000000000..091e7e2c7c
--- /dev/null
+++ b/apps/loopover-ui/src/routes/signup.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { ReactNode } from "react";
+
+vi.mock("@tanstack/react-router", () => ({
+ createFileRoute: () => () => ({}),
+ Link: ({ to, children }: { to: string; children: ReactNode }) => {children},
+}));
+
+const signIn = vi.fn();
+let authState: { status: string; message?: string } = { status: "idle" };
+vi.mock("@/lib/api/session", () => ({
+ useSession: () => ({ auth: authState, signIn }),
+}));
+
+import { SignupPage } from "./signup";
+
+// Self-serve signup surface (part of #4802).
+describe("SignupPage (#4802 self-serve signup)", () => {
+ it("presents the signup entry with the GitHub-account explanation points", () => {
+ authState = { status: "idle" };
+ render();
+ expect(screen.getByRole("heading", { name: /Create your account with GitHub/i })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "GitHub is your identity" })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "Scoped from the start" })).toBeTruthy();
+ expect(screen.getByRole("heading", { name: "Straight to connecting a repo" })).toBeTruthy();
+ });
+
+ it("starts the real GitHub OAuth flow via signIn(), not a fabricated credential form", () => {
+ authState = { status: "idle" };
+ signIn.mockClear();
+ render();
+ // No password/email fields — identity is GitHub's.
+ expect(screen.queryByLabelText(/password/i)).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: /Continue with GitHub/i }));
+ expect(signIn).toHaveBeenCalledTimes(1);
+ });
+
+ it("shows a starting state while sign-in is in flight and disables the button", () => {
+ authState = { status: "starting" };
+ render();
+ const button = screen.getByRole("button", { name: /Starting sign-up/i });
+ expect((button as HTMLButtonElement).disabled).toBe(true);
+ });
+
+ it("surfaces an auth error message when sign-in fails", () => {
+ authState = { status: "error", message: "GitHub sign-in was cancelled." };
+ render();
+ expect(screen.getByText("GitHub sign-in was cancelled.")).toBeTruthy();
+ });
+
+ it("routes onward to the install setup steps", () => {
+ authState = { status: "idle" };
+ render();
+ expect(screen.getByRole("link", { name: /See the setup steps/i }).getAttribute("href")).toBe(
+ "/install",
+ );
+ });
+});
diff --git a/apps/loopover-ui/src/routes/signup.tsx b/apps/loopover-ui/src/routes/signup.tsx
new file mode 100644
index 0000000000..56c729babe
--- /dev/null
+++ b/apps/loopover-ui/src/routes/signup.tsx
@@ -0,0 +1,137 @@
+import { createFileRoute, Link } from "@tanstack/react-router";
+import { ArrowRight, Github, Loader2, ShieldCheck, GitPullRequest } from "lucide-react";
+
+import { Section, SectionTitle, Card, Callout, Eyebrow } from "@/components/site/primitives";
+import { useSession } from "@/lib/api/session";
+
+// Self-serve signup surface (part of #4802). The install flow's first step ("Sign up") previously had
+// no dedicated page -- this is it: it explains the GitHub-backed account model and starts the real
+// GitHub OAuth flow (useSession().signIn -> /v1/auth/github/start), then points to /install to connect
+// a repository. No credential form is collected here; identity is GitHub's, matching how the rest of the
+// app authenticates. Reads no secrets and fabricates no session.
+
+export const Route = createFileRoute("/signup")({
+ head: () => ({
+ meta: [
+ { title: "Sign up — LoopOver self-serve" },
+ {
+ name: "description",
+ content:
+ "Create your LoopOver account with GitHub, then install the App on your own repository — self-serve, no engineering step required.",
+ },
+ { property: "og:title", content: "Sign up — LoopOver self-serve" },
+ {
+ property: "og:description",
+ content: "Sign up with GitHub and connect your repository, self-serve.",
+ },
+ { property: "og:url", content: "/signup" },
+ ],
+ links: [{ rel: "canonical", href: "/signup" }],
+ }),
+ component: SignupPage,
+});
+
+const POINTS = [
+ {
+ icon: Github,
+ title: "GitHub is your identity",
+ description:
+ "No separate password to manage. You sign in with GitHub, and your account is tied to the repositories you already own.",
+ },
+ {
+ icon: ShieldCheck,
+ title: "Scoped from the start",
+ description:
+ "Signing up grants nothing on your repositories. Access is requested only when you install the App, and you confirm the exact scopes then.",
+ },
+ {
+ icon: GitPullRequest,
+ title: "Straight to connecting a repo",
+ description:
+ "Once you're signed in, connect a repository and LoopOver starts reviewing its pull requests — no engineering handoff.",
+ },
+];
+
+export function SignupPage() {
+ const { auth, signIn } = useSession();
+ const isStarting = auth.status === "starting";
+
+ return (
+ <>
+
+
+ Step 1 · Sign up
+
+ Create your account with GitHub.
+
+
+ LoopOver is self-serve: sign up with GitHub, then install the App on your own repository
+ and confirm the scoped permissions — no manual or engineering step.
+
+
+ Creating an account grants LoopOver no access to any repository. That happens only at
+ install, where you pick the repositories and confirm the scopes. See{" "}
+
+ the setup steps
+ {" "}
+ for what comes next.
+
+