CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
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.
| 23d1a81 | 1 | /** |
| 2 | * Repository collaborators — add, list, remove. | |
| 3 | * | |
| febd4f0 | 4 | * Owner-only. Adding a collaborator inserts a pending `repo_collaborators` |
| 5 | * row with `acceptedAt = NULL` and a hashed invite token, then emails the | |
| 6 | * invitee a `/invites/:token` link. The grantee becomes active only after | |
| 7 | * they click the link (see `src/routes/invites.tsx`). | |
| 23d1a81 | 8 | * |
| 9 | * Collaborator lifecycle matrix: | |
| 10 | * - Add: POST /:owner/:repo/settings/collaborators/add | |
| 11 | * - Remove: POST /:owner/:repo/settings/collaborators/:collaboratorId/remove | |
| 12 | * - List: GET /:owner/:repo/settings/collaborators | |
| 13 | * | |
| 14 | * Middleware: softAuth on all, plus an inline owner-only check that mirrors | |
| 15 | * `src/routes/repo-settings.tsx` — the owner of the repo (by username) must | |
| 16 | * match the authed user. Non-owners get 403. | |
| 17 | */ | |
| 18 | ||
| 19 | import { Hono } from "hono"; | |
| 20 | import { eq, and } from "drizzle-orm"; | |
| 21 | import { db } from "../db"; | |
| 22 | import { | |
| 23 | repositories, | |
| 24 | users, | |
| 25 | repoCollaborators, | |
| 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"; | |
| febd4f0 | 31 | import { generateInviteToken, hashInviteToken } from "../lib/invite-tokens"; |
| 32 | import { sendEmail, absoluteUrl } from "../lib/email"; | |
| 23d1a81 | 33 | import { |
| 34 | Container, | |
| 35 | Form, | |
| 36 | FormGroup, | |
| 37 | Input, | |
| 38 | Select, | |
| 39 | Button, | |
| 40 | Alert, | |
| 41 | EmptyState, | |
| 42 | } from "../views/ui"; | |
| 43 | ||
| 44 | const collaboratorRoutes = new Hono<AuthEnv>(); | |
| 45 | ||
| 46 | collaboratorRoutes.use("*", softAuth); | |
| 47 | ||
| 48 | /** | |
| 49 | * Resolve (owner user, repo) from URL params, enforcing the authed user is | |
| 50 | * the repo owner. Returns `{ owner, repo }` on success, or an already-built | |
| 51 | * Response on failure (caller should return it directly). | |
| 52 | */ | |
| 53 | async function resolveOwnerRepo( | |
| 54 | c: any, | |
| 55 | ownerName: string, | |
| 56 | repoName: string | |
| 57 | ) { | |
| 58 | const user = c.get("user")!; | |
| 59 | const [owner] = await db | |
| 60 | .select() | |
| 61 | .from(users) | |
| 62 | .where(eq(users.username, ownerName)) | |
| 63 | .limit(1); | |
| 64 | if (!owner || owner.id !== user.id) { | |
| 65 | return { | |
| 66 | error: c.html( | |
| 67 | <Layout title="Unauthorized" user={user}> | |
| 68 | <EmptyState title="Unauthorized"> | |
| 69 | <p>Only the repository owner can manage collaborators.</p> | |
| 70 | </EmptyState> | |
| 71 | </Layout>, | |
| 72 | 403 | |
| 73 | ), | |
| 74 | }; | |
| 75 | } | |
| 76 | const [repo] = await db | |
| 77 | .select() | |
| 78 | .from(repositories) | |
| 79 | .where( | |
| 80 | and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName)) | |
| 81 | ) | |
| 82 | .limit(1); | |
| 83 | if (!repo) { | |
| 84 | return { error: c.notFound() }; | |
| 85 | } | |
| 86 | return { owner, repo, user }; | |
| 87 | } | |
| 88 | ||
| 89 | // ─── List collaborators ───────────────────────────────────────────────────── | |
| 90 | ||
| 91 | collaboratorRoutes.get( | |
| 92 | "/:owner/:repo/settings/collaborators", | |
| 93 | requireAuth, | |
| 94 | async (c) => { | |
| 95 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 96 | const success = c.req.query("success"); | |
| 97 | const error = c.req.query("error"); | |
| 98 | ||
| 99 | const resolved = await resolveOwnerRepo(c, ownerName, repoName); | |
| 100 | if ("error" in resolved) return resolved.error; | |
| 101 | const { repo, user } = resolved; | |
| 102 | ||
| 103 | // Join collaborators with users to get username + avatar. | |
| 104 | const rows = await db | |
| 105 | .select({ | |
| 106 | id: repoCollaborators.id, | |
| 107 | role: repoCollaborators.role, | |
| 108 | invitedAt: repoCollaborators.invitedAt, | |
| 109 | acceptedAt: repoCollaborators.acceptedAt, | |
| 110 | username: users.username, | |
| 111 | avatarUrl: users.avatarUrl, | |
| 112 | userId: users.id, | |
| 113 | }) | |
| 114 | .from(repoCollaborators) | |
| 115 | .innerJoin(users, eq(users.id, repoCollaborators.userId)) | |
| 116 | .where(eq(repoCollaborators.repositoryId, repo.id)); | |
| 117 | ||
| 118 | return c.html( | |
| 119 | <Layout title={`Collaborators — ${ownerName}/${repoName}`} user={user}> | |
| 120 | <RepoHeader owner={ownerName} repo={repoName} /> | |
| 121 | <Container maxWidth={700}> | |
| 122 | <h2 style="margin-bottom: 16px">Collaborators</h2> | |
| 123 | <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px"> | |
| 124 | <a href={`/${ownerName}/${repoName}/settings`}>← Back to settings</a> | |
| febd4f0 | 125 | {" | "} |
| 126 | <a | |
| 127 | href={`/${ownerName}/${repoName}/settings/collaborators/teams`} | |
| 128 | > | |
| 129 | Invite a team → | |
| 130 | </a> | |
| 23d1a81 | 131 | </p> |
| 132 | {success && ( | |
| 133 | <Alert variant="success">{decodeURIComponent(success)}</Alert> | |
| 134 | )} | |
| 135 | {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>} | |
| 136 | ||
| 137 | <div | |
| 138 | style="margin-bottom: 24px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)" | |
| 139 | > | |
| 140 | <h3 style="margin-bottom: 12px">Add a collaborator</h3> | |
| 141 | <Form | |
| 142 | method="post" | |
| 143 | action={`/${ownerName}/${repoName}/settings/collaborators/add`} | |
| 144 | > | |
| 145 | <FormGroup label="Username" htmlFor="username"> | |
| 146 | <Input | |
| 147 | name="username" | |
| 148 | id="username" | |
| 149 | placeholder="github-username" | |
| 150 | required | |
| 151 | /> | |
| 152 | </FormGroup> | |
| 153 | <FormGroup label="Role" htmlFor="role"> | |
| 154 | <Select name="role" id="role" value="read"> | |
| 155 | <option value="read">Read — clone + pull</option> | |
| 156 | <option value="write">Write — push + merge</option> | |
| 157 | <option value="admin">Admin — full control</option> | |
| 158 | </Select> | |
| 159 | </FormGroup> | |
| 160 | <Button type="submit" variant="primary"> | |
| 161 | Add collaborator | |
| 162 | </Button> | |
| 163 | </Form> | |
| 164 | </div> | |
| 165 | ||
| 166 | {rows.length === 0 ? ( | |
| 167 | <EmptyState title="No collaborators yet"> | |
| 168 | <p> | |
| 169 | Add a collaborator above to grant them access to this | |
| 170 | repository. | |
| 171 | </p> | |
| 172 | </EmptyState> | |
| 173 | ) : ( | |
| 174 | <div> | |
| 175 | {rows.map((row) => ( | |
| 176 | <div class="ssh-key-item"> | |
| 177 | <div> | |
| 178 | <strong> | |
| 179 | {row.avatarUrl && ( | |
| 180 | <img | |
| 181 | src={row.avatarUrl} | |
| 182 | alt="" | |
| ea52715 | 183 | width={20} |
| 184 | height={20} | |
| 23d1a81 | 185 | style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px" |
| 186 | /> | |
| 187 | )} | |
| 188 | <a href={`/${row.username}`}>{row.username}</a> | |
| 189 | </strong> | |
| 190 | <div class="ssh-key-meta"> | |
| 191 | Role: <strong>{row.role}</strong> | Invited:{" "} | |
| 192 | {new Date(row.invitedAt).toLocaleDateString()} |{" "} | |
| 193 | {row.acceptedAt ? ( | |
| 194 | <span style="color: var(--green)">Accepted</span> | |
| 195 | ) : ( | |
| 196 | <span style="color: var(--yellow)">Pending</span> | |
| 197 | )} | |
| 198 | </div> | |
| 199 | </div> | |
| 200 | <form | |
| 201 | method="post" | |
| 202 | action={`/${ownerName}/${repoName}/settings/collaborators/${row.id}/remove`} | |
| 203 | onsubmit="return confirm('Remove this collaborator?')" | |
| 204 | > | |
| 205 | <Button type="submit" variant="danger" size="sm"> | |
| 206 | Remove | |
| 207 | </Button> | |
| 208 | </form> | |
| 209 | </div> | |
| 210 | ))} | |
| 211 | </div> | |
| 212 | )} | |
| 213 | </Container> | |
| 214 | </Layout> | |
| 215 | ); | |
| 216 | } | |
| 217 | ); | |
| 218 | ||
| 219 | // ─── Add collaborator ─────────────────────────────────────────────────────── | |
| 220 | ||
| 221 | collaboratorRoutes.post( | |
| 222 | "/:owner/:repo/settings/collaborators/add", | |
| 223 | requireAuth, | |
| 224 | async (c) => { | |
| 225 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 226 | const body = await c.req.parseBody(); | |
| 227 | ||
| 228 | const resolved = await resolveOwnerRepo(c, ownerName, repoName); | |
| 229 | if ("error" in resolved) return resolved.error; | |
| 230 | const { repo, user } = resolved; | |
| 231 | ||
| 232 | const username = String(body.username || "").trim(); | |
| 233 | const roleRaw = String(body.role || "read"); | |
| 234 | const role: "read" | "write" | "admin" = | |
| 235 | roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read"; | |
| 236 | ||
| 237 | if (!username) { | |
| 238 | return c.redirect( | |
| 239 | `/${ownerName}/${repoName}/settings/collaborators?error=Username+required` | |
| 240 | ); | |
| 241 | } | |
| 242 | ||
| 243 | const [invitee] = await db | |
| 244 | .select() | |
| 245 | .from(users) | |
| 246 | .where(eq(users.username, username)) | |
| 247 | .limit(1); | |
| 248 | if (!invitee) { | |
| 249 | return c.redirect( | |
| 250 | `/${ownerName}/${repoName}/settings/collaborators?error=User+not+found` | |
| 251 | ); | |
| 252 | } | |
| 253 | if (invitee.id === user.id) { | |
| 254 | return c.redirect( | |
| 255 | `/${ownerName}/${repoName}/settings/collaborators?error=Owner+is+already+a+collaborator` | |
| 256 | ); | |
| 257 | } | |
| 258 | ||
| 259 | // If a row already exists for (repo, user), update the role instead of | |
| 260 | // erroring. Mirrors the "upsert" contract so the owner can re-invite | |
| 261 | // with a different role without first removing the prior row. | |
| 262 | const [existing] = await db | |
| 263 | .select() | |
| 264 | .from(repoCollaborators) | |
| 265 | .where( | |
| 266 | and( | |
| 267 | eq(repoCollaborators.repositoryId, repo.id), | |
| 268 | eq(repoCollaborators.userId, invitee.id) | |
| 269 | ) | |
| 270 | ) | |
| 271 | .limit(1); | |
| 272 | ||
| 273 | if (existing) { | |
| febd4f0 | 274 | // Re-inviting an existing collaborator just updates the role. We don't |
| 275 | // re-issue a token here — if the prior invite hasn't been accepted the | |
| 276 | // existing token is still valid; if it has, they're already in. | |
| 23d1a81 | 277 | await db |
| 278 | .update(repoCollaborators) | |
| febd4f0 | 279 | .set({ role }) |
| 23d1a81 | 280 | .where(eq(repoCollaborators.id, existing.id)); |
| 281 | return c.redirect( | |
| 282 | `/${ownerName}/${repoName}/settings/collaborators?success=Role+updated` | |
| 283 | ); | |
| 284 | } | |
| 285 | ||
| febd4f0 | 286 | // Fresh invite: generate a single-use token, store only its hash, and |
| 287 | // email the plaintext to the invitee. acceptedAt stays NULL until they | |
| 288 | // click through /invites/:token. | |
| 289 | const token = generateInviteToken(); | |
| 290 | const tokenHash = hashInviteToken(token); | |
| 291 | ||
| 23d1a81 | 292 | await db.insert(repoCollaborators).values({ |
| 293 | repositoryId: repo.id, | |
| 294 | userId: invitee.id, | |
| 295 | role, | |
| 296 | invitedBy: user.id, | |
| febd4f0 | 297 | inviteTokenHash: tokenHash, |
| 23d1a81 | 298 | }); |
| 299 | ||
| febd4f0 | 300 | // Email delivery degrades gracefully — a failed send should never block |
| 301 | // the invite row from existing. Owner can resend / share the URL by hand. | |
| 302 | const inviteUrl = absoluteUrl(`/invites/${token}`); | |
| 303 | try { | |
| 304 | const result = await sendEmail({ | |
| 305 | to: invitee.email, | |
| 306 | subject: `You've been invited to ${ownerName}/${repoName}`, | |
| 307 | text: `You've been invited to ${ownerName}/${repoName}. Click: ${inviteUrl}`, | |
| 308 | }); | |
| 309 | if (!result.ok) { | |
| 310 | console.error( | |
| 311 | `[collaborators] invite email send failed for ${invitee.username}:`, | |
| 312 | result.error || result.skipped | |
| 313 | ); | |
| 314 | } | |
| 315 | } catch (err) { | |
| 316 | console.error( | |
| 317 | `[collaborators] invite email threw for ${invitee.username}:`, | |
| 318 | err | |
| 319 | ); | |
| 320 | } | |
| 321 | ||
| 23d1a81 | 322 | return c.redirect( |
| febd4f0 | 323 | `/${ownerName}/${repoName}/settings/collaborators?success=Invite+sent` |
| 23d1a81 | 324 | ); |
| 325 | } | |
| 326 | ); | |
| 327 | ||
| 328 | // ─── Remove collaborator ──────────────────────────────────────────────────── | |
| 329 | ||
| 330 | collaboratorRoutes.post( | |
| 331 | "/:owner/:repo/settings/collaborators/:collaboratorId/remove", | |
| 332 | requireAuth, | |
| 333 | async (c) => { | |
| 334 | const { owner: ownerName, repo: repoName, collaboratorId } = | |
| 335 | c.req.param(); | |
| 336 | ||
| 337 | const resolved = await resolveOwnerRepo(c, ownerName, repoName); | |
| 338 | if ("error" in resolved) return resolved.error; | |
| 339 | const { repo } = resolved; | |
| 340 | ||
| 341 | // Scope the delete to this repo so an owner can't remove a collaborator | |
| 342 | // from some other repo by crafting a URL. | |
| 343 | await db | |
| 344 | .delete(repoCollaborators) | |
| 345 | .where( | |
| 346 | and( | |
| 347 | eq(repoCollaborators.id, collaboratorId), | |
| 348 | eq(repoCollaborators.repositoryId, repo.id) | |
| 349 | ) | |
| 350 | ); | |
| 351 | ||
| 352 | return c.redirect( | |
| 353 | `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+removed` | |
| 354 | ); | |
| 355 | } | |
| 356 | ); | |
| 357 | ||
| 358 | export default collaboratorRoutes; |