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