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