CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
team-collaborators.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| febd4f0 | 1 | /** |
| 2 | * Team-based repo collaborators — invite every accepted member of a team | |
| 3 | * as a repo collaborator in a single action. | |
| 4 | * | |
| 5 | * Owner-only. Mirrors `src/routes/collaborators.tsx`'s resolveOwnerRepo | |
| 6 | * pattern for the inline owner check. The "invite whole team" action | |
| 7 | * iterates the team's members and upserts one `repo_collaborators` row per | |
| 8 | * user (skipping the repo owner), auto-accepting the invite — matching the | |
| 9 | * v1 auto-accept contract used by the single-user invite flow. | |
| 10 | * | |
| 11 | * GET /:owner/:repo/settings/collaborators/teams — form + list | |
| 12 | * POST /:owner/:repo/settings/collaborators/teams/add — bulk insert | |
| 13 | */ | |
| 14 | ||
| 15 | import { Hono } from "hono"; | |
| 16 | import { eq, and } from "drizzle-orm"; | |
| 17 | import { db } from "../db"; | |
| 18 | import { | |
| 19 | repositories, | |
| 20 | users, | |
| 21 | repoCollaborators, | |
| 22 | organizations, | |
| 23 | orgMembers, | |
| 24 | teams, | |
| 25 | teamMembers, | |
| 26 | } from "../db/schema"; | |
| 27 | import { Layout } from "../views/layout"; | |
| 28 | import { RepoHeader } from "../views/components"; | |
| 29 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 30 | import type { AuthEnv } from "../middleware/auth"; | |
| 31 | import { | |
| 32 | Container, | |
| 33 | Form, | |
| 34 | FormGroup, | |
| 35 | Input, | |
| 36 | Select, | |
| 37 | Button, | |
| 38 | Alert, | |
| 39 | EmptyState, | |
| 40 | } from "../views/ui"; | |
| 41 | ||
| 42 | const teamCollaboratorRoutes = new Hono<AuthEnv>(); | |
| 43 | ||
| 44 | teamCollaboratorRoutes.use("*", softAuth); | |
| 45 | ||
| 46 | /** | |
| 47 | * Resolve (owner user, repo) from URL params and enforce owner-only access. | |
| 48 | * Mirrors the helper in `src/routes/collaborators.tsx` for consistency. | |
| 49 | */ | |
| 50 | async function resolveOwnerRepo( | |
| 51 | c: any, | |
| 52 | ownerName: string, | |
| 53 | repoName: string | |
| 54 | ) { | |
| 55 | const user = c.get("user")!; | |
| 56 | const [owner] = await db | |
| 57 | .select() | |
| 58 | .from(users) | |
| 59 | .where(eq(users.username, ownerName)) | |
| 60 | .limit(1); | |
| 61 | if (!owner || owner.id !== user.id) { | |
| 62 | return { | |
| 63 | error: c.html( | |
| 64 | <Layout title="Unauthorized" user={user}> | |
| 65 | <EmptyState title="Unauthorized"> | |
| 66 | <p>Only the repository owner can manage collaborators.</p> | |
| 67 | </EmptyState> | |
| 68 | </Layout>, | |
| 69 | 403 | |
| 70 | ), | |
| 71 | }; | |
| 72 | } | |
| 73 | const [repo] = await db | |
| 74 | .select() | |
| 75 | .from(repositories) | |
| 76 | .where( | |
| 77 | and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)) | |
| 78 | ) | |
| 79 | .limit(1); | |
| 80 | if (!repo) { | |
| 81 | return { error: c.notFound() }; | |
| 82 | } | |
| 83 | return { owner, repo, user }; | |
| 84 | } | |
| 85 | ||
| 86 | // ─── List + invite form ───────────────────────────────────────────────────── | |
| 87 | ||
| 88 | teamCollaboratorRoutes.get( | |
| 89 | "/:owner/:repo/settings/collaborators/teams", | |
| 90 | requireAuth, | |
| 91 | async (c) => { | |
| 92 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 93 | const success = c.req.query("success"); | |
| 94 | const error = c.req.query("error"); | |
| 95 | ||
| 96 | const resolved = await resolveOwnerRepo(c, ownerName, repoName); | |
| 97 | if ("error" in resolved) return resolved.error; | |
| 98 | const { repo, user } = resolved; | |
| 99 | ||
| 100 | // Orgs the current user belongs to — these populate the org dropdown. | |
| 101 | const userOrgs = await db | |
| 102 | .select({ | |
| 103 | id: organizations.id, | |
| 104 | slug: organizations.slug, | |
| 105 | name: organizations.name, | |
| 106 | }) | |
| 107 | .from(organizations) | |
| 108 | .innerJoin(orgMembers, eq(orgMembers.orgId, organizations.id)) | |
| 109 | .where(eq(orgMembers.userId, user.id)); | |
| 110 | ||
| 111 | // All collaborators for this repo — v1 just lists everyone with a count, | |
| 112 | // not filtered by "added via team" (would need a sourceTeamId column). | |
| 113 | const rows = await db | |
| 114 | .select({ | |
| 115 | id: repoCollaborators.id, | |
| 116 | role: repoCollaborators.role, | |
| 117 | invitedAt: repoCollaborators.invitedAt, | |
| 118 | acceptedAt: repoCollaborators.acceptedAt, | |
| 119 | username: users.username, | |
| 120 | avatarUrl: users.avatarUrl, | |
| 121 | }) | |
| 122 | .from(repoCollaborators) | |
| 123 | .innerJoin(users, eq(users.id, repoCollaborators.userId)) | |
| 124 | .where(eq(repoCollaborators.repositoryId, repo.id)); | |
| 125 | ||
| 126 | return c.html( | |
| 127 | <Layout | |
| 128 | title={`Invite team — ${ownerName}/${repoName}`} | |
| 129 | user={user} | |
| 130 | > | |
| 131 | <RepoHeader owner={ownerName} repo={repoName} /> | |
| 132 | <Container maxWidth={700}> | |
| 133 | <h2 style="margin-bottom: 16px">Invite a team</h2> | |
| 134 | <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px"> | |
| 135 | <a href={`/${ownerName}/${repoName}/settings/collaborators`}> | |
| 136 | ← Back to collaborators | |
| 137 | </a> | |
| 138 | </p> | |
| 139 | {success && ( | |
| 140 | <Alert variant="success">{decodeURIComponent(success)}</Alert> | |
| 141 | )} | |
| 142 | {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>} | |
| 143 | ||
| 144 | <div | |
| 145 | style="margin-bottom: 24px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)" | |
| 146 | > | |
| 147 | <h3 style="margin-bottom: 12px">Invite every member of a team</h3> | |
| 148 | {userOrgs.length === 0 ? ( | |
| 149 | <p style="font-size:14px;color:var(--text-muted)"> | |
| 150 | You don't belong to any organizations yet. | |
| 151 | </p> | |
| 152 | ) : ( | |
| 153 | <Form | |
| 154 | method="post" | |
| 155 | action={`/${ownerName}/${repoName}/settings/collaborators/teams/add`} | |
| 156 | > | |
| 157 | <FormGroup label="Organization" htmlFor="orgSlug"> | |
| 158 | <Select name="orgSlug" id="orgSlug"> | |
| 159 | {userOrgs.map((o) => ( | |
| 160 | <option value={o.slug}> | |
| 161 | {o.name} ({o.slug}) | |
| 162 | </option> | |
| 163 | ))} | |
| 164 | </Select> | |
| 165 | </FormGroup> | |
| 166 | <FormGroup label="Team slug" htmlFor="teamSlug"> | |
| 167 | <Input | |
| 168 | name="teamSlug" | |
| 169 | id="teamSlug" | |
| 170 | placeholder="engineering" | |
| 171 | required | |
| 172 | /> | |
| 173 | </FormGroup> | |
| 174 | <FormGroup label="Role" htmlFor="role"> | |
| 175 | <Select name="role" id="role" value="read"> | |
| 176 | <option value="read">Read — clone + pull</option> | |
| 177 | <option value="write">Write — push + merge</option> | |
| 178 | <option value="admin">Admin — full control</option> | |
| 179 | </Select> | |
| 180 | </FormGroup> | |
| 181 | <Button type="submit" variant="primary"> | |
| 182 | Invite team | |
| 183 | </Button> | |
| 184 | </Form> | |
| 185 | )} | |
| 186 | </div> | |
| 187 | ||
| 188 | <h3 style="margin-bottom: 12px"> | |
| 189 | Current collaborators ({rows.length}) | |
| 190 | </h3> | |
| 191 | {rows.length === 0 ? ( | |
| 192 | <EmptyState title="No collaborators yet"> | |
| 193 | <p>Invite a team above to add multiple people at once.</p> | |
| 194 | </EmptyState> | |
| 195 | ) : ( | |
| 196 | <div> | |
| 197 | {rows.map((row) => ( | |
| 198 | <div class="ssh-key-item"> | |
| 199 | <div> | |
| 200 | <strong> | |
| 201 | {row.avatarUrl && ( | |
| 202 | <img | |
| 203 | src={row.avatarUrl} | |
| 204 | alt="" | |
| 205 | style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px" | |
| 206 | /> | |
| 207 | )} | |
| 208 | <a href={`/${row.username}`}>{row.username}</a> | |
| 209 | </strong> | |
| 210 | <div class="ssh-key-meta"> | |
| 211 | Role: <strong>{row.role}</strong> | Invited:{" "} | |
| 212 | {new Date(row.invitedAt).toLocaleDateString()} |{" "} | |
| 213 | {row.acceptedAt ? ( | |
| 214 | <span style="color: var(--green)">Accepted</span> | |
| 215 | ) : ( | |
| 216 | <span style="color: var(--yellow)">Pending</span> | |
| 217 | )} | |
| 218 | </div> | |
| 219 | </div> | |
| 220 | </div> | |
| 221 | ))} | |
| 222 | </div> | |
| 223 | )} | |
| 224 | </Container> | |
| 225 | </Layout> | |
| 226 | ); | |
| 227 | } | |
| 228 | ); | |
| 229 | ||
| 230 | // ─── Invite entire team ───────────────────────────────────────────────────── | |
| 231 | ||
| 232 | teamCollaboratorRoutes.post( | |
| 233 | "/:owner/:repo/settings/collaborators/teams/add", | |
| 234 | requireAuth, | |
| 235 | async (c) => { | |
| 236 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 237 | const body = await c.req.parseBody(); | |
| 238 | ||
| 239 | const resolved = await resolveOwnerRepo(c, ownerName, repoName); | |
| 240 | if ("error" in resolved) return resolved.error; | |
| 241 | const { repo, user } = resolved; | |
| 242 | ||
| 243 | const orgSlug = String(body.orgSlug || "").trim(); | |
| 244 | const teamSlug = String(body.teamSlug || "").trim(); | |
| 245 | const roleRaw = String(body.role || "read"); | |
| 246 | const role: "read" | "write" | "admin" = | |
| 247 | roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read"; | |
| 248 | ||
| 249 | const redirBase = `/${ownerName}/${repoName}/settings/collaborators/teams`; | |
| 250 | ||
| 251 | if (!orgSlug || !teamSlug) { | |
| 252 | return c.redirect( | |
| 253 | `${redirBase}?error=Organization+and+team+slug+are+required` | |
| 254 | ); | |
| 255 | } | |
| 256 | ||
| 257 | // Resolve org by slug, then team by (orgId, slug). We also verify the | |
| 258 | // authed user is a member of the org — otherwise they shouldn't be able | |
| 259 | // to enumerate team membership via this endpoint. | |
| 260 | const [org] = await db | |
| 261 | .select() | |
| 262 | .from(organizations) | |
| 263 | .where(eq(organizations.slug, orgSlug)) | |
| 264 | .limit(1); | |
| 265 | if (!org) { | |
| 266 | return c.redirect(`${redirBase}?error=Organization+not+found`); | |
| 267 | } | |
| 268 | ||
| 269 | const [membership] = await db | |
| 270 | .select() | |
| 271 | .from(orgMembers) | |
| 272 | .where( | |
| 273 | and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)) | |
| 274 | ) | |
| 275 | .limit(1); | |
| 276 | if (!membership) { | |
| 277 | return c.redirect( | |
| 278 | `${redirBase}?error=You+are+not+a+member+of+that+organization` | |
| 279 | ); | |
| 280 | } | |
| 281 | ||
| 282 | const [team] = await db | |
| 283 | .select() | |
| 284 | .from(teams) | |
| 285 | .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug))) | |
| 286 | .limit(1); | |
| 287 | if (!team) { | |
| 288 | return c.redirect(`${redirBase}?error=Team+not+found`); | |
| 289 | } | |
| 290 | ||
| 291 | // Fetch team members. The team_members schema has no "acceptedAt" | |
| 292 | // column — a row existing IS the acceptance — so every row counts. | |
| 293 | const members = await db | |
| 294 | .select({ userId: teamMembers.userId }) | |
| 295 | .from(teamMembers) | |
| 296 | .where(eq(teamMembers.teamId, team.id)); | |
| 297 | ||
| 298 | let added = 0; | |
| 299 | for (const m of members) { | |
| 300 | // Never add the repo owner as their own collaborator. | |
| 301 | if (m.userId === repo.ownerId) continue; | |
| 302 | ||
| 303 | const [existing] = await db | |
| 304 | .select() | |
| 305 | .from(repoCollaborators) | |
| 306 | .where( | |
| 307 | and( | |
| 308 | eq(repoCollaborators.repositoryId, repo.id), | |
| 309 | eq(repoCollaborators.userId, m.userId) | |
| 310 | ) | |
| 311 | ) | |
| 312 | .limit(1); | |
| 313 | ||
| 314 | if (existing) { | |
| 315 | await db | |
| 316 | .update(repoCollaborators) | |
| 317 | .set({ role, acceptedAt: existing.acceptedAt ?? new Date() }) | |
| 318 | .where(eq(repoCollaborators.id, existing.id)); | |
| 319 | } else { | |
| 320 | await db.insert(repoCollaborators).values({ | |
| 321 | repositoryId: repo.id, | |
| 322 | userId: m.userId, | |
| 323 | role, | |
| 324 | invitedBy: user.id, | |
| 325 | acceptedAt: new Date(), // v1 auto-accept | |
| 326 | }); | |
| 327 | } | |
| 328 | added += 1; | |
| 329 | } | |
| 330 | ||
| 331 | const msg = `Added ${added} collaborators from team ${team.name}`; | |
| 332 | return c.redirect(`${redirBase}?success=${encodeURIComponent(msg)}`); | |
| 333 | } | |
| 334 | ); | |
| 335 | ||
| 336 | export default teamCollaboratorRoutes; |