| 1 | import { ArrowUpRight, Check, Ellipsis, ShieldAlert, ShieldCheck, Users } from "lucide-react"; |
| 2 | import { useState } from "react"; |
| 3 | import { Form, Link, data, redirect, useSearchParams, useSubmit } from "react-router"; |
| 4 | |
| 5 | import { |
| 6 | BASE_PERMISSIONS, |
| 7 | BASE_PERMISSION_LABELS, |
| 8 | type BasePermission, |
| 9 | DEFAULT_BASE_PERMISSION, |
| 10 | type Member, |
| 11 | ORG_ROLES, |
| 12 | ORG_ROLE_LABELS, |
| 13 | ORG_ROLE_SUMMARIES, |
| 14 | type OrgRole, |
| 15 | REPO_ROLE_LABELS, |
| 16 | } from "@g1t/contracts"; |
| 17 | |
| 18 | import type { Route } from "./+types/people"; |
| 19 | import { page } from "../../lib/meta"; |
| 20 | import { Avatar, Button, CopyLine, ErrorText, Field, Input, Pill, SubmitButton, TimeAgo } from "../../components/ui"; |
| 21 | import { Badge } from "../../components/ui/badge"; |
| 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"; |
| 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"; |
| 44 | import { billing, identity } from "../../lib/services.server"; |
| 45 | import { StartPlanToInvite } from "../../components/start-plan"; |
| 46 | import { |
| 47 | assertSameOrigin, |
| 48 | getViewer, |
| 49 | requireUser, |
| 50 | roleIn, |
| 51 | unwrap, |
| 52 | } from "../../lib/session.server"; |
| 53 | import { UserCard } from "../../components/user-card"; |
| 54 | import { PeoplePicker } from "../../components/people-picker"; |
| 55 | import { inviteTarget } from "../../lib/people-search"; |
| 56 | |
| 57 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 58 | return page(args, { title: `People · ${params.owner} · g1t` }); |
| 59 | } |
| 60 | |
| 61 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 62 | const viewer = getViewer(context); |
| 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"; |
| 68 | const [members, invites, workspace, outside, teams, free] = await Promise.all([ |
| 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, |
| 73 | // Each member's teams, as the viewer may see them. |
| 74 | identity.teamMemberships(viewer, params.owner).catch(() => null), |
| 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[]), |
| 78 | ]); |
| 79 | return { |
| 80 | role, |
| 81 | free: free.includes(params.owner.toLowerCase()), |
| 82 | members: unwrap(members), |
| 83 | invites: invites?.ok ? invites.value : [], |
| 84 | base: workspace?.basePermission ?? DEFAULT_BASE_PERMISSION, |
| 85 | outside: outside?.ok ? outside.value : [], |
| 86 | teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])), |
| 87 | origin: new URL(request.url).origin, |
| 88 | me: viewer?.username ?? null, |
| 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(); |
| 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 | } |
| 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 | } |
| 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(); |
| 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" }; |
| 143 | } |
| 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 }; |
| 146 | } |
| 147 | |
| 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 | |
| 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 | |
| 310 | export default function WorkspacePeople({ loaderData, actionData, params }: Route.ComponentProps) { |
| 311 | const { role, members, invites, origin, base, outside, teams, free, me } = loaderData; |
| 312 | const owner = role === "owner"; |
| 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; |
| 316 | // Waiting to be used, or used by someone still confirming their email: |
| 317 | // either can be revoked. |
| 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 | ); |
| 322 | const [search, setSearch] = useSearchParams(); |
| 323 | const tab = search.get("tab") === "outside" && owner ? "outside" : "members"; |
| 324 | const membersTab = ( |
| 325 | <> |
| 326 | <ul className="divide-y divide-line rounded-xl border border-line"> |
| 327 | {members.map((member) => ( |
| 328 | <li key={member.username} className="flex flex-wrap items-center gap-3 px-4 py-3"> |
| 329 | <Avatar name={member.username} image={member.avatar} size={28} /> |
| 330 | <div className="min-w-0 grow truncate"> |
| 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> |
| 336 | {member.name && <span className="ml-2 hidden text-sm text-muted sm:inline">{member.name}</span>} |
| 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 | )} |
| 353 | </div> |
| 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> |
| 364 | )} |
| 365 | </li> |
| 366 | ))} |
| 367 | </ul> |
| 368 | {owner && free && ( |
| 369 | <div className="mt-6"> |
| 370 | <StartPlanToInvite workspace={params.owner} owner={owner} /> |
| 371 | </div> |
| 372 | )} |
| 373 | {owner && !free && ( |
| 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" /> |
| 381 | <div className="grow"> |
| 382 | <Field |
| 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." |
| 385 | > |
| 386 | <PeoplePicker name="member" placeholder="username, name or name@example.com" /> |
| 387 | </Field> |
| 388 | </div> |
| 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> |
| 400 | <div className="sm:pt-[1.625rem]"> |
| 401 | <SubmitButton match={{ action: "add" }} pending="Inviting…"> |
| 402 | Invite |
| 403 | </SubmitButton> |
| 404 | </div> |
| 405 | </Form> |
| 406 | )} |
| 407 | {actionData && "invited" in actionData && actionData.invited && ( |
| 408 | <p className="text-sm text-muted" role="status"> |
| 409 | Invitation sent to <span className="text-fg">{actionData.invited}</span>. They join once they accept. |
| 410 | </p> |
| 411 | )} |
| 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 | )} |
| 417 | {actionData && "converted" in actionData && ( |
| 418 | <p className="text-sm text-muted" role="status"> |
| 419 | <span className="font-mono text-fg">{actionData.converted}</span> is invited to join {params.owner} as a member, and joins once they accept. |
| 420 | </p> |
| 421 | )} |
| 422 | {actionData && |
| 423 | !("base" in actionData) && |
| 424 | !("row" in actionData) && |
| 425 | !("leaving" in actionData) && |
| 426 | !("converting" in actionData && actionData.converting) && ( |
| 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"> |
| 440 | <h2 className="text-sm font-medium">Pending invitations</h2> |
| 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"> |
| 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>} |
| 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} /> |
| 462 | <SubmitButton variant="quiet" match={{ action: "revoke-invite", id: invite.id }} pending="Revoking…"> |
| 463 | Revoke |
| 464 | </SubmitButton> |
| 465 | </Form> |
| 466 | </div> |
| 467 | <p className="text-xs text-faint"> |
| 468 | By {invite.invitedBy ?? "g1t"} · <TimeAgo at={invite.createdAt} /> · works until{" "} |
| 469 | {new Date(invite.expiresAt).toISOString().slice(0, 10)} |
| 470 | </p> |
| 471 | {invite.code && <CopyLine text={inviteLink(invite.code, origin)} />} |
| 472 | </li> |
| 473 | ))} |
| 474 | </ul> |
| 475 | </section> |
| 476 | )} |
| 477 | |
| 478 | <BasePermissionSection |
| 479 | // Starts from the saved choice whenever it changes. |
| 480 | key={base} |
| 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 | /> |
| 487 | |
| 488 | <LeaveSection |
| 489 | slug={params.owner} |
| 490 | soleOwner={owner && owners <= 1} |
| 491 | error={actionData && "leaving" in actionData ? (actionData.error ?? null) : null} |
| 492 | /> |
| 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} |
| 516 | free={free} |
| 517 | error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null} |
| 518 | /> |
| 519 | </TabsContent> |
| 520 | </Tabs> |
| 521 | ) : ( |
| 522 | membersTab |
| 523 | )} |
| 524 | </div> |
| 525 | ); |
| 526 | } |
| 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> |
| 566 | <SubmitButton variant="quiet" match={{ action: "base-permission" }} pending="Saving…" disabled={chosen === base}> |
| 567 | Save |
| 568 | </SubmitButton> |
| 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, |
| 591 | free, |
| 592 | error, |
| 593 | }: { |
| 594 | people: Route.ComponentProps["loaderData"]["outside"]; |
| 595 | slug: string; |
| 596 | free: boolean; |
| 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> |
| 605 | {free && ( |
| 606 | <div className="mb-4"> |
| 607 | <StartPlanToInvite workspace={slug} owner /> |
| 608 | </div> |
| 609 | )} |
| 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"> |
| 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> |
| 628 | {person.name && <span className="ml-2 text-sm text-muted">{person.name}</span>} |
| 629 | </div> |
| 630 | {!free && ( |
| 631 | <Form method="post"> |
| 632 | <input type="hidden" name="action" value="convert" /> |
| 633 | <input type="hidden" name="member" value={person.username} /> |
| 634 | <SubmitButton variant="quiet" match={{ action: "convert", member: person.username }} pending="Inviting…"> |
| 635 | Invite as a member |
| 636 | </SubmitButton> |
| 637 | </Form> |
| 638 | )} |
| 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 | } |