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