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 main (membership, two-factor, GitHub repo roles) into tokens | 1 | import { ArrowUpRight, Check, Ellipsis, ShieldAlert, ShieldCheck, Users } from "lucide-react"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 2 | import { useState } from "react"; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 3 | import { Form, Link, data, redirect, useSearchParams, useSubmit } from "react-router"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 4 | |
| 5 | import { | |
| 6 | BASE_PERMISSIONS, | |
| 7 | BASE_PERMISSION_LABELS, | |
| 8 | type BasePermission, | |
| 9 | DEFAULT_BASE_PERMISSION, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 10 | type Member, |
| 11 | ORG_ROLES, | |
| 12 | ORG_ROLE_LABELS, | |
| 13 | ORG_ROLE_SUMMARIES, | |
| 14 | type OrgRole, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 15 | REPO_ROLE_LABELS, |
| 16 | } from "@g1t/contracts"; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 17 | |
| 18 | import type { Route } from "./+types/people"; | |
| Social cards for every page: og.g1t.sh | 19 | import { page } from "../../lib/meta"; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 20 | import { Avatar, Button, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui"; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 21 | import { Badge } from "../../components/ui/badge"; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 22 | import { |
| 23 | AlertDialog, | |
| 24 | AlertDialogCancel, | |
| 25 | AlertDialogContent, | |
| 26 | AlertDialogDescription, | |
| 27 | AlertDialogFooter, | |
| 28 | AlertDialogHeader, | |
| 29 | AlertDialogTitle, | |
| 30 | } from "../../components/ui/alert-dialog"; | |
| 31 | import { | |
| 32 | DropdownMenu, | |
| 33 | DropdownMenuContent, | |
| 34 | DropdownMenuItem, | |
| 35 | DropdownMenuLabel, | |
| 36 | DropdownMenuSeparator, | |
| 37 | DropdownMenuTrigger, | |
| 38 | } from "../../components/ui/dropdown-menu"; | |
| 39 | import { Hint } from "../../components/ui/hint"; | |
| 40 | import { forgetWorkspace } from "../../lib/workspace-choice"; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 41 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| 42 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; | |
| 43 | import { inviteLink, inviteState, moreInvitesMailto } from "../../lib/invites"; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 44 | import { billing, identity } from "../../lib/services.server"; |
| 45 | import { StartPlanToInvite } from "../../components/start-plan"; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 46 | import { |
| 47 | assertSameOrigin, | |
| 48 | getViewer, | |
| 49 | requireUser, | |
| 50 | roleIn, | |
| 51 | unwrap, | |
| 52 | } from "../../lib/session.server"; | |
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 53 | import { UserCard } from "../../components/user-card"; |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 54 | import { PeoplePicker } from "../../components/people-picker"; |
| 55 | import { inviteTarget } from "../../lib/people-search"; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 56 | |
| Social cards for every page: og.g1t.sh | 57 | export function meta({ params, ...args }: Route.MetaArgs) { |
| Navigation: the workspace's own page with tabs, pins and recent in the sidebar | 58 | return page(args, { title: `People · ${params.owner} · g1t` }); |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 59 | } |
| 60 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 61 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 62 | const viewer = getViewer(context); |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 63 | const role = roleIn(viewer, params.owner); |
| 64 | // Who is in a workspace is its members' business; anyone else, including | |
| 65 | // someone a repository is shared with, gets nothing here. | |
| 66 | if (!role) throw data(null, { status: 404 }); | |
| 67 | const owner = role === "owner"; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 68 | const [members, invites, workspace, outside, teams, free] = await Promise.all([ |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 69 | identity.listMembers(params.owner, viewer), |
| 70 | owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null, | |
| 71 | identity.getWorkspace(params.owner), | |
| 72 | owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 73 | // Each member's teams, as the viewer may see them. |
| 74 | identity.teamMemberships(viewer, params.owner).catch(() => null), | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 75 | // A free workspace adds no one until it starts the plan; identity |
| 76 | // refuses it either way, so a failure here only hides the note. | |
| 77 | billing.freeWorkspaces([params.owner]).catch(() => [] as string[]), | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 78 | ]); |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 79 | return { |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 80 | role, |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 81 | free: free.includes(params.owner.toLowerCase()), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 82 | members: unwrap(members), |
| 83 | invites: invites?.ok ? invites.value : [], | |
| 84 | base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION, | |
| 85 | outside: outside?.ok ? outside.value : [], | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 86 | teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])), |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 87 | origin: new URL(request.url).origin, |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 88 | me: viewer?.username ?? null, |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 89 | }; |
| 90 | } | |
| 91 | ||
| 92 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 93 | assertSameOrigin(request); | |
| 94 | const user = requireUser(context, request); | |
| 95 | const form = await request.formData(); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 96 | if (form.get("action") === "revoke-invite") { |
| 97 | const result = await identity.revokeWorkspaceInvite(user, params.owner, String(form.get("id") ?? "")); | |
| 98 | return result.ok ? null : { error: result.error.message }; | |
| 99 | } | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 100 | const target = String(form.get("member") ?? "").trim(); |
| 101 | // Owner or member, and the roles that add to a member. | |
| 102 | if (form.get("action") === "role") { | |
| 103 | const role = form.get("role") === "owner" ? "owner" : "member"; | |
| 104 | const result = await identity.updateMember(user, params.owner, target, { role }); | |
| 105 | return result.ok ? { changed: target } : { error: result.error.message, row: target }; | |
| 106 | } | |
| 107 | if (form.get("action") === "org-roles") { | |
| 108 | const roles = String(form.get("org_roles") ?? "") | |
| 109 | .split(",") | |
| 110 | .filter((role): role is OrgRole => (ORG_ROLES as readonly string[]).includes(role)); | |
| 111 | const result = await identity.updateMember(user, params.owner, target, { org_roles: roles }); | |
| 112 | return result.ok ? { changed: target } : { error: result.error.message, row: target }; | |
| 113 | } | |
| 114 | if (form.get("action") === "transfer") { | |
| 115 | const result = await identity.transferOwnership(user, params.owner, target); | |
| 116 | return result.ok ? { transferred: target } : { error: result.error.message, row: target }; | |
| 117 | } | |
| 118 | if (form.get("action") === "leave") { | |
| 119 | const result = await identity.leaveWorkspace(user, params.owner); | |
| 120 | if (!result.ok) return { error: result.error.message, leaving: true }; | |
| 121 | const secure = new URL(request.url).protocol === "https:"; | |
| 122 | throw redirect("/", { headers: { "Set-Cookie": forgetWorkspace(secure) } }); | |
| 123 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 124 | if (form.get("action") === "base-permission") { |
| 125 | const base = String(form.get("base") ?? ""); | |
| 126 | if (!(BASE_PERMISSIONS as readonly string[]).includes(base)) return { error: "Choose a base permission." }; | |
| 127 | const result = await identity.setBasePermission(user, params.owner, base as BasePermission); | |
| 128 | return result.ok ? { based: result.value } : { error: result.error.message, base: true }; | |
| 129 | } | |
| 130 | const member = String(form.get("member") ?? "").trim(); | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 131 | if (form.get("action") === "remove") { |
| 132 | const result = await identity.removeMember(user, params.owner, member); | |
| 133 | return result.ok ? null : { error: result.error.message, converting: false }; | |
| 134 | } | |
| 135 | // Nobody joins without saying yes: a username or an address gets an | |
| 136 | // invitation to accept or decline, with the role chosen here. | |
| 137 | const who = inviteTarget(member); | |
| 138 | if (!who) return { error: "Enter a g1t username or an email address.", outOfInvites: false }; | |
| 139 | const role = form.get("role") === "owner" ? "owner" : "member"; | |
| 140 | const result = await identity.inviteMember(user, params.owner, { ...who, role }); | |
| 141 | if (!result.ok) { | |
| 142 | return { error: result.error.message, outOfInvites: result.error.code === "limit", converting: form.get("action") === "convert" }; | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 143 | } |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 144 | if (form.get("action") === "convert") return { converted: member }; |
| 145 | return { invited: "email" in who ? who.email : `@${"username" in who ? who.username : member}`, outOfInvites: false }; | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 146 | } |
| 147 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 148 | /** What the base permission means for members, in a sentence. */ |
| 149 | const BASE_MEANS: Record<BasePermission, string> = { | |
| 150 | none: "Members see only public repositories and the ones they are given a role on.", | |
| 151 | read: "Members can see and clone every repository, open issues and pull requests, and comment.", | |
| 152 | write: "Members can see every repository, manage its issues and pull requests, push, merge, and put agents to work.", | |
| 153 | admin: "Members can do everything on every repository, including its settings, webhooks, secrets and who has access. Only owners transfer or delete one.", | |
| 154 | }; | |
| 155 | ||
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 156 | /** What a member's roles say about them, as badges: owner or member, then the roles on top. */ |
| 157 | function RoleBadges({ member }: { member: Member }) { | |
| 158 | return ( | |
| 159 | <span className="flex flex-wrap justify-end gap-1"> | |
| 160 | {member.role === "owner" ? <Badge tone="accent">Owner</Badge> : <Badge>Member</Badge>} | |
| 161 | {(member.org_roles ?? []).map((role) => ( | |
| 162 | <Hint key={role} label={ORG_ROLE_SUMMARIES[role]}> | |
| 163 | <Badge tone="info" tabIndex={0}> | |
| 164 | {ORG_ROLE_LABELS[role]} | |
| 165 | </Badge> | |
| 166 | </Hint> | |
| 167 | ))} | |
| 168 | </span> | |
| 169 | ); | |
| 170 | } | |
| 171 | ||
| 172 | /** Whether a member has two-factor authentication on, for owners. */ | |
| 173 | function TwoFactorMark({ on }: { on: boolean | null | undefined }) { | |
| 174 | if (on == null) return null; | |
| 175 | return on ? ( | |
| 176 | <Hint label="Two-factor authentication is on"> | |
| 177 | <span tabIndex={0} className="inline-flex text-success" aria-label="Two-factor authentication on"> | |
| 178 | <ShieldCheck size={15} /> | |
| 179 | </span> | |
| 180 | </Hint> | |
| 181 | ) : ( | |
| 182 | <Hint label="Two-factor authentication is off"> | |
| 183 | <span tabIndex={0} className="inline-flex text-warn" aria-label="Two-factor authentication off"> | |
| 184 | <ShieldAlert size={15} /> | |
| 185 | </span> | |
| 186 | </Hint> | |
| 187 | ); | |
| 188 | } | |
| 189 | ||
| 190 | /** An owner's menu for one member: their role, the roles on top, handing over and removing. */ | |
| 191 | function MemberMenu({ member, self, owners, slug }: { member: Member; self: boolean; owners: number; slug: string }) { | |
| 192 | const submit = useSubmit(); | |
| 193 | const [confirm, setConfirm] = useState<"transfer" | "remove" | null>(null); | |
| 194 | const roles = member.org_roles ?? []; | |
| 195 | const post = (fields: Record<string, string>) => | |
| 196 | submit({ ...fields, member: member.username }, { method: "post", preventScrollReset: true }); | |
| 197 | const lastOwner = member.role === "owner" && owners <= 1; | |
| 198 | return ( | |
| 199 | <> | |
| 200 | <DropdownMenu> | |
| 201 | <DropdownMenuTrigger asChild> | |
| 202 | <button | |
| 203 | type="button" | |
| 204 | aria-label={`Manage ${member.username}`} | |
| 205 | className="rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg" | |
| 206 | > | |
| 207 | <Ellipsis size={16} /> | |
| 208 | </button> | |
| 209 | </DropdownMenuTrigger> | |
| 210 | <DropdownMenuContent align="end" className="w-64"> | |
| 211 | <DropdownMenuLabel>Role in {slug}</DropdownMenuLabel> | |
| 212 | {member.role === "member" ? ( | |
| 213 | <DropdownMenuItem onSelect={() => post({ action: "role", role: "owner" })}>Make owner</DropdownMenuItem> | |
| 214 | ) : ( | |
| 215 | <DropdownMenuItem disabled={lastOwner} onSelect={() => post({ action: "role", role: "member" })}> | |
| 216 | {lastOwner ? "The only owner" : "Make member"} | |
| 217 | </DropdownMenuItem> | |
| 218 | )} | |
| 219 | <DropdownMenuSeparator /> | |
| 220 | <DropdownMenuLabel>Also</DropdownMenuLabel> | |
| 221 | {ORG_ROLES.map((role) => { | |
| 222 | const has = roles.includes(role); | |
| 223 | const next = has ? roles.filter((other) => other !== role) : [...roles, role]; | |
| 224 | return ( | |
| 225 | <DropdownMenuItem key={role} onSelect={() => post({ action: "org-roles", org_roles: next.join(",") })}> | |
| 226 | <span className="flex size-4 items-center justify-center">{has && <Check />}</span> | |
| 227 | {ORG_ROLE_LABELS[role]} | |
| 228 | </DropdownMenuItem> | |
| 229 | ); | |
| 230 | })} | |
| 231 | {!self && ( | |
| 232 | <> | |
| 233 | <DropdownMenuSeparator /> | |
| 234 | {member.role === "member" && ( | |
| 235 | <DropdownMenuItem onSelect={() => setConfirm("transfer")}>Transfer ownership…</DropdownMenuItem> | |
| 236 | )} | |
| 237 | <DropdownMenuItem disabled={lastOwner} onSelect={() => setConfirm("remove")} className="text-danger"> | |
| 238 | Remove from {slug}… | |
| 239 | </DropdownMenuItem> | |
| 240 | </> | |
| 241 | )} | |
| 242 | </DropdownMenuContent> | |
| 243 | </DropdownMenu> | |
| 244 | <AlertDialog open={confirm !== null} onOpenChange={(open) => !open && setConfirm(null)}> | |
| 245 | <AlertDialogContent> | |
| 246 | <Form method="post" className="grid gap-4" onSubmit={() => setConfirm(null)}> | |
| 247 | <input type="hidden" name="action" value={confirm === "transfer" ? "transfer" : "remove"} /> | |
| 248 | <input type="hidden" name="member" value={member.username} /> | |
| 249 | <AlertDialogHeader> | |
| 250 | <AlertDialogTitle> | |
| 251 | {confirm === "transfer" ? `Hand ${slug} to ${member.username}?` : `Remove ${member.username} from ${slug}?`} | |
| 252 | </AlertDialogTitle> | |
| 253 | <AlertDialogDescription> | |
| 254 | {confirm === "transfer" | |
| 255 | ? `${member.username} becomes an owner and you a member. Only an owner can make you an owner again.` | |
| 256 | : `Their roles on ${slug}'s repositories and their place in its teams go too. To keep them on a repository, add them back to it as an outside collaborator.`} | |
| 257 | </AlertDialogDescription> | |
| 258 | </AlertDialogHeader> | |
| 259 | <AlertDialogFooter> | |
| 260 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 261 | <SubmitButton variant="danger" match={{ action: confirm ?? "", member: member.username }} pending="Working…"> | |
| 262 | {confirm === "transfer" ? "Transfer ownership" : "Remove"} | |
| 263 | </SubmitButton> | |
| 264 | </AlertDialogFooter> | |
| 265 | </Form> | |
| 266 | </AlertDialogContent> | |
| 267 | </AlertDialog> | |
| 268 | </> | |
| 269 | ); | |
| 270 | } | |
| 271 | ||
| 272 | /** Leaving the workspace, for anyone in it. */ | |
| 273 | function LeaveSection({ slug, soleOwner, error }: { slug: string; soleOwner: boolean; error: string | null }) { | |
| 274 | const [open, setOpen] = useState(false); | |
| 275 | return ( | |
| 276 | <section id="leave" className="mt-10 scroll-mt-20 border-t border-line pt-8"> | |
| 277 | <h2 className="font-medium">Leave {slug}</h2> | |
| 278 | <p className="mt-1 text-sm text-muted"> | |
| 279 | {soleOwner | |
| 280 | ? `You are ${slug}'s only owner. Make another member an owner first, or delete the workspace in its settings.` | |
| 281 | : "Your roles on its repositories and your place in its teams go too. An owner can add you again."} | |
| 282 | </p> | |
| 283 | <div className="mt-4"> | |
| 284 | <Button variant="danger" type="button" disabled={soleOwner} onClick={() => setOpen(true)}> | |
| 285 | Leave {slug} | |
| 286 | </Button> | |
| 287 | </div> | |
| 288 | <ErrorText>{error}</ErrorText> | |
| 289 | <AlertDialog open={open} onOpenChange={setOpen}> | |
| 290 | <AlertDialogContent> | |
| 291 | <Form method="post" className="grid gap-4"> | |
| 292 | <input type="hidden" name="action" value="leave" /> | |
| 293 | <AlertDialogHeader> | |
| 294 | <AlertDialogTitle>Leave {slug}?</AlertDialogTitle> | |
| 295 | <AlertDialogDescription>You lose access to its private repositories at once.</AlertDialogDescription> | |
| 296 | </AlertDialogHeader> | |
| 297 | <AlertDialogFooter> | |
| 298 | <AlertDialogCancel type="button">Cancel</AlertDialogCancel> | |
| 299 | <SubmitButton variant="danger" match={{ action: "leave" }} pending="Leaving…"> | |
| 300 | Leave | |
| 301 | </SubmitButton> | |
| 302 | </AlertDialogFooter> | |
| 303 | </Form> | |
| 304 | </AlertDialogContent> | |
| 305 | </AlertDialog> | |
| 306 | </section> | |
| 307 | ); | |
| 308 | } | |
| 309 | ||
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 310 | export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) { |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 311 | const { role, members, invites, origin, base, outside, teams, free, me } = loaderData; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 312 | const owner = role === "owner"; |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 313 | const owners = members.filter((member) => member.role === "owner").length; |
| 314 | const rowError = (username: string) => | |
| 315 | actionData && "row" in actionData && actionData.row === username ? (actionData.error ?? null) : null; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 316 | // Waiting to be used, or used by someone still confirming their email: |
| 317 | // either can be revoked. | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 318 | // Waiting for an answer to a workspace invitation, too. |
| 319 | const pending = invites.filter( | |
| 320 | (invite) => invite.status === "pending" || invite.status === "awaiting_confirmation" || invite.status === "awaiting_answer", | |
| 321 | ); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 322 | const [search, setSearch] = useSearchParams(); |
| 323 | const tab = search.get("tab") === "outside" && owner ? "outside" : "members"; | |
| 324 | const membersTab = ( | |
| 325 | <> | |
| A catalogue of model providers, and settings that feel like settings | 326 | <ul className="divide-y divide-line rounded-xl border border-line"> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 327 | {members.map((member) => ( |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 328 | <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3"> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 329 | <Avatar name={member.username} image={member.avatar} size={28} /> |
| 330 | <div className="min-w-0 grow truncate"> | |
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 331 | <UserCard username={member.username}> |
| 332 | <Link to={`/u/${member.username}`} className="font-mono text-sm hover:text-accent"> | |
| 333 | {member.username} | |
| 334 | </Link> | |
| 335 | </UserCard> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 336 | {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>} |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 337 | {(teams[member.username] ?? []).length > 0 && ( |
| 338 | <div className="mt-1 flex flex-wrap gap-1"> | |
| 339 | {(teams[member.username] ?? []).slice(0, 3).map((team) => ( | |
| 340 | <Link | |
| 341 | key={team.slug} | |
| 342 | to={`/${params.owner}/-/teams/${team.slug}`} | |
| 343 | className="rounded-full border border-line px-2 py-px text-xs text-muted transition-colors hover:border-line-strong hover:text-fg" | |
| 344 | > | |
| 345 | {team.name} | |
| 346 | </Link> | |
| 347 | ))} | |
| 348 | {(teams[member.username] ?? []).length > 3 && ( | |
| 349 | <span className="px-1 text-xs text-faint">+{(teams[member.username] ?? []).length - 3} more</span> | |
| 350 | )} | |
| 351 | </div> | |
| 352 | )} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 353 | </div> |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 354 | {/* Together, so on a narrow screen they move under the name as one. */} |
| 355 | <div className="ml-auto flex items-center gap-2"> | |
| 356 | {owner && <TwoFactorMark on={member.two_factor} />} | |
| 357 | <RoleBadges member={member} /> | |
| 358 | {owner && <MemberMenu member={member} self={member.username === me} owners={owners} slug={params.owner} />} | |
| 359 | </div> | |
| 360 | {rowError(member.username) && ( | |
| 361 | <p className="basis-full text-sm text-danger" role="alert"> | |
| 362 | {rowError(member.username)} | |
| 363 | </p> | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 364 | )} |
| 365 | </li> | |
| 366 | ))} | |
| 367 | </ul> | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 368 | {owner && free && ( |
| 369 | <div className="mt-6"> | |
| 370 | <StartPlanToInvite workspace={params.owner} owner={owner} /> | |
| 371 | </div> | |
| 372 | )} | |
| 373 | {owner && !free && ( | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 374 | // Empty again once the person is on the list; kept as typed when it failed. |
| 375 | <Form | |
| 376 | method="post" | |
| 377 | key={`${members.length}:${pending.length}`} | |
| 378 | className="mt-6 flex flex-col gap-3 sm:flex-row sm:items-start" | |
| 379 | > | |
| 380 | <input type="hidden" name="action" value="add" /> | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 381 | <div className="grow"> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 382 | <Field |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 383 | label="Invite someone" |
| 384 | hint="Search people on g1t by username or name, or enter an email address. They get an invitation in their inbox and by email, and join once they accept. An address without a g1t account gets an invite to make one, using one of your invites." | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 385 | > |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 386 | <PeoplePicker name="member" placeholder="username, name or name@example.com" /> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 387 | </Field> |
| 388 | </div> | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 389 | <label className="block"> |
| 390 | <span className="mb-1.5 block text-sm font-medium text-muted">Role</span> | |
| 391 | <select | |
| 392 | name="role" | |
| 393 | defaultValue="member" | |
| 394 | className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim sm:w-auto" | |
| 395 | > | |
| 396 | <option value="member">Member</option> | |
| 397 | <option value="owner">Owner</option> | |
| 398 | </select> | |
| 399 | </label> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 400 | <div className="sm:pt-[1.625rem]"> |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 401 | <SubmitButton match={{ action: "add" }} pending="Inviting…"> |
| 402 | Invite | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 403 | </SubmitButton> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 404 | </div> |
| 405 | </Form> | |
| 406 | )} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 407 | {actionData && "invited" in actionData && actionData.invited && ( |
| 408 | <p className="text-sm text-muted" role="status"> | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 409 | Invitation sent to <span className="text-fg">{actionData.invited}</span>. They join once they accept. |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 410 | </p> |
| 411 | )} | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 412 | {actionData && "transferred" in actionData && ( |
| 413 | <p className="text-sm text-muted" role="status"> | |
| 414 | <span className="font-mono text-fg">{actionData.transferred}</span> owns {params.owner} now, and you are a member. | |
| 415 | </p> | |
| 416 | )} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 417 | {actionData && "converted" in actionData && ( |
| 418 | <p className="text-sm text-muted" role="status"> | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 419 | <span className="font-mono text-fg">{actionData.converted}</span> is invited to join {params.owner} as a member, and joins once they accept. |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 420 | </p> |
| 421 | )} | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 422 | {actionData && |
| 423 | !("base" in actionData) && | |
| 424 | !("row" in actionData) && | |
| 425 | !("leaving" in actionData) && | |
| 426 | !("converting" in actionData && actionData.converting) && ( | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 427 | <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText> |
| 428 | )} | |
| 429 | {actionData && "outOfInvites" in actionData && actionData.outOfInvites && ( | |
| 430 | <p className="mt-1 text-sm text-muted"> | |
| 431 | <a href={moreInvitesMailto(params.owner)} className="text-accent underline underline-offset-4"> | |
| 432 | Ask for more invites | |
| 433 | </a>{" "} | |
| 434 | for {params.owner}. | |
| 435 | </p> | |
| 436 | )} | |
| 437 | ||
| 438 | {owner && pending.length > 0 && ( | |
| 439 | <section className="mt-8"> | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 440 | <h2 className="text-sm font-medium">Pending invitations</h2> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 441 | <ul className="mt-3 divide-y divide-line rounded-xl border border-line"> |
| 442 | {pending.map((invite) => ( | |
| 443 | <li key={invite.id} className="space-y-2 px-4 py-3"> | |
| 444 | <div className="flex flex-wrap items-center gap-3"> | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 445 | <span className="min-w-0 grow truncate text-sm"> |
| 446 | {invite.email ?? | |
| 447 | (invite.invitee ? ( | |
| 448 | <UserCard username={invite.invitee}> | |
| 449 | <Link to={`/u/${invite.invitee}`} className="font-mono hover:text-accent"> | |
| 450 | @{invite.invitee} | |
| 451 | </Link> | |
| 452 | </UserCard> | |
| 453 | ) : ( | |
| 454 | "Anyone with the link" | |
| 455 | ))} | |
| 456 | </span> | |
| 457 | {invite.role === "owner" && <Pill>Owner</Pill>} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 458 | <Pill>{inviteState(invite).label}</Pill> |
| 459 | <Form method="post"> | |
| 460 | <input type="hidden" name="action" value="revoke-invite" /> | |
| 461 | <input type="hidden" name="id" value={invite.id} /> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 462 | <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…"> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 463 | Revoke |
| Merge branch 'worktree-agent-a985c01f43538af01' | 464 | </SubmitButton> |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 465 | </Form> |
| 466 | </div> | |
| 467 | <p className="text-xs text-faint"> | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 468 | By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} /> · works until{" "} |
| 469 | {new Date(invite.expiresAt).toISOString().slice(0, 10)} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 470 | </p> |
| 471 | {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />} | |
| 472 | </li> | |
| 473 | ))} | |
| 474 | </ul> | |
| 475 | </section> | |
| 476 | )} | |
| 477 | ||
| 478 | <BasePermissionSection | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 479 | // Starts from the saved choice whenever it changes. |
| 480 | key={base} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 481 | base={base} |
| 482 | owner={owner} | |
| 483 | slug={params.owner} | |
| 484 | error={actionData && "base" in actionData && "error" in actionData ? (actionData.error ?? null) : null} | |
| 485 | saved={Boolean(actionData && "based" in actionData)} | |
| 486 | /> | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 487 | |
| 488 | <LeaveSection | |
| 489 | slug={params.owner} | |
| 490 | soleOwner={owner && owners <= 1} | |
| 491 | error={actionData && "leaving" in actionData ? (actionData.error ?? null) : null} | |
| 492 | /> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 493 | </> |
| 494 | ); | |
| 495 | return ( | |
| 496 | <div className="max-w-2xl"> | |
| 497 | {owner ? ( | |
| 498 | <Tabs | |
| 499 | value={tab} | |
| 500 | onValueChange={(value) => { | |
| 501 | const next = new URLSearchParams(search); | |
| 502 | if (value === "outside") next.set("tab", "outside"); | |
| 503 | else next.delete("tab"); | |
| 504 | setSearch(next, { replace: true, preventScrollReset: true }); | |
| 505 | }} | |
| 506 | > | |
| 507 | <TabsList className="mb-3"> | |
| 508 | <TabsTrigger value="members">Members · {members.length}</TabsTrigger> | |
| 509 | <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger> | |
| 510 | </TabsList> | |
| 511 | <TabsContent value="members">{membersTab}</TabsContent> | |
| 512 | <TabsContent value="outside"> | |
| 513 | <OutsideCollaborators | |
| 514 | people={outside} | |
| 515 | slug={params.owner} | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 516 | free={free} |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 517 | error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null} |
| 518 | /> | |
| 519 | </TabsContent> | |
| 520 | </Tabs> | |
| 521 | ) : ( | |
| 522 | membersTab | |
| 523 | )} | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 524 | </div> |
| 525 | ); | |
| 526 | } | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 527 | |
| 528 | /** What members get on every repository, which owners choose. */ | |
| 529 | function BasePermissionSection({ | |
| 530 | base, | |
| 531 | owner, | |
| 532 | slug, | |
| 533 | error, | |
| 534 | saved, | |
| 535 | }: { | |
| 536 | base: BasePermission; | |
| 537 | owner: boolean; | |
| 538 | slug: string; | |
| 539 | error: string | null; | |
| 540 | saved: boolean; | |
| 541 | }) { | |
| 542 | const [chosen, setChosen] = useState<BasePermission>(base); | |
| 543 | return ( | |
| 544 | <section id="base-permission" className="mt-10 scroll-mt-20 border-t border-line pt-8"> | |
| 545 | <h2 className="font-medium">Base permission</h2> | |
| 546 | <p className="mt-1 text-sm text-muted"> | |
| 547 | The role every member has on each of {slug}'s repositories. Owners are Admins on all of them, and a role given to | |
| 548 | someone on one repository adds to this; it never takes away. | |
| 549 | </p> | |
| 550 | {owner ? ( | |
| 551 | <Form method="post" className="mt-4 rounded-xl border border-line bg-surface p-4"> | |
| 552 | <input type="hidden" name="action" value="base-permission" /> | |
| 553 | <div className="flex flex-col gap-3 sm:flex-row sm:items-center"> | |
| 554 | <Select name="base" value={chosen} onValueChange={(value) => setChosen(value as BasePermission)}> | |
| 555 | <SelectTrigger aria-label="Base permission" className="sm:max-w-48"> | |
| 556 | <SelectValue /> | |
| 557 | </SelectTrigger> | |
| 558 | <SelectContent> | |
| 559 | {BASE_PERMISSIONS.map((value) => ( | |
| 560 | <SelectItem key={value} value={value} description={BASE_MEANS[value]}> | |
| 561 | {BASE_PERMISSION_LABELS[value]} | |
| 562 | </SelectItem> | |
| 563 | ))} | |
| 564 | </SelectContent> | |
| 565 | </Select> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 566 | <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}> |
| 567 | Save | |
| 568 | </SubmitButton> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 569 | {saved && chosen === base && <span className="text-sm text-muted">Saved.</span>} |
| 570 | </div> | |
| 571 | <p className="mt-3 text-sm text-muted">{BASE_MEANS[chosen]}</p> | |
| 572 | <ErrorText>{error}</ErrorText> | |
| 573 | </Form> | |
| 574 | ) : ( | |
| 575 | <div className="mt-4 rounded-xl border border-line bg-surface p-4 text-sm"> | |
| 576 | <p> | |
| 577 | <span className="font-medium">{BASE_PERMISSION_LABELS[base]}.</span>{" "} | |
| 578 | <span className="text-muted">{BASE_MEANS[base]}</span> | |
| 579 | </p> | |
| 580 | <p className="mt-2 text-xs text-faint">Owners of the workspace choose it.</p> | |
| 581 | </div> | |
| 582 | )} | |
| 583 | </section> | |
| 584 | ); | |
| 585 | } | |
| 586 | ||
| 587 | /** People with a role on some of the workspace's repositories who are not members of it. */ | |
| 588 | function OutsideCollaborators({ | |
| 589 | people, | |
| 590 | slug, | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 591 | free, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 592 | error, |
| 593 | }: { | |
| 594 | people: Route.ComponentProps["loaderData"]["outside"]; | |
| 595 | slug: string; | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 596 | free: boolean; |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 597 | error: string | null | undefined; |
| 598 | }) { | |
| 599 | return ( | |
| 600 | <> | |
| 601 | <p className="mb-4 text-sm text-muted"> | |
| 602 | People given a role on one or more of {slug}'s repositories without being members. They see only those | |
| 603 | repositories. Change or take away a role on the repository's Access settings. | |
| 604 | </p> | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 605 | {free && ( |
| 606 | <div className="mb-4"> | |
| 607 | <StartPlanToInvite workspace={slug} owner /> | |
| 608 | </div> | |
| 609 | )} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 610 | {people.length === 0 ? ( |
| 611 | <div className="rounded-xl border border-dashed border-line px-6 py-10 text-center"> | |
| 612 | <Users size={18} className="mx-auto text-faint" /> | |
| 613 | <p className="mt-2 text-sm font-medium">No outside collaborators</p> | |
| 614 | <p className="mt-1 text-sm text-muted">Add someone to a single repository from its Settings → Access.</p> | |
| 615 | </div> | |
| 616 | ) : ( | |
| 617 | <ul className="divide-y divide-line rounded-xl border border-line"> | |
| 618 | {people.map((person) => ( | |
| 619 | <li key={person.username} className="px-4 py-3"> | |
| 620 | <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 621 | <Avatar name={person.username} image={person.avatar} size={28} /> | |
| 622 | <div className="min-w-0 grow basis-32"> | |
| Merge commit authors and hovercards: commits show their accounts, every name opens a card (repos 0016) | 623 | <UserCard username={person.username}> |
| 624 | <Link to={`/u/${person.username}`} className="font-mono text-sm hover:text-accent"> | |
| 625 | {person.username} | |
| 626 | </Link> | |
| 627 | </UserCard> | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 628 | {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>} |
| 629 | </div> | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 630 | {!free && ( |
| 631 | <Form method="post"> | |
| 632 | <input type="hidden" name="action" value="convert" /> | |
| 633 | <input type="hidden" name="member" value={person.username} /> | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 634 | <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Inviting…"> |
| 635 | Invite as a member | |
| Merge Stripe Tax, the card fee on card payments, and one free workspace per person | 636 | </SubmitButton> |
| 637 | </Form> | |
| 638 | )} | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 639 | </div> |
| 640 | <ul className="mt-2 flex flex-wrap gap-1.5 pl-10"> | |
| 641 | {person.repos.map((grant) => ( | |
| 642 | <li key={grant.repo}> | |
| 643 | <Link | |
| 644 | to={`/${grant.repo}/settings/access`} | |
| 645 | className="group inline-flex items-center gap-1.5 rounded-full border border-line px-2 py-0.5 text-xs transition-colors hover:border-line-strong hover:bg-surface" | |
| 646 | > | |
| 647 | <span className="font-mono text-fg/90">{grant.repo}</span> | |
| 648 | <span className="text-muted">{REPO_ROLE_LABELS[grant.role]}</span> | |
| 649 | <ArrowUpRight size={11} className="text-faint group-hover:text-fg" /> | |
| 650 | </Link> | |
| 651 | </li> | |
| 652 | ))} | |
| 653 | </ul> | |
| 654 | </li> | |
| 655 | ))} | |
| 656 | </ul> | |
| 657 | )} | |
| 658 | <ErrorText>{error}</ErrorText> | |
| 659 | </> | |
| 660 | ); | |
| 661 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.