Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

147 linesCodeBlameRaw
1import { Form, Link, redirect } from "react-router";
2
3import type { Route } from "./+types/new";
4import { page } from "../../lib/meta";
5import { planHref } from "../../components/start-plan";
6import { ButtonLink, ErrorText, Field, Input, SubmitButton } from "../../components/ui";
7import { Card } from "../../components/ui/card";
8import { billing, identity } from "../../lib/services.server";
9import { InvitationList } from "../../components/invitation-list";
10import { answerInvitation, loadInvitations } from "../../lib/invitations.server";
11import { declinedLine } from "../../lib/invitations";
12import { assertSameOrigin, nextPath, requireUser } from "../../lib/session.server";
13
14export function meta(args: Route.MetaArgs) {
15 return page(args, { title: "New workspace · g1t" });
16}
17
18export async function loader({ request, context }: Route.LoaderArgs) {
19 const user = requireUser(context, request);
20 const owned = (user.workspaces ?? []).filter((membership) => membership.role === "owner").map((membership) => membership.slug);
21 // A person owns at most one free workspace; identity refuses a second
22 // either way, so a failure here only leaves the form up.
23 const first = (user.workspaces ?? []).length === 0;
24 const [free, invitations] = await Promise.all([
25 owned.length > 0 ? billing.freeWorkspaces(owned).catch(() => [] as string[]) : Promise.resolve([] as string[]),
26 // Someone without a workspace may have been invited to one.
27 first ? loadInvitations(user) : Promise.resolve([]),
28 ]);
29 const next = nextPath(request);
30 return { user, first, free, invitations, next: next === "/" ? null : next };
31}
32
33export async function action({ request, context }: Route.ActionArgs) {
34 assertSameOrigin(request);
35 const user = requireUser(context, request);
36 const form = await request.formData();
37 // Accepting or declining an invitation shown here (lib/invitations.server.ts).
38 const answered = await answerInvitation(user, form, request);
39 if (answered) return answered;
40 const result = await identity.createWorkspace(user, String(form.get("slug") ?? ""), String(form.get("displayName") ?? ""));
41 if (!result.ok) return { error: result.error.message };
42 // Someone sent here on their way elsewhere carries on to it.
43 const next = nextPath(request);
44 throw redirect(next === "/" ? `/${result.value.slug}` : next);
45}
46
47export default function NewWorkspace({ loaderData, actionData }: Route.ComponentProps) {
48 const { user, first, free, invitations, next } = loaderData;
49 const said = actionData as { error?: string; invitationError?: string; invitationId?: string; declined?: string } | undefined;
50 return (
51 <main className="mx-auto max-w-lg pt-8">
52 <h1 className="text-xl font-semibold">{first ? "Create your workspace or ask to join one" : "New workspace"}</h1>
53 {first && (
54 <section className="mt-4">
55 {said?.declined !== undefined && (
56 <p className="mb-3 text-sm text-muted" role="status">
57 {declinedLine(said.declined)}
58 </p>
59 )}
60 {invitations.length > 0 ? (
61 <>
62 <h2 className="text-sm font-medium">You are invited</h2>
63 <p className="mt-1 mb-3 text-sm text-muted">Accept an invitation to join that workspace, or decline it.</p>
64 <InvitationList invitations={invitations} error={said?.invitationError} errorFor={said?.invitationId} next={next} />
65 </>
66 ) : (
67 <p className="text-sm text-muted">
68 To join a team that is on g1t already, ask one of its workspace's owners to invite you by your username,{" "}
69 <span className="font-mono text-fg">{user.username}</span>. Their invitation shows here and in your notifications.
70 </p>
71 )}
72 <h2 className="mt-8 text-sm font-medium">Or create your own</h2>
73 </section>
74 )}
75 <p className="mt-2 text-sm text-muted">
76 {first && "Everything on g1t lives in a workspace, so this comes first. "}A workspace holds repositories, the people who work on them and the access
77 tokens that automate them, and is the first part of every address: <span className="font-mono text-fg">g1t.sh/workspace/repo</span>. Use one for
78 yourself, and one for each team or company you work with.
79 </p>
80 {free.length > 0 ? (
81 <OneFreeWorkspace free={free} />
82 ) : (
83 <>
84 <Form method="post" className="mt-8 space-y-4">
85 <Field label="Name in URLs" hint="Lowercase letters, digits and single hyphens. An owner can change it later; old addresses redirect for 90 days.">
86 <div className="flex items-center gap-2 font-mono text-sm">
87 <span className="text-muted">g1t.sh/</span>
88 <Input name="slug" required autoFocus maxLength={39} defaultValue={first ? user.username : ""} pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" />
89 </div>
90 </Field>
91 <Field label="Display name (optional)">
92 <Input name="displayName" maxLength={80} />
93 </Field>
94 <p className="text-xs text-faint">A new workspace is free. Each person can own one free workspace; more need the plan on the ones you have.</p>
95 <ErrorText>{said?.error}</ErrorText>
96 <SubmitButton pending="Creating…">Create workspace</SubmitButton>
97 </Form>
98 </>
99 )}
100 </main>
101 );
102}
103
104/** Why there is no form: the person owns a free workspace already. */
105function OneFreeWorkspace({ free }: { free: string[] }) {
106 const [first] = free;
107 return (
108 <Card asChild className="mt-8 p-5">
109 <section>
110 <h2 className="font-medium">You already own a free workspace</h2>
111 <p className="mt-2 text-sm text-muted">
112 Each person can own one workspace that is not on the g1t plan.{" "}
113 {free.length === 1 ? (
114 <>
115 Yours is <span className="font-mono text-fg">{first}</span>.
116 </>
117 ) : (
118 <>
119 You own {free.length} from before (
120 {free.map((slug, i) => (
121 <span key={slug}>
122 {i > 0 && ", "}
123 <span className="font-mono text-fg">{slug}</span>
124 </span>
125 ))}
126 ), and keep them all.
127 </>
128 )}{" "}
129 A new workspace starts free, so to make one, start the plan on {free.length === 1 ? "it" : "each of them"}, or delete{" "}
130 {free.length === 1 ? "it" : "the ones"} you no longer use.
131 </p>
132 <div className="mt-4 flex flex-wrap items-center gap-3">
133 <ButtonLink to={planHref(first)}>Start the plan on {first}</ButtonLink>
134 <Link to={`/${first}/-/settings`} className="text-sm text-muted hover:text-fg">
135 Workspace settings
136 </Link>
137 </div>
138 <p className="mt-4 text-xs text-faint">
139 The plan is one price for everyone in the workspace, never per person.{" "}
140 <Link to="/pricing" className="underline underline-offset-2 hover:text-fg">
141 Pricing
142 </Link>
143 </p>
144 </section>
145 </Card>
146 );
147}