Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame316 lines · 1 contributor
23d1a81Claude1/**
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
18import { Hono } from "hono";
19import { eq, and } from "drizzle-orm";
20import { db } from "../db";
21import {
22 repositories,
23 users,
24 repoCollaborators,
25} from "../db/schema";
26import { Layout } from "../views/layout";
27import { RepoHeader } from "../views/components";
28import { softAuth, requireAuth } from "../middleware/auth";
29import type { AuthEnv } from "../middleware/auth";
30import {
31 Container,
32 Form,
33 FormGroup,
34 Input,
35 Select,
36 Button,
37 Alert,
38 EmptyState,
39} from "../views/ui";
40
41const collaboratorRoutes = new Hono<AuthEnv>();
42
43collaboratorRoutes.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 */
50async 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
88collaboratorRoutes.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
210collaboratorRoutes.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
288collaboratorRoutes.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
316export default collaboratorRoutes;