130 lines6,404 bytesCodeBlame

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1import { Box, CircleAlert, MailOpen } from "lucide-react";
2import { Form, data, redirect, useNavigation } from "react-router";
3
4import { REPO_ROLE_LABELS, REPO_ROLE_SUMMARIES } from "@g1t/contracts";
5
6import type { Route } from "./+types/invitations";
7import { Avatar, Button, ButtonLink, ErrorText } from "../../components/ui";
8import { page } from "../../lib/meta";
9import { identity } from "../../lib/services.server";
10import { assertSameOrigin, requireUser } from "../../lib/session.server";
11
12export function meta({ params, ...args }: Route.MetaArgs) {
13 return page(args, { title: `Invitation · ${params.owner}/${params.repo} · g1t` });
14}
15
16/** The viewer's pending invitation to this repository, if they have one. */
17async function invitationTo(user: Parameters<typeof identity.myRepoInvitations>[0], owner: string, repo: string) {
18 const full = `${owner}/${repo}`.toLowerCase();
19 const mine = await identity.myRepoInvitations(user);
20 return mine.find((invitation) => invitation.repo.toLowerCase() === full && invitation.status === "pending") ?? null;
21}
22
23export async function loader({ params, context, request }: Route.LoaderArgs) {
24 // Someone invited usually cannot see the repository yet, so this reads
25 // their own invitations, not the repository.
26 const user = requireUser(context, request);
27 const invitation = await invitationTo(user, params.owner, params.repo);
28 return { invitation, viewer: { username: user.username, avatar: user.avatar ?? null } };
29}
30
31export async function action({ params, context, request }: Route.ActionArgs) {
32 assertSameOrigin(request);
33 const user = requireUser(context, request);
34 const form = await request.formData();
35 const accept = form.get("intent") === "accept";
36 const invitation = await invitationTo(user, params.owner, params.repo);
37 if (!invitation) return data({ error: "This invitation is no longer open." }, { status: 404 });
38 const answered = await identity.respondRepoInvitation(user, invitation.id, accept);
39 if (!answered.ok) return data({ error: answered.error.message }, { status: 422 });
40 // The role is theirs from the next request on: straight to the repository.
41 throw redirect(accept ? `/${invitation.repo}` : "/");
42}
43
44const LONG_DATE = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
45
46export default function RepoInvitation({ loaderData, actionData, params }: Route.ComponentProps) {
47 const { invitation, viewer } = loaderData;
48 const navigation = useNavigation();
49 const answering = navigation.state !== "idle" ? navigation.formData?.get("intent") : null;
50 const full = `${params.owner}/${params.repo}`;
51 if (!invitation) {
52 return (
53 <div className="mx-auto max-w-lg px-4 py-16">
54 <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
55 <CircleAlert size={22} className="text-warn" />
56 No open invitation
57 </h1>
58 <p className="mt-3 text-sm leading-6 text-muted">
59 There is no invitation to <span className="font-mono text-fg">{full}</span> waiting for{" "}
60 <span className="font-mono text-fg">{viewer.username}</span>. It may have been accepted, declined, revoked, or
61 it expired; an invitation lasts 7 days. Ask whoever invited you to send another.
62 </p>
63 <div className="mt-8 flex flex-wrap gap-3">
64 <ButtonLink to={`/${full}`} variant="quiet">
65 Go to {full}
66 </ButtonLink>
67 <ButtonLink to="/" variant="quiet">
68 Mission control
69 </ButtonLink>
70 </div>
71 </div>
72 );
73 }
74 return (
75 <div className="mx-auto max-w-lg px-4 py-16">
76 <div className="flex items-center gap-3">
77 {invitation.invited_by ? (
78 <Avatar name={invitation.invited_by} image={invitation.inviter_avatar} size={44} />
79 ) : (
80 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-full bg-accent/15 text-accent">
81 <MailOpen size={20} />
82 </span>
83 )}
84 <span className="text-faint">→</span>
85 <span className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
86 <Box size={18} />
87 </span>
88 </div>
89 <h1 className="mt-6 text-2xl font-semibold tracking-tight text-balance">
90 {invitation.invited_by ?? "Someone"} invited you to <span className="font-mono break-all">{invitation.repo}</span>
91 </h1>
92 <p className="mt-2 text-sm leading-6 text-muted">
93 Accepting gives you the <span className="text-fg">{REPO_ROLE_LABELS[invitation.role]}</span> role on this
94 repository: {REPO_ROLE_SUMMARIES[invitation.role].replace(/^./, (c) => c.toLowerCase())} You see this repository
95 only, not the rest of the {params.owner} workspace.
96 </p>
97 <dl className="mt-5 space-y-1 text-sm">
98 {invitation.invited_by && (
99 <div className="flex gap-2">
100 <dt className="w-24 shrink-0 text-faint">From</dt>
101 <dd className="font-mono text-fg-soft">@{invitation.invited_by}</dd>
102 </div>
103 )}
104 <div className="flex gap-2">
105 <dt className="w-24 shrink-0 text-faint">Role</dt>
106 <dd className="text-fg-soft">{REPO_ROLE_LABELS[invitation.role]}</dd>
107 </div>
108 <div className="flex gap-2">
109 <dt className="w-24 shrink-0 text-faint">Works until</dt>
110 <dd className="text-fg-soft">{LONG_DATE.format(new Date(invitation.expires_at))}</dd>
111 </div>
112 </dl>
113 <Form method="post" className="mt-8 space-y-3">
114 <p className="flex items-center gap-2 text-sm text-muted">
115 <Avatar name={viewer.username} image={viewer.avatar} size={20} />
116 Signed in as <span className="font-mono text-fg">{viewer.username}</span>
117 </p>
118 <ErrorText>{actionData?.error}</ErrorText>
119 <div className="flex flex-col gap-3 sm:flex-row">
120 <Button type="submit" name="intent" value="accept" disabled={answering != null}>
121 {answering === "accept" ? "Accepting…" : "Accept invitation"}
122 </Button>
123 <Button type="submit" name="intent" value="decline" variant="quiet" disabled={answering != null}>
124 {answering === "decline" ? "Declining…" : "Decline"}
125 </Button>
126 </div>
127 </Form>
128 </div>
129 );
130}