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 | * | |
| 4 | * Owner-only. v1 auto-accepts the invite (no email flow yet): when the owner | |
| 5 | * adds a user by username, we insert a `repo_collaborators` row with | |
| 6 | * `acceptedAt = now()` so the grantee is immediately active. | |
| 7 | * | |
| 8 | * Collaborator lifecycle matrix: | |
| 9 | * - Add: POST /:owner/:repo/settings/collaborators/add | |
| 10 | * - Remove: POST /:owner/:repo/settings/collaborators/:collaboratorId/remove | |
| 11 | * - List: GET /:owner/:repo/settings/collaborators | |
| 12 | * | |
| 13 | * Middleware: softAuth on all, plus an inline owner-only check that mirrors | |
| 14 | * `src/routes/repo-settings.tsx` — the owner of the repo (by username) must | |
| 15 | * match the authed user. Non-owners get 403. | |
| 16 | */ | |
| 17 | ||
| 18 | import { Hono } from "hono"; | |
| 19 | import { eq, and } from "drizzle-orm"; | |
| 20 | import { db } from "../db"; | |
| 21 | import { | |
| 22 | repositories, | |
| 23 | users, | |
| 24 | repoCollaborators, | |
| 25 | } from "../db/schema"; | |
| 26 | import { Layout } from "../views/layout"; | |
| 27 | import { RepoHeader } from "../views/components"; | |
| 28 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 29 | import type { AuthEnv } from "../middleware/auth"; | |
| 30 | import { | |
| 31 | Container, | |
| 32 | Form, | |
| 33 | FormGroup, | |
| 34 | Input, | |
| 35 | Select, | |
| 36 | Button, | |
| 37 | Alert, | |
| 38 | EmptyState, | |
| 39 | } from "../views/ui"; | |
| 40 | ||
| 41 | const collaboratorRoutes = new Hono<AuthEnv>(); | |
| 42 | ||
| 43 | collaboratorRoutes.use("*", softAuth); | |
| 44 | ||
| 45 | /** | |
| 46 | * Resolve (owner user, repo) from URL params, enforcing the authed user is | |
| 47 | * the repo owner. Returns `{ owner, repo }` on success, or an already-built | |
| 48 | * Response on failure (caller should return it directly). | |
| 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 collaborators ───────────────────────────────────────────────────── | |
| 87 | ||
| 88 | collaboratorRoutes.get( | |
| 89 | "/:owner/:repo/settings/collaborators", | |
| 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 | // Join collaborators with users to get username + avatar. | |
| 101 | const rows = await db | |
| 102 | .select({ | |
| 103 | id: repoCollaborators.id, | |
| 104 | role: repoCollaborators.role, | |
| 105 | invitedAt: repoCollaborators.invitedAt, | |
| 106 | acceptedAt: repoCollaborators.acceptedAt, | |
| 107 | username: users.username, | |
| 108 | avatarUrl: users.avatarUrl, | |
| 109 | userId: users.id, | |
| 110 | }) | |
| 111 | .from(repoCollaborators) | |
| 112 | .innerJoin(users, eq(users.id, repoCollaborators.userId)) | |
| 113 | .where(eq(repoCollaborators.repositoryId, repo.id)); | |
| 114 | ||
| 115 | return c.html( | |
| 116 | <Layout title={`Collaborators — ${ownerName}/${repoName}`} user={user}> | |
| 117 | <RepoHeader owner={ownerName} repo={repoName} /> | |
| 118 | <Container maxWidth={700}> | |
| 119 | <h2 style="margin-bottom: 16px">Collaborators</h2> | |
| 120 | <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px"> | |
| 121 | <a href={`/${ownerName}/${repoName}/settings`}>← Back to settings</a> | |
| 122 | </p> | |
| 123 | {success && ( | |
| 124 | <Alert variant="success">{decodeURIComponent(success)}</Alert> | |
| 125 | )} | |
| 126 | {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>} | |
| 127 | ||
| 128 | <div | |
| 129 | style="margin-bottom: 24px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)" | |
| 130 | > | |
| 131 | <h3 style="margin-bottom: 12px">Add a collaborator</h3> | |
| 132 | <Form | |
| 133 | method="post" | |
| 134 | action={`/${ownerName}/${repoName}/settings/collaborators/add`} | |
| 135 | > | |
| 136 | <FormGroup label="Username" htmlFor="username"> | |
| 137 | <Input | |
| 138 | name="username" | |
| 139 | id="username" | |
| 140 | placeholder="github-username" | |
| 141 | required | |
| 142 | /> | |
| 143 | </FormGroup> | |
| 144 | <FormGroup label="Role" htmlFor="role"> | |
| 145 | <Select name="role" id="role" value="read"> | |
| 146 | <option value="read">Read — clone + pull</option> | |
| 147 | <option value="write">Write — push + merge</option> | |
| 148 | <option value="admin">Admin — full control</option> | |
| 149 | </Select> | |
| 150 | </FormGroup> | |
| 151 | <Button type="submit" variant="primary"> | |
| 152 | Add collaborator | |
| 153 | </Button> | |
| 154 | </Form> | |
| 155 | </div> | |
| 156 | ||
| 157 | {rows.length === 0 ? ( | |
| 158 | <EmptyState title="No collaborators yet"> | |
| 159 | <p> | |
| 160 | Add a collaborator above to grant them access to this | |
| 161 | repository. | |
| 162 | </p> | |
| 163 | </EmptyState> | |
| 164 | ) : ( | |
| 165 | <div> | |
| 166 | {rows.map((row) => ( | |
| 167 | <div class="ssh-key-item"> | |
| 168 | <div> | |
| 169 | <strong> | |
| 170 | {row.avatarUrl && ( | |
| 171 | <img | |
| 172 | src={row.avatarUrl} | |
| 173 | alt="" | |
| 174 | style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px" | |
| 175 | /> | |
| 176 | )} | |
| 177 | <a href={`/${row.username}`}>{row.username}</a> | |
| 178 | </strong> | |
| 179 | <div class="ssh-key-meta"> | |
| 180 | Role: <strong>{row.role}</strong> | Invited:{" "} | |
| 181 | {new Date(row.invitedAt).toLocaleDateString()} |{" "} | |
| 182 | {row.acceptedAt ? ( | |
| 183 | <span style="color: var(--green)">Accepted</span> | |
| 184 | ) : ( | |
| 185 | <span style="color: var(--yellow)">Pending</span> | |
| 186 | )} | |
| 187 | </div> | |
| 188 | </div> | |
| 189 | <form | |
| 190 | method="post" | |
| 191 | action={`/${ownerName}/${repoName}/settings/collaborators/${row.id}/remove`} | |
| 192 | onsubmit="return confirm('Remove this collaborator?')" | |
| 193 | > | |
| 194 | <Button type="submit" variant="danger" size="sm"> | |
| 195 | Remove | |
| 196 | </Button> | |
| 197 | </form> | |
| 198 | </div> | |
| 199 | ))} | |
| 200 | </div> | |
| 201 | )} | |
| 202 | </Container> | |
| 203 | </Layout> | |
| 204 | ); | |
| 205 | } | |
| 206 | ); | |
| 207 | ||
| 208 | // ─── Add collaborator ─────────────────────────────────────────────────────── | |
| 209 | ||
| 210 | collaboratorRoutes.post( | |
| 211 | "/:owner/:repo/settings/collaborators/add", | |
| 212 | requireAuth, | |
| 213 | async (c) => { | |
| 214 | const { owner: ownerName, repo: repoName } = c.req.param(); | |
| 215 | const body = await c.req.parseBody(); | |
| 216 | ||
| 217 | const resolved = await resolveOwnerRepo(c, ownerName, repoName); | |
| 218 | if ("error" in resolved) return resolved.error; | |
| 219 | const { repo, user } = resolved; | |
| 220 | ||
| 221 | const username = String(body.username || "").trim(); | |
| 222 | const roleRaw = String(body.role || "read"); | |
| 223 | const role: "read" | "write" | "admin" = | |
| 224 | roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read"; | |
| 225 | ||
| 226 | if (!username) { | |
| 227 | return c.redirect( | |
| 228 | `/${ownerName}/${repoName}/settings/collaborators?error=Username+required` | |
| 229 | ); | |
| 230 | } | |
| 231 | ||
| 232 | const [invitee] = await db | |
| 233 | .select() | |
| 234 | .from(users) | |
| 235 | .where(eq(users.username, username)) | |
| 236 | .limit(1); | |
| 237 | if (!invitee) { | |
| 238 | return c.redirect( | |
| 239 | `/${ownerName}/${repoName}/settings/collaborators?error=User+not+found` | |
| 240 | ); | |
| 241 | } | |
| 242 | if (invitee.id === user.id) { | |
| 243 | return c.redirect( | |
| 244 | `/${ownerName}/${repoName}/settings/collaborators?error=Owner+is+already+a+collaborator` | |
| 245 | ); | |
| 246 | } | |
| 247 | ||
| 248 | // If a row already exists for (repo, user), update the role instead of | |
| 249 | // erroring. Mirrors the "upsert" contract so the owner can re-invite | |
| 250 | // with a different role without first removing the prior row. | |
| 251 | const [existing] = await db | |
| 252 | .select() | |
| 253 | .from(repoCollaborators) | |
| 254 | .where( | |
| 255 | and( | |
| 256 | eq(repoCollaborators.repositoryId, repo.id), | |
| 257 | eq(repoCollaborators.userId, invitee.id) | |
| 258 | ) | |
| 259 | ) | |
| 260 | .limit(1); | |
| 261 | ||
| 262 | if (existing) { | |
| 263 | await db | |
| 264 | .update(repoCollaborators) | |
| 265 | .set({ role, acceptedAt: existing.acceptedAt ?? new Date() }) | |
| 266 | .where(eq(repoCollaborators.id, existing.id)); | |
| 267 | return c.redirect( | |
| 268 | `/${ownerName}/${repoName}/settings/collaborators?success=Role+updated` | |
| 269 | ); | |
| 270 | } | |
| 271 | ||
| 272 | await db.insert(repoCollaborators).values({ | |
| 273 | repositoryId: repo.id, | |
| 274 | userId: invitee.id, | |
| 275 | role, | |
| 276 | invitedBy: user.id, | |
| 277 | acceptedAt: new Date(), // v1 auto-accept; no email invite flow yet | |
| 278 | }); | |
| 279 | ||
| 280 | return c.redirect( | |
| 281 | `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+added` | |
| 282 | ); | |
| 283 | } | |
| 284 | ); | |
| 285 | ||
| 286 | // ─── Remove collaborator ──────────────────────────────────────────────────── | |
| 287 | ||
| 288 | collaboratorRoutes.post( | |
| 289 | "/:owner/:repo/settings/collaborators/:collaboratorId/remove", | |
| 290 | requireAuth, | |
| 291 | async (c) => { | |
| 292 | const { owner: ownerName, repo: repoName, collaboratorId } = | |
| 293 | c.req.param(); | |
| 294 | ||
| 295 | const resolved = await resolveOwnerRepo(c, ownerName, repoName); | |
| 296 | if ("error" in resolved) return resolved.error; | |
| 297 | const { repo } = resolved; | |
| 298 | ||
| 299 | // Scope the delete to this repo so an owner can't remove a collaborator | |
| 300 | // from some other repo by crafting a URL. | |
| 301 | await db | |
| 302 | .delete(repoCollaborators) | |
| 303 | .where( | |
| 304 | and( | |
| 305 | eq(repoCollaborators.id, collaboratorId), | |
| 306 | eq(repoCollaborators.repositoryId, repo.id) | |
| 307 | ) | |
| 308 | ); | |
| 309 | ||
| 310 | return c.redirect( | |
| 311 | `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+removed` | |
| 312 | ); | |
| 313 | } | |
| 314 | ); | |
| 315 | ||
| 316 | export default collaboratorRoutes; |