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.tsxBlame782 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
398a10cClaude13 *
14 * 2026 polish: gradient-hairline hero + orb, scoped `.tc-*` classes,
15 * polished form card with focus rings + gradient submit, polished
16 * collaborator list cards, orbital empty state.
febd4f0Claude17 */
18
19import { Hono } from "hono";
20import { eq, and } from "drizzle-orm";
21import { db } from "../db";
22import {
23 repositories,
24 users,
25 repoCollaborators,
26 organizations,
27 orgMembers,
28 teams,
29 teamMembers,
30} from "../db/schema";
31import { Layout } from "../views/layout";
32import { RepoHeader } from "../views/components";
33import { softAuth, requireAuth } from "../middleware/auth";
34import type { AuthEnv } from "../middleware/auth";
35import {
36 EmptyState,
37} from "../views/ui";
38
39const teamCollaboratorRoutes = new Hono<AuthEnv>();
40
41teamCollaboratorRoutes.use("*", softAuth);
42
398a10cClaude43// ─── Scoped CSS — all classes prefixed `.tc-*` ─────────────────────────────
44const tcStyles = `
eed4684Claude45 .tc-wrap { max-width: 1200px; margin: 0 auto; padding: var(--space-5, 24px) var(--space-4, 24px); }
398a10cClaude46
47 /* ─── Back link ─── */
48 .tc-back {
49 display: inline-flex;
50 align-items: center;
51 gap: 6px;
52 font-size: 13px;
53 color: var(--text-muted);
54 text-decoration: none;
55 margin-bottom: var(--space-3);
56 transition: color 140ms ease;
57 }
58 .tc-back:hover { color: var(--text-strong); }
59
60 /* ─── Hero ─── */
61 .tc-hero {
62 position: relative;
63 margin-bottom: var(--space-5);
64 padding: clamp(24px, 3.5vw, 36px) clamp(20px, 3vw, 32px);
65 background: var(--bg-elevated);
66 border: 1px solid var(--border);
67 border-radius: 16px;
68 overflow: hidden;
69 }
70 .tc-hero::before {
71 content: '';
72 position: absolute;
73 top: 0; left: 0; right: 0;
74 height: 2px;
75 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
76 opacity: 0.75;
77 pointer-events: none;
78 }
79 .tc-hero-orb {
80 position: absolute;
81 inset: -25% -10% auto auto;
82 width: 360px; height: 360px;
83 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
84 filter: blur(80px);
85 opacity: 0.7;
86 pointer-events: none;
87 animation: tcHeroOrb 14s ease-in-out infinite;
88 z-index: 0;
89 }
90 @keyframes tcHeroOrb {
91 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.55; }
92 50% { transform: scale(1.08) translate(-10px, 8px); opacity: 0.8; }
93 }
94 @media (prefers-reduced-motion: reduce) {
95 .tc-hero-orb { animation: none; }
96 }
97 .tc-hero-inner { position: relative; z-index: 1; max-width: 640px; }
98 .tc-eyebrow {
99 font-family: var(--font-mono);
100 font-size: 11px;
101 letter-spacing: 0.16em;
102 text-transform: uppercase;
103 color: var(--text-muted);
104 font-weight: 600;
105 margin-bottom: 10px;
106 }
107 .tc-eyebrow strong { color: var(--accent); font-weight: 700; }
108 .tc-title {
109 font-family: var(--font-display);
110 font-size: clamp(26px, 3.6vw, 36px);
111 font-weight: 800;
112 letter-spacing: -0.026em;
113 line-height: 1.08;
114 margin: 0 0 var(--space-2);
115 color: var(--text-strong);
116 }
117 .tc-title-grad {
118 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
119 -webkit-background-clip: text;
120 background-clip: text;
121 -webkit-text-fill-color: transparent;
122 color: transparent;
123 }
124 .tc-sub {
125 font-size: 14.5px;
126 color: var(--text-muted);
127 line-height: 1.55;
128 margin: 0;
129 max-width: 560px;
130 }
131
132 /* ─── Banners ─── */
133 .tc-banner {
134 position: relative;
135 padding: 12px 16px 12px 40px;
136 margin-bottom: var(--space-4);
137 border-radius: 12px;
138 border: 1px solid var(--border);
139 background: var(--bg-elevated);
140 font-size: 14px;
141 line-height: 1.5;
142 }
143 .tc-banner::before {
144 content: '';
145 position: absolute;
146 left: 14px; top: 16px;
147 width: 12px; height: 12px;
148 border-radius: 50%;
149 }
150 .tc-banner-success {
151 border-color: rgba(63, 185, 80, 0.32);
152 background: linear-gradient(180deg, rgba(63,185,80,0.06) 0%, var(--bg-elevated) 100%);
153 }
154 .tc-banner-success::before {
155 background: radial-gradient(circle, #3fb950 30%, transparent 70%);
156 box-shadow: 0 0 10px rgba(63,185,80,0.5);
157 }
158 .tc-banner-error {
159 border-color: rgba(248, 81, 73, 0.32);
160 background: linear-gradient(180deg, rgba(248,81,73,0.06) 0%, var(--bg-elevated) 100%);
161 }
162 .tc-banner-error::before {
163 background: radial-gradient(circle, #f85149 30%, transparent 70%);
164 box-shadow: 0 0 10px rgba(248,81,73,0.5);
165 }
166
167 /* ─── Form card ─── */
168 .tc-card {
169 position: relative;
170 margin-bottom: var(--space-5);
171 padding: clamp(20px, 3vw, 28px);
172 background: var(--bg-elevated);
173 border: 1px solid var(--border);
174 border-radius: 14px;
175 overflow: hidden;
176 }
177 .tc-card-head {
178 display: flex;
179 align-items: center;
180 gap: 10px;
181 margin-bottom: 6px;
182 }
183 .tc-card-badge {
184 display: inline-flex;
185 align-items: center;
186 justify-content: center;
187 width: 26px; height: 26px;
188 border-radius: 50%;
189 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
190 color: #c5b3ff;
191 border: 1px solid rgba(140,109,255,0.40);
192 font-family: var(--font-display);
193 font-weight: 700;
194 font-size: 12.5px;
195 }
196 .tc-card-title {
197 font-family: var(--font-display);
198 font-size: 16px;
199 font-weight: 700;
200 letter-spacing: -0.012em;
201 color: var(--text-strong);
202 margin: 0;
203 }
204 .tc-card-sub {
205 font-size: 13px;
206 color: var(--text-muted);
207 margin: 0 0 var(--space-3);
208 line-height: 1.5;
209 }
210 .tc-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--space-3); }
211 .tc-field-label {
212 font-size: 13px;
213 color: var(--text-strong);
214 font-weight: 600;
215 }
216 .tc-field-input,
217 .tc-field-select {
218 appearance: none;
219 width: 100%;
220 background: var(--bg);
221 border: 1px solid var(--border);
222 color: var(--text-strong);
223 border-radius: 10px;
224 padding: 10px 12px;
225 font-size: 14px;
226 font-family: inherit;
227 transition: border-color 140ms ease, box-shadow 140ms ease;
228 }
229 .tc-field-select {
230 background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%),
231 linear-gradient(135deg, var(--text-muted) 50%, transparent 50%);
232 background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%;
233 background-size: 5px 5px, 5px 5px;
234 background-repeat: no-repeat;
235 padding-right: 32px;
236 }
237 .tc-field-input:focus,
238 .tc-field-select:focus {
239 outline: none;
240 border-color: var(--accent);
241 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
242 }
243 .tc-submit {
244 appearance: none;
245 border: 1px solid rgba(140,109,255,0.45);
246 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
247 color: #fff;
248 padding: 11px 22px;
249 border-radius: 10px;
250 font-family: var(--font-display);
251 font-weight: 700;
252 font-size: 14px;
253 cursor: pointer;
254 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.55);
255 transition: transform 140ms ease, box-shadow 140ms ease, filter 140ms ease;
256 }
257 .tc-submit:hover {
258 transform: translateY(-1px);
259 box-shadow: 0 12px 24px -8px rgba(140,109,255,0.7);
260 filter: brightness(1.06);
261 }
262 .tc-submit:focus-visible {
263 outline: 3px solid rgba(140,109,255,0.45);
264 outline-offset: 2px;
265 }
266
267 /* ─── Empty card (no orgs) ─── */
268 .tc-empty-orgs {
269 position: relative;
270 padding: var(--space-4);
271 border: 1px dashed var(--border);
272 border-radius: 12px;
273 background: var(--bg-secondary);
274 text-align: center;
275 font-size: 13.5px;
276 color: var(--text-muted);
277 line-height: 1.55;
278 }
279 .tc-empty-orgs a { color: var(--accent); text-decoration: none; }
280 .tc-empty-orgs a:hover { text-decoration: underline; }
281
282 /* ─── Collaborator list ─── */
283 .tc-list-head {
284 display: flex;
285 align-items: baseline;
286 justify-content: space-between;
287 margin: var(--space-5) 0 var(--space-3);
288 }
289 .tc-list-title {
290 font-family: var(--font-display);
291 font-size: 18px;
292 font-weight: 700;
293 letter-spacing: -0.014em;
294 color: var(--text-strong);
295 margin: 0;
296 }
297 .tc-list-count {
298 font-family: var(--font-mono);
299 font-size: 12px;
300 color: var(--text-muted);
301 }
302 .tc-list {
303 display: flex;
304 flex-direction: column;
305 gap: 8px;
306 }
307 .tc-row {
308 display: flex;
309 align-items: center;
310 gap: 12px;
311 padding: 12px 16px;
312 background: var(--bg-elevated);
313 border: 1px solid var(--border);
314 border-radius: 12px;
315 transition: border-color 140ms ease, transform 140ms ease;
316 }
317 .tc-row:hover {
318 border-color: rgba(140,109,255,0.35);
319 transform: translateY(-1px);
320 }
321 .tc-avatar {
322 width: 32px; height: 32px;
323 border-radius: 50%;
324 object-fit: cover;
325 background: var(--bg-secondary);
326 flex-shrink: 0;
327 }
328 .tc-avatar-fallback {
329 width: 32px; height: 32px;
330 border-radius: 50%;
331 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
332 color: #fff;
333 display: inline-flex;
334 align-items: center;
335 justify-content: center;
336 font-family: var(--font-display);
337 font-weight: 700;
338 font-size: 13px;
339 flex-shrink: 0;
340 }
341 .tc-row-body { flex: 1; min-width: 0; }
342 .tc-row-name {
343 font-size: 14px;
344 color: var(--text-strong);
345 font-weight: 600;
346 text-decoration: none;
347 }
348 .tc-row-name:hover { color: var(--accent); }
349 .tc-row-meta {
350 font-size: 12px;
351 color: var(--text-muted);
352 margin-top: 2px;
353 display: flex;
354 gap: 10px;
355 flex-wrap: wrap;
356 }
357 .tc-row-role {
358 font-family: var(--font-mono);
359 font-size: 11px;
360 padding: 2px 8px;
361 border-radius: 999px;
362 background: rgba(140,109,255,0.10);
363 border: 1px solid rgba(140,109,255,0.30);
364 color: #c5b3ff;
365 text-transform: uppercase;
366 letter-spacing: 0.04em;
367 }
368 .tc-row-pill-ok {
369 font-family: var(--font-mono);
370 font-size: 11px;
371 padding: 2px 8px;
372 border-radius: 999px;
373 background: rgba(63,185,80,0.10);
374 border: 1px solid rgba(63,185,80,0.35);
375 color: #4ec55d;
376 text-transform: uppercase;
377 letter-spacing: 0.04em;
378 }
379 .tc-row-pill-warn {
380 font-family: var(--font-mono);
381 font-size: 11px;
382 padding: 2px 8px;
383 border-radius: 999px;
384 background: rgba(251,191,36,0.10);
385 border: 1px solid rgba(251,191,36,0.35);
386 color: #fbbf24;
387 text-transform: uppercase;
388 letter-spacing: 0.04em;
389 }
390
391 /* ─── Empty state for no collaborators ─── */
392 .tc-empty {
393 position: relative;
394 padding: 36px 24px;
395 text-align: center;
396 background: var(--bg-elevated);
397 border: 1px solid var(--border);
398 border-radius: 14px;
399 overflow: hidden;
400 }
401 .tc-empty-orb {
402 position: absolute;
403 inset: -50% 25% auto 25%;
404 width: 50%; height: 200px;
405 background: radial-gradient(circle, rgba(140,109,255,0.16), transparent 65%);
406 filter: blur(50px);
407 opacity: 0.7;
408 pointer-events: none;
409 z-index: 0;
410 }
411 .tc-empty-inner { position: relative; z-index: 1; }
412 .tc-empty-title {
413 font-family: var(--font-display);
414 font-size: 16px;
415 font-weight: 700;
416 letter-spacing: -0.012em;
417 color: var(--text-strong);
418 margin: 0 0 6px;
419 }
420 .tc-empty-desc {
421 font-size: 13.5px;
422 color: var(--text-muted);
423 line-height: 1.55;
424 margin: 0;
425 }
426`;
427
febd4f0Claude428/**
429 * Resolve (owner user, repo) from URL params and enforce owner-only access.
430 * Mirrors the helper in `src/routes/collaborators.tsx` for consistency.
431 */
432async function resolveOwnerRepo(
433 c: any,
434 ownerName: string,
435 repoName: string
436) {
437 const user = c.get("user")!;
438 const [owner] = await db
439 .select()
440 .from(users)
441 .where(eq(users.username, ownerName))
442 .limit(1);
443 if (!owner || owner.id !== user.id) {
444 return {
445 error: c.html(
446 <Layout title="Unauthorized" user={user}>
447 <EmptyState title="Unauthorized">
448 <p>Only the repository owner can manage collaborators.</p>
449 </EmptyState>
450 </Layout>,
451 403
452 ),
453 };
454 }
455 const [repo] = await db
456 .select()
457 .from(repositories)
458 .where(
459 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
460 )
461 .limit(1);
462 if (!repo) {
463 return { error: c.notFound() };
464 }
465 return { owner, repo, user };
466}
467
468// ─── List + invite form ─────────────────────────────────────────────────────
469
470teamCollaboratorRoutes.get(
471 "/:owner/:repo/settings/collaborators/teams",
472 requireAuth,
473 async (c) => {
474 const { owner: ownerName, repo: repoName } = c.req.param();
475 const success = c.req.query("success");
476 const error = c.req.query("error");
477
478 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
479 if ("error" in resolved) return resolved.error;
480 const { repo, user } = resolved;
481
482 // Orgs the current user belongs to — these populate the org dropdown.
483 const userOrgs = await db
484 .select({
485 id: organizations.id,
486 slug: organizations.slug,
487 name: organizations.name,
488 })
489 .from(organizations)
490 .innerJoin(orgMembers, eq(orgMembers.orgId, organizations.id))
491 .where(eq(orgMembers.userId, user.id));
492
493 // All collaborators for this repo — v1 just lists everyone with a count,
494 // not filtered by "added via team" (would need a sourceTeamId column).
495 const rows = await db
496 .select({
497 id: repoCollaborators.id,
498 role: repoCollaborators.role,
499 invitedAt: repoCollaborators.invitedAt,
500 acceptedAt: repoCollaborators.acceptedAt,
501 username: users.username,
502 avatarUrl: users.avatarUrl,
503 })
504 .from(repoCollaborators)
505 .innerJoin(users, eq(users.id, repoCollaborators.userId))
506 .where(eq(repoCollaborators.repositoryId, repo.id));
507
508 return c.html(
509 <Layout
510 title={`Invite team — ${ownerName}/${repoName}`}
511 user={user}
512 >
513 <RepoHeader owner={ownerName} repo={repoName} />
398a10cClaude514 <style dangerouslySetInnerHTML={{ __html: tcStyles }} />
515 <div class="tc-wrap">
516 <a
517 href={`/${ownerName}/${repoName}/settings/collaborators`}
518 class="tc-back"
519 >
520 &larr; Back to collaborators
521 </a>
522
523 {/* ─── Hero ─── */}
524 <div class="tc-hero">
525 <div class="tc-hero-orb" aria-hidden="true" />
526 <div class="tc-hero-inner">
527 <div class="tc-eyebrow">
528 <strong>Teams</strong> · bulk invite
529 </div>
530 <h1 class="tc-title">
531 Invite a whole{" "}
532 <span class="tc-title-grad">team</span> at once.
533 </h1>
534 <p class="tc-sub">
535 Pick an org and a team, choose a role, and every member of
536 that team is added to{" "}
537 <strong>{ownerName}/{repoName}</strong> in one shot. No
538 individual invite emails to chase.
539 </p>
540 </div>
541 </div>
542
febd4f0Claude543 {success && (
398a10cClaude544 <div class="tc-banner tc-banner-success" role="status">
545 {decodeURIComponent(success)}
546 </div>
547 )}
548 {error && (
549 <div class="tc-banner tc-banner-error" role="alert">
550 {decodeURIComponent(error)}
551 </div>
febd4f0Claude552 )}
553
398a10cClaude554 {/* ─── Form card ─── */}
555 <div class="tc-card">
556 <div class="tc-card-head">
557 <span class="tc-card-badge" aria-hidden="true">1</span>
558 <h2 class="tc-card-title">Invite every member of a team</h2>
559 </div>
560 <p class="tc-card-sub">
561 Each member is added with the role you pick. Existing
562 collaborators are updated; the repo owner is always skipped.
563 </p>
febd4f0Claude564 {userOrgs.length === 0 ? (
398a10cClaude565 <div class="tc-empty-orgs">
566 You don't belong to any organizations yet.{" "}
567 <a href="/orgs/new">Create one</a> to start inviting teams.
568 </div>
febd4f0Claude569 ) : (
398a10cClaude570 <form
febd4f0Claude571 method="post"
572 action={`/${ownerName}/${repoName}/settings/collaborators/teams/add`}
573 >
398a10cClaude574 <div class="tc-field">
575 <label class="tc-field-label" for="orgSlug">Organization</label>
576 <select
577 name="orgSlug"
578 id="orgSlug"
579 class="tc-field-select"
580 >
febd4f0Claude581 {userOrgs.map((o) => (
582 <option value={o.slug}>
583 {o.name} ({o.slug})
584 </option>
585 ))}
398a10cClaude586 </select>
587 </div>
588 <div class="tc-field">
589 <label class="tc-field-label" for="teamSlug">Team slug</label>
590 <input
febd4f0Claude591 name="teamSlug"
592 id="teamSlug"
593 placeholder="engineering"
594 required
398a10cClaude595 class="tc-field-input"
febd4f0Claude596 />
398a10cClaude597 </div>
598 <div class="tc-field">
599 <label class="tc-field-label" for="role">Role</label>
600 <select
601 name="role"
602 id="role"
603 class="tc-field-select"
604 >
febd4f0Claude605 <option value="read">Read — clone + pull</option>
606 <option value="write">Write — push + merge</option>
607 <option value="admin">Admin — full control</option>
398a10cClaude608 </select>
609 </div>
610 <button type="submit" class="tc-submit">
febd4f0Claude611 Invite team
398a10cClaude612 </button>
613 </form>
febd4f0Claude614 )}
615 </div>
616
398a10cClaude617 {/* ─── Current collaborators ─── */}
618 <div class="tc-list-head">
619 <h2 class="tc-list-title">Current collaborators</h2>
620 <span class="tc-list-count">{rows.length} total</span>
621 </div>
febd4f0Claude622 {rows.length === 0 ? (
398a10cClaude623 <div class="tc-empty">
624 <div class="tc-empty-orb" aria-hidden="true" />
625 <div class="tc-empty-inner">
626 <h3 class="tc-empty-title">No collaborators yet</h3>
627 <p class="tc-empty-desc">
628 Invite a team above to add multiple people at once,
629 or invite a single user from the collaborators page.
630 </p>
631 </div>
632 </div>
febd4f0Claude633 ) : (
398a10cClaude634 <div class="tc-list">
febd4f0Claude635 {rows.map((row) => (
398a10cClaude636 <div class="tc-row">
637 {row.avatarUrl ? (
638 <img
639 class="tc-avatar"
640 src={row.avatarUrl}
641 alt=""
642 width={32}
643 height={32}
644 />
645 ) : (
646 <span class="tc-avatar-fallback" aria-hidden="true">
647 {row.username.charAt(0).toUpperCase()}
648 </span>
649 )}
650 <div class="tc-row-body">
651 <a href={`/${row.username}`} class="tc-row-name">
652 {row.username}
653 </a>
654 <div class="tc-row-meta">
655 <span class="tc-row-role">{row.role}</span>
febd4f0Claude656 {row.acceptedAt ? (
398a10cClaude657 <span class="tc-row-pill-ok">Accepted</span>
febd4f0Claude658 ) : (
398a10cClaude659 <span class="tc-row-pill-warn">Pending</span>
febd4f0Claude660 )}
398a10cClaude661 <span>
662 Invited {new Date(row.invitedAt).toLocaleDateString()}
663 </span>
febd4f0Claude664 </div>
665 </div>
666 </div>
667 ))}
668 </div>
669 )}
398a10cClaude670 </div>
febd4f0Claude671 </Layout>
672 );
673 }
674);
675
676// ─── Invite entire team ─────────────────────────────────────────────────────
677
678teamCollaboratorRoutes.post(
679 "/:owner/:repo/settings/collaborators/teams/add",
680 requireAuth,
681 async (c) => {
682 const { owner: ownerName, repo: repoName } = c.req.param();
683 const body = await c.req.parseBody();
684
685 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
686 if ("error" in resolved) return resolved.error;
687 const { repo, user } = resolved;
688
689 const orgSlug = String(body.orgSlug || "").trim();
690 const teamSlug = String(body.teamSlug || "").trim();
691 const roleRaw = String(body.role || "read");
692 const role: "read" | "write" | "admin" =
693 roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read";
694
695 const redirBase = `/${ownerName}/${repoName}/settings/collaborators/teams`;
696
697 if (!orgSlug || !teamSlug) {
698 return c.redirect(
699 `${redirBase}?error=Organization+and+team+slug+are+required`
700 );
701 }
702
703 // Resolve org by slug, then team by (orgId, slug). We also verify the
704 // authed user is a member of the org — otherwise they shouldn't be able
705 // to enumerate team membership via this endpoint.
706 const [org] = await db
707 .select()
708 .from(organizations)
709 .where(eq(organizations.slug, orgSlug))
710 .limit(1);
711 if (!org) {
712 return c.redirect(`${redirBase}?error=Organization+not+found`);
713 }
714
715 const [membership] = await db
716 .select()
717 .from(orgMembers)
718 .where(
719 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id))
720 )
721 .limit(1);
722 if (!membership) {
723 return c.redirect(
724 `${redirBase}?error=You+are+not+a+member+of+that+organization`
725 );
726 }
727
728 const [team] = await db
729 .select()
730 .from(teams)
731 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
732 .limit(1);
733 if (!team) {
734 return c.redirect(`${redirBase}?error=Team+not+found`);
735 }
736
737 // Fetch team members. The team_members schema has no "acceptedAt"
738 // column — a row existing IS the acceptance — so every row counts.
739 const members = await db
740 .select({ userId: teamMembers.userId })
741 .from(teamMembers)
742 .where(eq(teamMembers.teamId, team.id));
743
744 let added = 0;
745 for (const m of members) {
746 // Never add the repo owner as their own collaborator.
747 if (m.userId === repo.ownerId) continue;
748
749 const [existing] = await db
750 .select()
751 .from(repoCollaborators)
752 .where(
753 and(
754 eq(repoCollaborators.repositoryId, repo.id),
755 eq(repoCollaborators.userId, m.userId)
756 )
757 )
758 .limit(1);
759
760 if (existing) {
761 await db
762 .update(repoCollaborators)
763 .set({ role, acceptedAt: existing.acceptedAt ?? new Date() })
764 .where(eq(repoCollaborators.id, existing.id));
765 } else {
766 await db.insert(repoCollaborators).values({
767 repositoryId: repo.id,
768 userId: m.userId,
769 role,
770 invitedBy: user.id,
771 acceptedAt: new Date(), // v1 auto-accept
772 });
773 }
774 added += 1;
775 }
776
777 const msg = `Added ${added} collaborators from team ${team.name}`;
778 return c.redirect(`${redirBase}?success=${encodeURIComponent(msg)}`);
779 }
780);
781
782export default teamCollaboratorRoutes;