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

team-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.

team-collaborators.tsxBlame336 lines · 1 contributor
febd4f0Claude1/**
2 * Team-based repo collaborators — invite every accepted member of a team
3 * as a repo collaborator in a single action.
4 *
5 * Owner-only. Mirrors `src/routes/collaborators.tsx`'s resolveOwnerRepo
6 * pattern for the inline owner check. The "invite whole team" action
7 * iterates the team's members and upserts one `repo_collaborators` row per
8 * user (skipping the repo owner), auto-accepting the invite — matching the
9 * v1 auto-accept contract used by the single-user invite flow.
10 *
11 * GET /:owner/:repo/settings/collaborators/teams — form + list
12 * POST /:owner/:repo/settings/collaborators/teams/add — bulk insert
13 */
14
15import { Hono } from "hono";
16import { eq, and } from "drizzle-orm";
17import { db } from "../db";
18import {
19 repositories,
20 users,
21 repoCollaborators,
22 organizations,
23 orgMembers,
24 teams,
25 teamMembers,
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";
31import {
32 Container,
33 Form,
34 FormGroup,
35 Input,
36 Select,
37 Button,
38 Alert,
39 EmptyState,
40} from "../views/ui";
41
42const teamCollaboratorRoutes = new Hono<AuthEnv>();
43
44teamCollaboratorRoutes.use("*", softAuth);
45
46/**
47 * Resolve (owner user, repo) from URL params and enforce owner-only access.
48 * Mirrors the helper in `src/routes/collaborators.tsx` for consistency.
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 + invite form ─────────────────────────────────────────────────────
87
88teamCollaboratorRoutes.get(
89 "/:owner/:repo/settings/collaborators/teams",
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 // Orgs the current user belongs to — these populate the org dropdown.
101 const userOrgs = await db
102 .select({
103 id: organizations.id,
104 slug: organizations.slug,
105 name: organizations.name,
106 })
107 .from(organizations)
108 .innerJoin(orgMembers, eq(orgMembers.orgId, organizations.id))
109 .where(eq(orgMembers.userId, user.id));
110
111 // All collaborators for this repo — v1 just lists everyone with a count,
112 // not filtered by "added via team" (would need a sourceTeamId column).
113 const rows = await db
114 .select({
115 id: repoCollaborators.id,
116 role: repoCollaborators.role,
117 invitedAt: repoCollaborators.invitedAt,
118 acceptedAt: repoCollaborators.acceptedAt,
119 username: users.username,
120 avatarUrl: users.avatarUrl,
121 })
122 .from(repoCollaborators)
123 .innerJoin(users, eq(users.id, repoCollaborators.userId))
124 .where(eq(repoCollaborators.repositoryId, repo.id));
125
126 return c.html(
127 <Layout
128 title={`Invite team — ${ownerName}/${repoName}`}
129 user={user}
130 >
131 <RepoHeader owner={ownerName} repo={repoName} />
132 <Container maxWidth={700}>
133 <h2 style="margin-bottom: 16px">Invite a team</h2>
134 <p style="font-size:14px;color:var(--text-muted);margin-bottom:16px">
135 <a href={`/${ownerName}/${repoName}/settings/collaborators`}>
136 ← Back to collaborators
137 </a>
138 </p>
139 {success && (
140 <Alert variant="success">{decodeURIComponent(success)}</Alert>
141 )}
142 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
143
144 <div
145 style="margin-bottom: 24px; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius)"
146 >
147 <h3 style="margin-bottom: 12px">Invite every member of a team</h3>
148 {userOrgs.length === 0 ? (
149 <p style="font-size:14px;color:var(--text-muted)">
150 You don't belong to any organizations yet.
151 </p>
152 ) : (
153 <Form
154 method="post"
155 action={`/${ownerName}/${repoName}/settings/collaborators/teams/add`}
156 >
157 <FormGroup label="Organization" htmlFor="orgSlug">
158 <Select name="orgSlug" id="orgSlug">
159 {userOrgs.map((o) => (
160 <option value={o.slug}>
161 {o.name} ({o.slug})
162 </option>
163 ))}
164 </Select>
165 </FormGroup>
166 <FormGroup label="Team slug" htmlFor="teamSlug">
167 <Input
168 name="teamSlug"
169 id="teamSlug"
170 placeholder="engineering"
171 required
172 />
173 </FormGroup>
174 <FormGroup label="Role" htmlFor="role">
175 <Select name="role" id="role" value="read">
176 <option value="read">Read — clone + pull</option>
177 <option value="write">Write — push + merge</option>
178 <option value="admin">Admin — full control</option>
179 </Select>
180 </FormGroup>
181 <Button type="submit" variant="primary">
182 Invite team
183 </Button>
184 </Form>
185 )}
186 </div>
187
188 <h3 style="margin-bottom: 12px">
189 Current collaborators ({rows.length})
190 </h3>
191 {rows.length === 0 ? (
192 <EmptyState title="No collaborators yet">
193 <p>Invite a team above to add multiple people at once.</p>
194 </EmptyState>
195 ) : (
196 <div>
197 {rows.map((row) => (
198 <div class="ssh-key-item">
199 <div>
200 <strong>
201 {row.avatarUrl && (
202 <img
203 src={row.avatarUrl}
204 alt=""
205 style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:6px"
206 />
207 )}
208 <a href={`/${row.username}`}>{row.username}</a>
209 </strong>
210 <div class="ssh-key-meta">
211 Role: <strong>{row.role}</strong> | Invited:{" "}
212 {new Date(row.invitedAt).toLocaleDateString()} |{" "}
213 {row.acceptedAt ? (
214 <span style="color: var(--green)">Accepted</span>
215 ) : (
216 <span style="color: var(--yellow)">Pending</span>
217 )}
218 </div>
219 </div>
220 </div>
221 ))}
222 </div>
223 )}
224 </Container>
225 </Layout>
226 );
227 }
228);
229
230// ─── Invite entire team ─────────────────────────────────────────────────────
231
232teamCollaboratorRoutes.post(
233 "/:owner/:repo/settings/collaborators/teams/add",
234 requireAuth,
235 async (c) => {
236 const { owner: ownerName, repo: repoName } = c.req.param();
237 const body = await c.req.parseBody();
238
239 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
240 if ("error" in resolved) return resolved.error;
241 const { repo, user } = resolved;
242
243 const orgSlug = String(body.orgSlug || "").trim();
244 const teamSlug = String(body.teamSlug || "").trim();
245 const roleRaw = String(body.role || "read");
246 const role: "read" | "write" | "admin" =
247 roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read";
248
249 const redirBase = `/${ownerName}/${repoName}/settings/collaborators/teams`;
250
251 if (!orgSlug || !teamSlug) {
252 return c.redirect(
253 `${redirBase}?error=Organization+and+team+slug+are+required`
254 );
255 }
256
257 // Resolve org by slug, then team by (orgId, slug). We also verify the
258 // authed user is a member of the org — otherwise they shouldn't be able
259 // to enumerate team membership via this endpoint.
260 const [org] = await db
261 .select()
262 .from(organizations)
263 .where(eq(organizations.slug, orgSlug))
264 .limit(1);
265 if (!org) {
266 return c.redirect(`${redirBase}?error=Organization+not+found`);
267 }
268
269 const [membership] = await db
270 .select()
271 .from(orgMembers)
272 .where(
273 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id))
274 )
275 .limit(1);
276 if (!membership) {
277 return c.redirect(
278 `${redirBase}?error=You+are+not+a+member+of+that+organization`
279 );
280 }
281
282 const [team] = await db
283 .select()
284 .from(teams)
285 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
286 .limit(1);
287 if (!team) {
288 return c.redirect(`${redirBase}?error=Team+not+found`);
289 }
290
291 // Fetch team members. The team_members schema has no "acceptedAt"
292 // column — a row existing IS the acceptance — so every row counts.
293 const members = await db
294 .select({ userId: teamMembers.userId })
295 .from(teamMembers)
296 .where(eq(teamMembers.teamId, team.id));
297
298 let added = 0;
299 for (const m of members) {
300 // Never add the repo owner as their own collaborator.
301 if (m.userId === repo.ownerId) continue;
302
303 const [existing] = await db
304 .select()
305 .from(repoCollaborators)
306 .where(
307 and(
308 eq(repoCollaborators.repositoryId, repo.id),
309 eq(repoCollaborators.userId, m.userId)
310 )
311 )
312 .limit(1);
313
314 if (existing) {
315 await db
316 .update(repoCollaborators)
317 .set({ role, acceptedAt: existing.acceptedAt ?? new Date() })
318 .where(eq(repoCollaborators.id, existing.id));
319 } else {
320 await db.insert(repoCollaborators).values({
321 repositoryId: repo.id,
322 userId: m.userId,
323 role,
324 invitedBy: user.id,
325 acceptedAt: new Date(), // v1 auto-accept
326 });
327 }
328 added += 1;
329 }
330
331 const msg = `Added ${added} collaborators from team ${team.name}`;
332 return c.redirect(`${redirBase}?success=${encodeURIComponent(msg)}`);
333 }
334);
335
336export default teamCollaboratorRoutes;