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. +

+
+ + + See the setup steps + + +
+ {auth.status === "error" ? ( +

+ {auth.message} +

+ ) : null} +
+
+ +
+ +
+ {POINTS.map((point) => { + const Icon = point.icon; + return ( + +
+ +

{point.title}

+
+

{point.description}

+
+ ); + })} +
+
+ +
+
+ + 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. + +
+
+ + ); +}