Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

collaborators.tsxBlame358 lines · 2 contributors
23d1a81Claude1/**
2 * Repository collaborators — add, list, remove.
3 *
febd4f0Claude4 * 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`).
23d1a81Claude8 *
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
19import { Hono } from "hono";
20import { eq, and } from "drizzle-orm";
21import { db } from "../db";
22import {
23 repositories,
24 users,
25 repoCollaborators,
26} from "../db/schema";
27import { Layout } from "../views/layout";
28import { RepoHeader } from "../views/components";
29import { softAuth, requireAuth } from "../middleware/auth";
30import type { AuthEnv } from "../middleware/auth";
febd4f0Claude31import { generateInviteToken, hashInviteToken } from "../lib/invite-tokens";
32import { sendEmail, absoluteUrl } from "../lib/email";
23d1a81Claude33import {
34 Container,
35 Form,
36 FormGroup,
37 Input,
38 Select,
39 Button,
40 Alert,
41 EmptyState,
42} from "../views/ui";
43
44const collaboratorRoutes = new Hono<AuthEnv>();
45
46collaboratorRoutes.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 */
53async 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
91collaboratorRoutes.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>
febd4f0Claude125 {" | "}
126 <a
127 href={`/${ownerName}/${repoName}/settings/collaborators/teams`}
128 >
129 Invite a team →
130 </a>
23d1a81Claude131 </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=""
ea52715copilot-swe-agent[bot]183 width={20}
184 height={20}
23d1a81Claude185 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
221collaboratorRoutes.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) {
febd4f0Claude274 // 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.
23d1a81Claude277 await db
278 .update(repoCollaborators)
febd4f0Claude279 .set({ role })
23d1a81Claude280 .where(eq(repoCollaborators.id, existing.id));
281 return c.redirect(
282 `/${ownerName}/${repoName}/settings/collaborators?success=Role+updated`
283 );
284 }
285
febd4f0Claude286 // 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
23d1a81Claude292 await db.insert(repoCollaborators).values({
293 repositoryId: repo.id,
294 userId: invitee.id,
295 role,
296 invitedBy: user.id,
febd4f0Claude297 inviteTokenHash: tokenHash,
23d1a81Claude298 });
299
febd4f0Claude300 // 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
23d1a81Claude322 return c.redirect(
febd4f0Claude323 `/${ownerName}/${repoName}/settings/collaborators?success=Invite+sent`
23d1a81Claude324 );
325 }
326);
327
328// ─── Remove collaborator ────────────────────────────────────────────────────
329
330collaboratorRoutes.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
358export default collaboratorRoutes;