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.tsxBlame356 lines · 1 contributor
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=""
183 style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px"
184 />
185 )}
186 <a href={`/${row.username}`}>{row.username}</a>
187 </strong>
188 <div class="ssh-key-meta">
189 Role: <strong>{row.role}</strong> | Invited:{" "}
190 {new Date(row.invitedAt).toLocaleDateString()} |{" "}
191 {row.acceptedAt ? (
192 <span style="color: var(--green)">Accepted</span>
193 ) : (
194 <span style="color: var(--yellow)">Pending</span>
195 )}
196 </div>
197 </div>
198 <form
199 method="post"
200 action={`/${ownerName}/${repoName}/settings/collaborators/${row.id}/remove`}
201 onsubmit="return confirm('Remove this collaborator?')"
202 >
203 <Button type="submit" variant="danger" size="sm">
204 Remove
205 </Button>
206 </form>
207 </div>
208 ))}
209 </div>
210 )}
211 </Container>
212 </Layout>
213 );
214 }
215);
216
217// ─── Add collaborator ───────────────────────────────────────────────────────
218
219collaboratorRoutes.post(
220 "/:owner/:repo/settings/collaborators/add",
221 requireAuth,
222 async (c) => {
223 const { owner: ownerName, repo: repoName } = c.req.param();
224 const body = await c.req.parseBody();
225
226 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
227 if ("error" in resolved) return resolved.error;
228 const { repo, user } = resolved;
229
230 const username = String(body.username || "").trim();
231 const roleRaw = String(body.role || "read");
232 const role: "read" | "write" | "admin" =
233 roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read";
234
235 if (!username) {
236 return c.redirect(
237 `/${ownerName}/${repoName}/settings/collaborators?error=Username+required`
238 );
239 }
240
241 const [invitee] = await db
242 .select()
243 .from(users)
244 .where(eq(users.username, username))
245 .limit(1);
246 if (!invitee) {
247 return c.redirect(
248 `/${ownerName}/${repoName}/settings/collaborators?error=User+not+found`
249 );
250 }
251 if (invitee.id === user.id) {
252 return c.redirect(
253 `/${ownerName}/${repoName}/settings/collaborators?error=Owner+is+already+a+collaborator`
254 );
255 }
256
257 // If a row already exists for (repo, user), update the role instead of
258 // erroring. Mirrors the "upsert" contract so the owner can re-invite
259 // with a different role without first removing the prior row.
260 const [existing] = await db
261 .select()
262 .from(repoCollaborators)
263 .where(
264 and(
265 eq(repoCollaborators.repositoryId, repo.id),
266 eq(repoCollaborators.userId, invitee.id)
267 )
268 )
269 .limit(1);
270
271 if (existing) {
febd4f0Claude272 // Re-inviting an existing collaborator just updates the role. We don't
273 // re-issue a token here — if the prior invite hasn't been accepted the
274 // existing token is still valid; if it has, they're already in.
23d1a81Claude275 await db
276 .update(repoCollaborators)
febd4f0Claude277 .set({ role })
23d1a81Claude278 .where(eq(repoCollaborators.id, existing.id));
279 return c.redirect(
280 `/${ownerName}/${repoName}/settings/collaborators?success=Role+updated`
281 );
282 }
283
febd4f0Claude284 // Fresh invite: generate a single-use token, store only its hash, and
285 // email the plaintext to the invitee. acceptedAt stays NULL until they
286 // click through /invites/:token.
287 const token = generateInviteToken();
288 const tokenHash = hashInviteToken(token);
289
23d1a81Claude290 await db.insert(repoCollaborators).values({
291 repositoryId: repo.id,
292 userId: invitee.id,
293 role,
294 invitedBy: user.id,
febd4f0Claude295 inviteTokenHash: tokenHash,
23d1a81Claude296 });
297
febd4f0Claude298 // Email delivery degrades gracefully — a failed send should never block
299 // the invite row from existing. Owner can resend / share the URL by hand.
300 const inviteUrl = absoluteUrl(`/invites/${token}`);
301 try {
302 const result = await sendEmail({
303 to: invitee.email,
304 subject: `You've been invited to ${ownerName}/${repoName}`,
305 text: `You've been invited to ${ownerName}/${repoName}. Click: ${inviteUrl}`,
306 });
307 if (!result.ok) {
308 console.error(
309 `[collaborators] invite email send failed for ${invitee.username}:`,
310 result.error || result.skipped
311 );
312 }
313 } catch (err) {
314 console.error(
315 `[collaborators] invite email threw for ${invitee.username}:`,
316 err
317 );
318 }
319
23d1a81Claude320 return c.redirect(
febd4f0Claude321 `/${ownerName}/${repoName}/settings/collaborators?success=Invite+sent`
23d1a81Claude322 );
323 }
324);
325
326// ─── Remove collaborator ────────────────────────────────────────────────────
327
328collaboratorRoutes.post(
329 "/:owner/:repo/settings/collaborators/:collaboratorId/remove",
330 requireAuth,
331 async (c) => {
332 const { owner: ownerName, repo: repoName, collaboratorId } =
333 c.req.param();
334
335 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
336 if ("error" in resolved) return resolved.error;
337 const { repo } = resolved;
338
339 // Scope the delete to this repo so an owner can't remove a collaborator
340 // from some other repo by crafting a URL.
341 await db
342 .delete(repoCollaborators)
343 .where(
344 and(
345 eq(repoCollaborators.id, collaboratorId),
346 eq(repoCollaborators.repositoryId, repo.id)
347 )
348 );
349
350 return c.redirect(
351 `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+removed`
352 );
353 }
354);
355
356export default collaboratorRoutes;