Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 1 | import { CircleAlert, Lock, MailCheck, Ticket } from "lucide-react"; |
| Merge branch 'worktree-agent-a985c01f43538af01' | 2 | import { Form, Link, data, redirect } from "react-router"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 3 | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 4 | import type { InvitePreview, User } from "@g1t/contracts"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 5 | |
| 6 | import type { Route } from "./+types/invite"; | |
| 7 | import { page } from "../lib/meta"; | |
| 8 | import { Mark } from "../components/logo"; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 9 | import { ContinueWithGithub, OrDivider } from "../components/github"; |
| 10 | import { Honeypot } from "../components/honeypot"; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 11 | import { Avatar, ButtonLink, ErrorText, Field, Input, SubmitButton } from "../components/ui"; |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 12 | import { githubSignInEnabled } from "../lib/github.server"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 13 | import { identity } from "../lib/services.server"; |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 14 | import { cleanCode, cleanProof, inviteSignUpCopy, landingFor, looksAutomated, suggestUsername, welcomeCookie } from "../lib/invites"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 15 | import { clientKey } from "../lib/registration.server"; |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 16 | import { assertSameOrigin, getViewer, requireUser, roleIn, startSession } from "../lib/session.server"; |
| 17 | import { rememberWorkspace } from "../lib/workspace-choice"; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 18 | |
| 19 | export function meta(args: Route.MetaArgs) { | |
| 20 | return page(args, { | |
| 21 | title: "You're invited · g1t", | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 22 | description: "An invite to g1t, one workspace where a team and its agents talk, work and ship.", |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 23 | }); |
| 24 | } | |
| 25 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 26 | /** |
| 27 | * Where an invite is used, from first click to landing inside: who sent | |
| 28 | * it and what it is for, then signing up on this page (or signing in, for | |
| 29 | * an address that has an account), and the workspace or repository it | |
| 30 | * gives. Signing in or up elsewhere (GitHub, /login) comes back here with | |
| 31 | * `?accept=1`, which finishes the job. | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 32 | * |
| 33 | * The link in the invite's own email also carries `?proof=`, which only | |
| 34 | * that email has: signing up from it makes the account with the address | |
| 35 | * confirmed already. The code alone (typed in, or a link passed on) does | |
| 36 | * not, and the address is confirmed after sign-up as usual. | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 37 | */ |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 38 | export async function loader({ request, context, params }: Route.LoaderArgs) { |
| 39 | const code = cleanCode(params.code); | |
| 40 | const viewer = getViewer(context); | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 41 | const search = new URL(request.url).searchParams; |
| 42 | const accepting = search.get("accept") === "1"; | |
| 43 | const emailProof = cleanProof(search.get("proof")); | |
| 44 | const checked = await identity.checkInvite(code, clientKey(request), { viewer, anyStatus: true, emailProof }); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 45 | const invite = checked.ok ? checked.value : null; |
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 46 | // A shared link for a group signs up on /register, which names the group. |
| 47 | if (invite?.sharedLabel) throw redirect(`/register?invite=${encodeURIComponent(code)}`); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 48 | const lands = invite ? landingFor(invite) : null; |
| 49 | ||
| 50 | if (viewer && invite) { | |
| 51 | // Used by this person already: just made the account with it (through | |
| 52 | // GitHub), or an old link opened again. | |
| 53 | if (invite.status === "redeemed" && invite.forViewer) throw landIn(request, invite, [], accepting); | |
| 54 | // Back from signing in to accept an invite sent to their address. | |
| 55 | if (accepting && invite.status === "pending" && invite.forViewer === true && lands && !alreadyIn(viewer, invite)) { | |
| 56 | const accepted = await identity.acceptInvite(viewer, code); | |
| 57 | if (accepted.ok) throw landIn(request, invite, [], true); | |
| 58 | return { ...base(), acceptError: accepted.error.message }; | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | function base() { | |
| 63 | return { | |
| 64 | code, | |
| 65 | invite, | |
| 66 | error: checked.ok ? null : checked.error.message, | |
| 67 | viewer: viewer ? { username: viewer.username, avatar: viewer.avatar ?? null } : null, | |
| 68 | alreadyIn: viewer && invite ? alreadyIn(viewer, invite) : false, | |
| 69 | github: false, | |
| 70 | suggestion: suggestUsername(invite?.address), | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 71 | // Only a proof identity accepted goes back into the form. |
| 72 | proof: invite?.emailProven ? emailProof : null, | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 73 | started: Date.now(), |
| 74 | acceptError: null as string | null, | |
| 75 | }; | |
| 76 | } | |
| 77 | const signingUp = !viewer && invite?.status === "pending" && invite.kind === "account" && !invite.hasAccount; | |
| 78 | return { ...base(), github: signingUp ? await githubSignInEnabled() : false }; | |
| 79 | } | |
| 80 | ||
| 81 | /** Whether the viewer is in what the invite gives already. */ | |
| 82 | function alreadyIn(viewer: User, invite: InvitePreview): boolean { | |
| 83 | if (invite.workspace) return roleIn(viewer, invite.workspace.slug) !== null; | |
| 84 | return false; | |
| 85 | } | |
| 86 | ||
| 87 | /** | |
| 88 | * The redirect into what the invite gave, with the session (when one was | |
| 89 | * just made), the welcome for that first view, and the workspace made the | |
| 90 | * one the sidebar is about. | |
| 91 | */ | |
| 92 | function landIn(request: Request, invite: InvitePreview, cookies: string[], welcome: boolean): Response { | |
| 93 | const secure = new URL(request.url).protocol === "https:"; | |
| 94 | const target = landingFor(invite); | |
| 95 | const headers = new Headers(); | |
| 96 | for (const cookie of cookies) headers.append("set-cookie", cookie); | |
| 97 | if (target && welcome) headers.append("set-cookie", welcomeCookie(target, secure)); | |
| 98 | if (invite.workspace) headers.append("set-cookie", rememberWorkspace(invite.workspace.slug, secure)); | |
| 99 | return redirect(target ? `/${target}` : "/", { headers }); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 100 | } |
| 101 | ||
| 102 | export async function action({ request, context, params }: Route.ActionArgs) { | |
| 103 | assertSameOrigin(request); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 104 | const code = cleanCode(params.code); |
| 105 | const form = await request.formData(); | |
| 106 | const client = clientKey(request); | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 107 | const emailProof = cleanProof(String(form.get("proof") ?? "")); |
| 108 | const checked = await identity.checkInvite(code, client, { viewer: getViewer(context), emailProof }); | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 109 | if (!checked.ok) return data({ error: checked.error.message }, { status: 422 }); |
| 110 | const invite = checked.value; | |
| 111 | ||
| 112 | if (form.get("intent") === "register") { | |
| 113 | if (looksAutomated(form)) return data({ error: "Something went wrong. Try again." }, { status: 422 }); | |
| 114 | // An invite for one address makes the account with that address, | |
| 115 | // whatever the form sent. | |
| 116 | const email = invite.address ?? String(form.get("email") ?? ""); | |
| 117 | const result = await identity.register( | |
| 118 | String(form.get("username") ?? ""), | |
| 119 | email, | |
| 120 | String(form.get("password") ?? ""), | |
| 121 | code, | |
| 122 | client, | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 123 | // Identity checks it again, against this invite and this address. |
| 124 | emailProof, | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 125 | ); |
| 126 | if (!result.ok) return data({ error: result.error.message }, { status: 422 }); | |
| 127 | throw landIn(request, invite, [startSession(result.value.sessionToken)], true); | |
| 128 | } | |
| 129 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 130 | const user = requireUser(context, request); |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 131 | const result = await identity.acceptInvite(user, code); |
| Merge branch 'worktree-agent-a985c01f43538af01' | 132 | // Said with a 200, so the page loads again and shows the invite as it |
| 133 | // now stands (used up, revoked) beside the reason: after a 4xx answer | |
| 134 | // React Router keeps the page's data as it was. | |
| 135 | if (!result.ok) return { error: result.error.message }; | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 136 | throw landIn(request, invite, [], true); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 137 | } |
| 138 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 139 | /** Who sent it, and the workspace it joins, as faces. */ |
| 140 | function Faces({ invite }: { invite: InvitePreview }) { | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 141 | const from = invite.invitedBy; |
| 142 | return ( | |
| 143 | <div className="flex items-center gap-3"> | |
| 144 | {from ? ( | |
| 145 | <Avatar name={from.username} image={from.avatar} size={44} /> | |
| 146 | ) : ( | |
| 147 | <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent"> | |
| 148 | <Ticket size={20} /> | |
| 149 | </span> | |
| 150 | )} | |
| 151 | {invite.workspace && ( | |
| 152 | <> | |
| 153 | <span className="text-faint">→</span> | |
| 154 | <Avatar name={invite.workspace.slug} image={invite.workspace.avatar} size={44} square /> | |
| 155 | </> | |
| 156 | )} | |
| 157 | </div> | |
| 158 | ); | |
| 159 | } | |
| 160 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 161 | function senderName(invite: InvitePreview): string { |
| 162 | return invite.invitedBy ? (invite.invitedBy.name ?? invite.invitedBy.username) : "The g1t team"; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 163 | } |
| 164 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 165 | /** "Chase Pierce invited you to join Flagon, Inc. on g1t", with the place in bold. */ |
| 166 | function Headline({ invite }: { invite: InvitePreview }) { | |
| 167 | const from = senderName(invite); | |
| 168 | if (invite.workspace) { | |
| 169 | return ( | |
| 170 | <> | |
| 171 | {from} invited you to join <strong className="font-semibold text-fg">{invite.workspace.name}</strong> on g1t | |
| 172 | </> | |
| 173 | ); | |
| 174 | } | |
| 175 | if (invite.repository) { | |
| 176 | return ( | |
| 177 | <> | |
| 178 | {from} invited you to collaborate on <strong className="font-mono font-semibold text-fg">{invite.repository.name}</strong> | |
| 179 | </> | |
| 180 | ); | |
| 181 | } | |
| 182 | return <>{from} invited you to g1t</>; | |
| 183 | } | |
| 184 | ||
| 185 | function about(invite: InvitePreview, signedIn: boolean): string { | |
| 186 | const signingUp = !signedIn && !invite.hasAccount && invite.kind === "account"; | |
| 187 | if (invite.workspace) { | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 188 | return `g1t is one workspace where a team and its agents talk, work and ship. ${ |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 189 | signingUp ? "Make your account below and you join" : "Accepting joins you to" |
| 190 | } ${invite.workspace.name} as a member.`; | |
| 191 | } | |
| 192 | if (invite.repository) { | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 193 | return `g1t is one workspace where a team and its agents talk, work and ship. ${ |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 194 | signingUp ? "Make your account below and you get" : "Accepting gives you" |
| 195 | } the ${invite.repository.role} role on ${invite.repository.name}.`; | |
| 196 | } | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 197 | return "g1t is one workspace where a team and its agents talk, work and ship: chat with people and agents, give agents a job and a budget, and land code through checks that hold. This invite gets you in."; |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 198 | } |
| 199 | ||
| 200 | /** What accepting is called on its button. */ | |
| 201 | function joinLabel(invite: InvitePreview): string { | |
| 202 | if (invite.workspace) return `Join ${invite.workspace.name}`; | |
| 203 | if (invite.repository) return `Accept access to ${invite.repository.name}`; | |
| 204 | return "Accept invite"; | |
| 205 | } | |
| 206 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 207 | const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }); |
| 208 | ||
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 209 | type Loaded = Route.ComponentProps["loaderData"]; |
| 210 | ||
| 211 | /** Signing up, on this page: the address the invite was sent to, a username, a password. */ | |
| 212 | function SignUp({ loaded, error }: { loaded: Loaded; error: string | null }) { | |
| 213 | const invite = loaded.invite!; | |
| 214 | const here = `/invite/${loaded.code}`; | |
| 215 | const back = `${here}?accept=1`; | |
| 216 | const github = `/auth/github?${new URLSearchParams({ invite: loaded.code, next: back })}`; | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 217 | const copy = inviteSignUpCopy(invite); |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 218 | return ( |
| 219 | <section aria-labelledby="sign-up" className="rounded-xl border border-line bg-surface/60 p-5 sm:p-6"> | |
| 220 | <h2 id="sign-up" className="text-base font-semibold"> | |
| 221 | Create your account | |
| 222 | </h2> | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 223 | <p className="mt-1 text-sm text-muted">{copy.intro}</p> |
| 224 | {copy.confirmed && ( | |
| 225 | <p className="mt-4 flex items-start gap-2 rounded-md border border-success/40 bg-success/5 p-3 text-sm" role="status"> | |
| 226 | <MailCheck size={16} aria-hidden="true" className="mt-0.5 shrink-0 text-success" /> | |
| 227 | <span>{copy.confirmed}</span> | |
| 228 | </p> | |
| 229 | )} | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 230 | {loaded.github && ( |
| 231 | <div className="mt-5"> | |
| 232 | <ContinueWithGithub href={github} /> | |
| 233 | <OrDivider /> | |
| 234 | </div> | |
| 235 | )} | |
| 236 | <Form method="post" className={`relative space-y-4 ${loaded.github ? "" : "mt-5"}`}> | |
| 237 | <input type="hidden" name="intent" value="register" /> | |
| 238 | <Honeypot started={loaded.started} /> | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 239 | {loaded.proof && <input type="hidden" name="proof" value={loaded.proof} />} |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 240 | {invite.address ? ( |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 241 | <Field label="Email" hint={copy.hint}> |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 242 | <span className="relative block"> |
| 243 | <Input name="email" type="email" value={invite.address} readOnly aria-readonly="true" autoComplete="email" /> | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 244 | {copy.confirmed ? ( |
| 245 | <MailCheck size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-success" /> | |
| 246 | ) : ( | |
| 247 | <Lock size={14} aria-hidden="true" className="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 text-faint" /> | |
| 248 | )} | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 249 | </span> |
| 250 | </Field> | |
| 251 | ) : ( | |
| Signing up from the invite email confirms the address: its link carries a proof only that email has, so there is no code to enter | 252 | <Field label="Email" hint={copy.hint}> |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 253 | <Input name="email" type="email" autoComplete="email" required maxLength={254} /> |
| 254 | </Field> | |
| 255 | )} | |
| 256 | <Field label="Username" hint="Lowercase letters, digits and hyphens. It is how you sign in and how others see you."> | |
| 257 | <Input | |
| 258 | name="username" | |
| 259 | autoComplete="username" | |
| 260 | required | |
| 261 | autoFocus | |
| 262 | maxLength={39} | |
| 263 | defaultValue={loaded.suggestion} | |
| 264 | pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*" | |
| 265 | /> | |
| 266 | </Field> | |
| 267 | <Field label="Password" hint="At least 10 characters."> | |
| 268 | <Input name="password" type="password" autoComplete="new-password" required minLength={10} /> | |
| 269 | </Field> | |
| 270 | <ErrorText>{error}</ErrorText> | |
| 271 | <div className="pt-1 *:w-full"> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 272 | <SubmitButton pending="Creating account…" match={{ intent: "register" }}> |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 273 | {invite.workspace |
| 274 | ? `Create account and join ${invite.workspace.name}` | |
| 275 | : invite.repository | |
| 276 | ? "Create account and accept" | |
| 277 | : "Create account"} | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 278 | </SubmitButton> |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 279 | </div> |
| 280 | </Form> | |
| 281 | <p className="mt-5 text-center text-sm text-muted"> | |
| 282 | Already on g1t?{" "} | |
| 283 | <Link to={`/login?next=${encodeURIComponent(back)}`} className="text-fg underline underline-offset-4"> | |
| 284 | Sign in to accept | |
| 285 | </Link> | |
| 286 | </p> | |
| 287 | </section> | |
| 288 | ); | |
| 289 | } | |
| 290 | ||
| 291 | /** What to do next: join, sign in, sign out, or sign up. */ | |
| 292 | function Next({ loaded, error }: { loaded: Loaded; error: string | null }) { | |
| 293 | const invite = loaded.invite!; | |
| 294 | const viewer = loaded.viewer; | |
| 295 | const here = `/invite/${loaded.code}`; | |
| 296 | const back = `${here}?accept=1`; | |
| 297 | const joinable = invite.workspace !== null || invite.repository !== null; | |
| 298 | ||
| 299 | if (viewer) { | |
| 300 | const signedInAs = ( | |
| 301 | <p className="flex items-center gap-2 text-sm text-muted"> | |
| 302 | <Avatar name={viewer.username} image={viewer.avatar} size={20} /> | |
| 303 | Signed in as <span className="font-mono text-fg">{viewer.username}</span> | |
| 304 | </p> | |
| 305 | ); | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 306 | // Its own intent, which /logout ignores, so only its button says it is working. |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 307 | const signOut = (label: string, variant: "primary" | "quiet") => ( |
| 308 | <Form method="post" action={`/logout?next=${encodeURIComponent(here)}`}> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 309 | <input type="hidden" name="intent" value="sign-out" /> |
| 310 | <SubmitButton variant={variant} pending="Signing out…" match={{ intent: "sign-out" }}> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 311 | {label} |
| Merge branch 'worktree-agent-a985c01f43538af01' | 312 | </SubmitButton> |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 313 | </Form> |
| 314 | ); | |
| 315 | if (invite.forViewer === false) { | |
| 316 | return ( | |
| 317 | <div className="space-y-4"> | |
| 318 | {signedInAs} | |
| 319 | <div className="rounded-md border border-warn/40 bg-warn/5 p-4 text-sm" role="status"> | |
| 320 | <p className="font-medium text-fg">This invite is for {invite.address ?? invite.email}, not this account.</p> | |
| 321 | <p className="mt-1 text-muted"> | |
| 322 | Only an account with that address confirmed can use it. Sign out, then sign in or sign up with it. | |
| 323 | </p> | |
| 324 | </div> | |
| 325 | <ErrorText>{error}</ErrorText> | |
| 326 | <div className="flex flex-wrap gap-3">{signOut("Sign out and continue", "primary")}</div> | |
| 327 | </div> | |
| 328 | ); | |
| 329 | } | |
| 330 | if (loaded.alreadyIn && invite.workspace) { | |
| 331 | return ( | |
| 332 | <div className="space-y-4"> | |
| 333 | {signedInAs} | |
| 334 | <p className="text-sm text-muted">You are in {invite.workspace.name} already.</p> | |
| 335 | <ButtonLink to={`/${invite.workspace.slug}`}>Go to {invite.workspace.name}</ButtonLink> | |
| 336 | </div> | |
| 337 | ); | |
| 338 | } | |
| 339 | if (!joinable) { | |
| 340 | return ( | |
| 341 | <div className="space-y-4"> | |
| 342 | {signedInAs} | |
| 343 | <div className="rounded-md border border-line bg-surface p-4 text-sm"> | |
| 344 | <p> | |
| 345 | You already have a g1t account, <span className="font-mono">{viewer.username}</span>, so this invite has | |
| 346 | nothing more to give you. | |
| 347 | </p> | |
| 348 | <p className="mt-2 text-muted">Pass it on to whoever it was meant for, or keep it for someone else.</p> | |
| 349 | </div> | |
| 350 | <div className="flex flex-wrap gap-3">{signOut("Sign out to use it", "quiet")}</div> | |
| 351 | </div> | |
| 352 | ); | |
| 353 | } | |
| 354 | return ( | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 355 | <> |
| 356 | {/* "Not you?" posts this form, so it does not carry the accept form's intent. */} | |
| 357 | <Form id="invite-sign-out" method="post" action={`/logout?next=${encodeURIComponent(here)}`} hidden> | |
| 358 | <input type="hidden" name="intent" value="sign-out" /> | |
| 359 | </Form> | |
| 360 | <Form method="post" className="space-y-4"> | |
| 361 | <input type="hidden" name="intent" value="accept" /> | |
| 362 | {signedInAs} | |
| 363 | {invite.forViewer === null && ( | |
| 364 | <p className="text-sm text-muted">This invite is for anyone with the link. Accepting uses it up.</p> | |
| 365 | )} | |
| 366 | <ErrorText>{error}</ErrorText> | |
| 367 | <div className="flex flex-wrap items-center gap-3"> | |
| 368 | <SubmitButton pending={invite.workspace ? "Joining…" : "Accepting…"} match={{ intent: "accept" }}> | |
| 369 | {joinLabel(invite)} | |
| 370 | </SubmitButton> | |
| 371 | <span className="text-sm text-muted"> | |
| 372 | Not you?{" "} | |
| 373 | <SubmitButton | |
| 374 | form="invite-sign-out" | |
| 375 | pending="Signing out…" | |
| 376 | match={{ intent: "sign-out" }} | |
| 377 | className="inline-flex items-center gap-1 text-fg underline underline-offset-4 disabled:opacity-50" | |
| 378 | > | |
| 379 | Sign out | |
| 380 | </SubmitButton> | |
| 381 | </span> | |
| 382 | </div> | |
| 383 | </Form> | |
| 384 | </> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 385 | ); |
| 386 | } | |
| 387 | ||
| 388 | // Someone already on g1t: sign in, and the invite is accepted on return. | |
| 389 | if (invite.hasAccount || invite.kind === "workspace") { | |
| 390 | return ( | |
| 391 | <div className="space-y-4"> | |
| 392 | <div className="rounded-md border border-line bg-surface p-4 text-sm"> | |
| 393 | <p className="font-medium text-fg">{invite.address ?? invite.email ?? "This address"} has a g1t account.</p> | |
| 394 | <p className="mt-1 text-muted"> | |
| 395 | Sign in to it and {joinable ? `you ${invite.workspace ? `join ${invite.workspace.name}` : `get access to ${invite.repository!.name}`} straight away` : "the invite is accepted"}. | |
| 396 | </p> | |
| 397 | </div> | |
| 398 | <ErrorText>{error}</ErrorText> | |
| 399 | <ButtonLink to={`/login?next=${encodeURIComponent(back)}`}>Sign in to accept</ButtonLink> | |
| 400 | </div> | |
| 401 | ); | |
| 402 | } | |
| 403 | return <SignUp loaded={loaded} error={error} />; | |
| 404 | } | |
| 405 | ||
| 406 | /** A code that cannot be used: why, and whom to ask. */ | |
| 407 | function Dead({ loaded }: { loaded: Loaded }) { | |
| 408 | const invite = loaded.invite; | |
| 409 | const title = !invite | |
| 410 | ? "This invite link does not work" | |
| 411 | : invite.status === "expired" | |
| 412 | ? "This invite has expired" | |
| 413 | : invite.status === "revoked" | |
| 414 | ? "This invite was withdrawn" | |
| 415 | : "This invite has been used"; | |
| 416 | const from = invite?.invitedBy; | |
| 417 | return ( | |
| 418 | <> | |
| 419 | <h1 className="mt-6 flex items-center gap-2 text-2xl font-semibold tracking-tight"> | |
| 420 | <CircleAlert size={22} className="shrink-0 text-warn" /> | |
| 421 | {title} | |
| 422 | </h1> | |
| 423 | {invite ? ( | |
| 424 | <p className="mt-3 text-sm leading-6 text-muted"> | |
| 425 | It was {invite.workspace ? `an invite to join ${invite.workspace.name}` : invite.repository ? `an invite to ${invite.repository.name}` : "an invite to g1t"} | |
| 426 | {invite.status === "expired" && `, and stopped working on ${LONG_DATE.format(new Date(invite.expiresAt))}`}.{" "} | |
| 427 | {from ? "Ask the person who sent it for a new one." : "Ask for access and we will send a new one."} | |
| 428 | </p> | |
| 429 | ) : ( | |
| 430 | <p className="mt-3 text-sm leading-6 text-muted">{loaded.error}</p> | |
| 431 | )} | |
| 432 | {from && ( | |
| 433 | <div className="mt-5 flex items-center gap-3 rounded-lg border border-line bg-surface p-3"> | |
| 434 | <Avatar name={from.username} image={from.avatar} size={36} /> | |
| 435 | <p className="min-w-0 text-sm"> | |
| 436 | Ask <span className="font-medium text-fg">{from.name ?? from.username}</span>{" "} | |
| 437 | <Link to={`/u/${from.username}`} className="font-mono text-muted hover:text-fg"> | |
| 438 | @{from.username} | |
| 439 | </Link>{" "} | |
| 440 | <span className="text-muted">for a new invite.</span> | |
| 441 | </p> | |
| 442 | </div> | |
| 443 | )} | |
| 444 | <div className="mt-8 flex flex-wrap gap-3"> | |
| Sign-up buttons say Sign up everywhere; only the sign-up page says registration takes an invite | 445 | <ButtonLink to="/register#request">Sign up</ButtonLink> |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 446 | <ButtonLink to={loaded.viewer ? "/" : "/login"} variant="quiet"> |
| 447 | {loaded.viewer ? "Go to g1t" : "Sign in"} | |
| 448 | </ButtonLink> | |
| 449 | </div> | |
| 450 | </> | |
| 451 | ); | |
| 452 | } | |
| 453 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 454 | export default function Invite({ loaderData, actionData }: Route.ComponentProps) { |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 455 | const { invite } = loaderData; |
| 456 | const error = (actionData && "error" in actionData ? actionData.error : null) ?? loaderData.acceptError; | |
| 457 | const usable = invite?.status === "pending"; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 458 | return ( |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 459 | <main className="mx-auto flex max-w-md flex-col px-4 pt-16 pb-12 sm:pt-20"> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 460 | <Mark className="size-9" /> |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 461 | {usable ? ( |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 462 | <> |
| 463 | <div className="mt-8"> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 464 | <Faces invite={invite} /> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 465 | </div> |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 466 | <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance text-fg-soft"> |
| 467 | <Headline invite={invite} /> | |
| 468 | </h1> | |
| 469 | <p className="mt-2 text-sm leading-6 text-muted">{about(invite, loaderData.viewer !== null)}</p> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 470 | <dl className="mt-5 space-y-1 text-sm"> |
| 471 | {invite.invitedBy && ( | |
| 472 | <div className="flex gap-2"> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 473 | <dt className="w-24 shrink-0 text-faint">From</dt> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 474 | <dd className="font-mono text-fg-soft">@{invite.invitedBy.username}</dd> |
| 475 | </div> | |
| 476 | )} | |
| 477 | {invite.email && ( | |
| 478 | <div className="flex gap-2"> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 479 | <dt className="w-24 shrink-0 text-faint">For</dt> |
| 480 | <dd className="font-mono break-all text-fg-soft">{invite.address ?? invite.email}</dd> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 481 | </div> |
| 482 | )} | |
| 483 | <div className="flex gap-2"> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 484 | <dt className="w-24 shrink-0 text-faint">Works until</dt> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 485 | <dd className="text-fg-soft">{LONG_DATE.format(new Date(invite.expiresAt))}</dd> |
| 486 | </div> | |
| 487 | </dl> | |
| 488 | <div className="mt-8"> | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 489 | <Next loaded={loaderData} error={error} /> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 490 | </div> |
| 491 | </> | |
| 492 | ) : ( | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 493 | <Dead loaded={loaderData} /> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 494 | )} |
| 495 | </main> | |
| 496 | ); | |
| 497 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.