Skip to content

Commit 48218ee

Browse files
committed
feat(ui): add the self-serve signup entry surface
1 parent 2331f44 commit 48218ee

3 files changed

Lines changed: 217 additions & 0 deletions

File tree

apps/loopover-ui/src/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
1010

1111
import { Route as rootRouteImport } from './routes/__root'
12+
import { Route as SignupRouteImport } from './routes/signup'
1213
import { Route as RoadmapRouteImport } from './routes/roadmap'
1314
import { Route as MinersRouteImport } from './routes/miners'
1415
import { Route as MaintainersRouteImport } from './routes/maintainers'
@@ -87,6 +88,11 @@ import { Route as AppAnalyticsRouteImport } from './routes/app.analytics'
8788
import { Route as ApiOpRouteImport } from './routes/api.$op'
8889
import { Route as ReposOwnerRepoQualityRouteImport } from './routes/repos.$owner.$repo.quality'
8990

91+
const SignupRoute = SignupRouteImport.update({
92+
id: '/signup',
93+
path: '/signup',
94+
getParentRoute: () => rootRouteImport,
95+
} as any)
9096
const RoadmapRoute = RoadmapRouteImport.update({
9197
id: '/roadmap',
9298
path: '/roadmap',
@@ -502,6 +508,7 @@ export interface FileRoutesByFullPath {
502508
'/maintainers': typeof MaintainersRoute
503509
'/miners': typeof MinersRoute
504510
'/roadmap': typeof RoadmapRoute
511+
'/signup': typeof SignupRoute
505512
'/api/$op': typeof ApiOpRoute
506513
'/app/analytics': typeof AppAnalyticsRoute
507514
'/app/audit': typeof AppAuditRoute
@@ -578,6 +585,7 @@ export interface FileRoutesByTo {
578585
'/maintainers': typeof MaintainersRoute
579586
'/miners': typeof MinersRoute
580587
'/roadmap': typeof RoadmapRoute
588+
'/signup': typeof SignupRoute
581589
'/api/$op': typeof ApiOpRoute
582590
'/app/analytics': typeof AppAnalyticsRoute
583591
'/app/audit': typeof AppAuditRoute
@@ -658,6 +666,7 @@ export interface FileRoutesById {
658666
'/maintainers': typeof MaintainersRoute
659667
'/miners': typeof MinersRoute
660668
'/roadmap': typeof RoadmapRoute
669+
'/signup': typeof SignupRoute
661670
'/api/$op': typeof ApiOpRoute
662671
'/app/analytics': typeof AppAnalyticsRoute
663672
'/app/audit': typeof AppAuditRoute
@@ -739,6 +748,7 @@ export interface FileRouteTypes {
739748
| '/maintainers'
740749
| '/miners'
741750
| '/roadmap'
751+
| '/signup'
742752
| '/api/$op'
743753
| '/app/analytics'
744754
| '/app/audit'
@@ -815,6 +825,7 @@ export interface FileRouteTypes {
815825
| '/maintainers'
816826
| '/miners'
817827
| '/roadmap'
828+
| '/signup'
818829
| '/api/$op'
819830
| '/app/analytics'
820831
| '/app/audit'
@@ -894,6 +905,7 @@ export interface FileRouteTypes {
894905
| '/maintainers'
895906
| '/miners'
896907
| '/roadmap'
908+
| '/signup'
897909
| '/api/$op'
898910
| '/app/analytics'
899911
| '/app/audit'
@@ -974,11 +986,19 @@ export interface RootRouteChildren {
974986
MaintainersRoute: typeof MaintainersRoute
975987
MinersRoute: typeof MinersRoute
976988
RoadmapRoute: typeof RoadmapRoute
989+
SignupRoute: typeof SignupRoute
977990
ReposOwnerRepoQualityRoute: typeof ReposOwnerRepoQualityRoute
978991
}
979992

980993
declare module '@tanstack/react-router' {
981994
interface FileRoutesByPath {
995+
'/signup': {
996+
id: '/signup'
997+
path: '/signup'
998+
fullPath: '/signup'
999+
preLoaderRoute: typeof SignupRouteImport
1000+
parentRoute: typeof rootRouteImport
1001+
}
9821002
'/roadmap': {
9831003
id: '/roadmap'
9841004
path: '/roadmap'
@@ -1687,6 +1707,7 @@ const rootRouteChildren: RootRouteChildren = {
16871707
MaintainersRoute: MaintainersRoute,
16881708
MinersRoute: MinersRoute,
16891709
RoadmapRoute: RoadmapRoute,
1710+
SignupRoute: SignupRoute,
16901711
ReposOwnerRepoQualityRoute: ReposOwnerRepoQualityRoute,
16911712
}
16921713
export const routeTree = rootRouteImport
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import { describe, expect, it, vi } from "vitest";
3+
import type { ReactNode } from "react";
4+
5+
vi.mock("@tanstack/react-router", () => ({
6+
createFileRoute: () => () => ({}),
7+
Link: ({ to, children }: { to: string; children: ReactNode }) => <a href={to}>{children}</a>,
8+
}));
9+
10+
const signIn = vi.fn();
11+
let authState: { status: string; message?: string } = { status: "idle" };
12+
vi.mock("@/lib/api/session", () => ({
13+
useSession: () => ({ auth: authState, signIn }),
14+
}));
15+
16+
import { SignupPage } from "./signup";
17+
18+
// Self-serve signup surface (part of #4802).
19+
describe("SignupPage (#4802 self-serve signup)", () => {
20+
it("presents the signup entry with the GitHub-account explanation points", () => {
21+
authState = { status: "idle" };
22+
render(<SignupPage />);
23+
expect(screen.getByRole("heading", { name: /Create your account with GitHub/i })).toBeTruthy();
24+
expect(screen.getByRole("heading", { name: "GitHub is your identity" })).toBeTruthy();
25+
expect(screen.getByRole("heading", { name: "Scoped from the start" })).toBeTruthy();
26+
expect(screen.getByRole("heading", { name: "Straight to connecting a repo" })).toBeTruthy();
27+
});
28+
29+
it("starts the real GitHub OAuth flow via signIn(), not a fabricated credential form", () => {
30+
authState = { status: "idle" };
31+
signIn.mockClear();
32+
render(<SignupPage />);
33+
// No password/email fields — identity is GitHub's.
34+
expect(screen.queryByLabelText(/password/i)).toBeNull();
35+
fireEvent.click(screen.getByRole("button", { name: /Continue with GitHub/i }));
36+
expect(signIn).toHaveBeenCalledTimes(1);
37+
});
38+
39+
it("shows a starting state while sign-in is in flight and disables the button", () => {
40+
authState = { status: "starting" };
41+
render(<SignupPage />);
42+
const button = screen.getByRole("button", { name: /Starting sign-up/i });
43+
expect((button as HTMLButtonElement).disabled).toBe(true);
44+
});
45+
46+
it("surfaces an auth error message when sign-in fails", () => {
47+
authState = { status: "error", message: "GitHub sign-in was cancelled." };
48+
render(<SignupPage />);
49+
expect(screen.getByText("GitHub sign-in was cancelled.")).toBeTruthy();
50+
});
51+
52+
it("routes onward to the install setup steps", () => {
53+
authState = { status: "idle" };
54+
render(<SignupPage />);
55+
expect(screen.getByRole("link", { name: /See the setup steps/i }).getAttribute("href")).toBe(
56+
"/install",
57+
);
58+
});
59+
});
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { createFileRoute, Link } from "@tanstack/react-router";
2+
import { ArrowRight, Github, Loader2, ShieldCheck, GitPullRequest } from "lucide-react";
3+
4+
import { Section, SectionTitle, Card, Callout, Eyebrow } from "@/components/site/primitives";
5+
import { useSession } from "@/lib/api/session";
6+
7+
// Self-serve signup surface (part of #4802). The install flow's first step ("Sign up") previously had
8+
// no dedicated page -- this is it: it explains the GitHub-backed account model and starts the real
9+
// GitHub OAuth flow (useSession().signIn -> /v1/auth/github/start), then points to /install to connect
10+
// a repository. No credential form is collected here; identity is GitHub's, matching how the rest of the
11+
// app authenticates. Reads no secrets and fabricates no session.
12+
13+
export const Route = createFileRoute("/signup")({
14+
head: () => ({
15+
meta: [
16+
{ title: "Sign up — LoopOver self-serve" },
17+
{
18+
name: "description",
19+
content:
20+
"Create your LoopOver account with GitHub, then install the App on your own repository — self-serve, no engineering step required.",
21+
},
22+
{ property: "og:title", content: "Sign up — LoopOver self-serve" },
23+
{
24+
property: "og:description",
25+
content: "Sign up with GitHub and connect your repository, self-serve.",
26+
},
27+
{ property: "og:url", content: "/signup" },
28+
],
29+
links: [{ rel: "canonical", href: "/signup" }],
30+
}),
31+
component: SignupPage,
32+
});
33+
34+
const POINTS = [
35+
{
36+
icon: Github,
37+
title: "GitHub is your identity",
38+
description:
39+
"No separate password to manage. You sign in with GitHub, and your account is tied to the repositories you already own.",
40+
},
41+
{
42+
icon: ShieldCheck,
43+
title: "Scoped from the start",
44+
description:
45+
"Signing up grants nothing on your repositories. Access is requested only when you install the App, and you confirm the exact scopes then.",
46+
},
47+
{
48+
icon: GitPullRequest,
49+
title: "Straight to connecting a repo",
50+
description:
51+
"Once you're signed in, connect a repository and LoopOver starts reviewing its pull requests — no engineering handoff.",
52+
},
53+
];
54+
55+
export function SignupPage() {
56+
const { auth, signIn } = useSession();
57+
const isStarting = auth.status === "starting";
58+
59+
return (
60+
<>
61+
<Section className="pt-16 pb-12 sm:pt-24">
62+
<div className="max-w-3xl">
63+
<Eyebrow accent>Step 1 · Sign up</Eyebrow>
64+
<h1 className="mt-4 text-token-2xl font-medium tracking-tight text-foreground">
65+
Create your account with GitHub.
66+
</h1>
67+
<p className="mt-4 max-w-2xl text-token-md text-muted-foreground">
68+
LoopOver is self-serve: sign up with GitHub, then install the App on your own repository
69+
and confirm the scoped permissions — no manual or engineering step.
70+
</p>
71+
<div className="mt-7 flex flex-wrap items-center gap-2">
72+
<button
73+
type="button"
74+
onClick={() => void signIn()}
75+
disabled={isStarting}
76+
className="inline-flex h-9 items-center justify-center gap-1.5 whitespace-nowrap rounded-token bg-coral px-4 text-token-sm font-medium text-primary-foreground transition-[filter,transform] duration-150 hover:brightness-110 active:scale-[0.98] focus-ring motion-reduce:transition-none motion-reduce:active:scale-100 disabled:cursor-not-allowed disabled:opacity-60"
77+
>
78+
{isStarting ? (
79+
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
80+
) : (
81+
<Github className="size-3.5" />
82+
)}
83+
{isStarting ? "Starting sign-up…" : "Continue with GitHub"}
84+
</button>
85+
<Link
86+
to="/install"
87+
className="inline-flex h-9 items-center justify-center gap-1.5 whitespace-nowrap rounded-token border border-border bg-transparent px-4 text-token-sm font-medium text-foreground transition-colors duration-150 hover:bg-accent focus-ring motion-reduce:transition-none"
88+
>
89+
See the setup steps
90+
<ArrowRight className="size-3.5" />
91+
</Link>
92+
</div>
93+
{auth.status === "error" ? (
94+
<p className="mt-3 max-w-md rounded-token border border-danger/40 bg-danger/10 px-3 py-2 text-token-xs text-danger">
95+
{auth.message}
96+
</p>
97+
) : null}
98+
</div>
99+
</Section>
100+
101+
<Section className="py-0">
102+
<SectionTitle
103+
eyebrow="What signing up means"
104+
title="An account you already control"
105+
description="Signup is a GitHub sign-in, not a new credential store. You stay in control of what LoopOver can access, and when."
106+
/>
107+
<div className="mt-8 grid gap-4 sm:grid-cols-3">
108+
{POINTS.map((point) => {
109+
const Icon = point.icon;
110+
return (
111+
<Card key={point.title}>
112+
<div className="flex items-center gap-2 text-muted-foreground">
113+
<Icon aria-hidden className="size-4" />
114+
<h3 className="text-token-md font-medium text-foreground">{point.title}</h3>
115+
</div>
116+
<p className="mt-2 text-token-sm text-muted-foreground">{point.description}</p>
117+
</Card>
118+
);
119+
})}
120+
</div>
121+
</Section>
122+
123+
<Section className="pt-12 pb-24">
124+
<div className="max-w-3xl">
125+
<Callout variant="safety" title="Nothing is granted by signing up">
126+
Creating an account grants LoopOver no access to any repository. That happens only at
127+
install, where you pick the repositories and confirm the scopes. See{" "}
128+
<Link to="/install" className="text-foreground underline underline-offset-2">
129+
the setup steps
130+
</Link>{" "}
131+
for what comes next.
132+
</Callout>
133+
</div>
134+
</Section>
135+
</>
136+
);
137+
}

0 commit comments

Comments
 (0)