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.tsxBlame930 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.
17f6cacClaude17 *
18 * 2026 polish: the GET surface uses a scoped `.collab-*` class system that
19 * mirrors `admin-ops.tsx` (section cards, hero gradient hairline, traffic-
20 * light dots for invite status). RepoHeader sits above untouched; we only
21 * own the content-area markup beneath it.
23d1a81Claude22 */
23
24import { Hono } from "hono";
25import { eq, and } from "drizzle-orm";
26import { db } from "../db";
27import {
28 repositories,
29 users,
30 repoCollaborators,
31} from "../db/schema";
32import { Layout } from "../views/layout";
33import { RepoHeader } from "../views/components";
34import { softAuth, requireAuth } from "../middleware/auth";
35import type { AuthEnv } from "../middleware/auth";
febd4f0Claude36import { generateInviteToken, hashInviteToken } from "../lib/invite-tokens";
37import { sendEmail, absoluteUrl } from "../lib/email";
17f6cacClaude38import { EmptyState } from "../views/ui";
23d1a81Claude39
40const collaboratorRoutes = new Hono<AuthEnv>();
41
42collaboratorRoutes.use("*", softAuth);
43
44/**
45 * Resolve (owner user, repo) from URL params, enforcing the authed user is
46 * the repo owner. Returns `{ owner, repo }` on success, or an already-built
47 * Response on failure (caller should return it directly).
48 */
49async function resolveOwnerRepo(
50 c: any,
51 ownerName: string,
52 repoName: string
53) {
54 const user = c.get("user")!;
55 const [owner] = await db
56 .select()
57 .from(users)
58 .where(eq(users.username, ownerName))
59 .limit(1);
60 if (!owner || owner.id !== user.id) {
61 return {
62 error: c.html(
63 <Layout title="Unauthorized" user={user}>
64 <EmptyState title="Unauthorized">
65 <p>Only the repository owner can manage collaborators.</p>
66 </EmptyState>
67 </Layout>,
68 403
69 ),
70 };
71 }
72 const [repo] = await db
73 .select()
74 .from(repositories)
75 .where(
76 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
77 )
78 .limit(1);
79 if (!repo) {
80 return { error: c.notFound() };
81 }
82 return { owner, repo, user };
83}
84
17f6cacClaude85// ─── Scoped CSS (.collab-*) ─────────────────────────────────────────────────
86//
87// Every selector is prefixed `.collab-*` so the surface can't bleed into
88// the repo header / nav / page chrome above. Tokens reused from the layout
89// (--bg-elevated, --border, --text-strong, --accent, --space-*, --font-*).
90
91const collabStyles = `
eed4684Claude92 .collab-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
17f6cacClaude93
94 /* ─── Header strip (sits below RepoHeader + RepoNav) ─── */
95 .collab-head { margin-bottom: var(--space-5); }
96 .collab-eyebrow {
97 display: inline-flex;
98 align-items: center;
99 gap: 8px;
100 text-transform: uppercase;
101 font-family: var(--font-mono);
102 font-size: 11px;
103 letter-spacing: 0.16em;
104 color: var(--text-muted);
105 font-weight: 600;
106 margin-bottom: 10px;
107 }
108 .collab-eyebrow-dot {
109 width: 8px; height: 8px;
110 border-radius: 9999px;
111 background: linear-gradient(135deg, #8c6dff, #36c5d6);
112 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
113 }
114 .collab-title {
115 font-family: var(--font-display);
116 font-size: clamp(24px, 3.4vw, 36px);
117 font-weight: 800;
118 letter-spacing: -0.028em;
119 line-height: 1.1;
120 margin: 0 0 6px;
121 color: var(--text-strong);
122 }
123 .collab-title-grad {
124 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
125 -webkit-background-clip: text;
126 background-clip: text;
127 -webkit-text-fill-color: transparent;
128 color: transparent;
129 }
130 .collab-sub {
131 margin: 0;
132 font-size: 14px;
133 color: var(--text-muted);
134 line-height: 1.5;
135 max-width: 700px;
136 }
137
138 /* ─── Banners ─── */
139 .collab-banner {
140 margin-bottom: var(--space-4);
141 padding: 10px 14px;
142 border-radius: 10px;
143 font-size: 13.5px;
144 border: 1px solid var(--border);
145 background: rgba(255,255,255,0.025);
146 color: var(--text);
147 display: flex;
148 align-items: center;
149 gap: 10px;
150 }
151 .collab-banner.is-ok {
152 border-color: rgba(52,211,153,0.40);
153 background: rgba(52,211,153,0.08);
154 color: #bbf7d0;
155 }
156 .collab-banner.is-error {
157 border-color: rgba(248,113,113,0.40);
158 background: rgba(248,113,113,0.08);
159 color: #fecaca;
160 }
161 .collab-banner-dot {
162 width: 8px; height: 8px;
163 border-radius: 9999px;
164 background: currentColor;
165 flex-shrink: 0;
166 }
167
168 /* ─── Section cards ─── */
169 .collab-section {
170 margin-bottom: var(--space-5);
171 background: var(--bg-elevated);
172 border: 1px solid var(--border);
173 border-radius: 14px;
174 overflow: hidden;
175 position: relative;
176 }
177 .collab-section::before {
178 content: '';
179 position: absolute;
180 top: 0; left: 0; right: 0;
181 height: 2px;
182 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
183 opacity: 0.55;
184 pointer-events: none;
185 }
186 .collab-section-head {
187 padding: var(--space-4) var(--space-5) var(--space-3);
188 border-bottom: 1px solid var(--border);
189 display: flex;
190 align-items: flex-start;
191 justify-content: space-between;
192 gap: var(--space-3);
193 flex-wrap: wrap;
194 }
195 .collab-section-head-text { flex: 1; min-width: 240px; }
196 .collab-section-title {
197 margin: 0;
198 font-family: var(--font-display);
199 font-size: 16px;
200 font-weight: 700;
201 letter-spacing: -0.018em;
202 color: var(--text-strong);
203 display: flex;
204 align-items: center;
205 gap: 10px;
206 }
207 .collab-section-title-icon {
208 display: inline-flex;
209 align-items: center;
210 justify-content: center;
211 width: 26px; height: 26px;
212 border-radius: 8px;
213 background: rgba(140,109,255,0.12);
214 color: #b69dff;
215 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
216 flex-shrink: 0;
217 }
218 .collab-section-sub {
219 margin: 6px 0 0 36px;
220 font-size: 12.5px;
221 color: var(--text-muted);
222 line-height: 1.45;
223 }
224 .collab-section-body { padding: var(--space-4) var(--space-5); }
225
226 /* ─── Invite form ─── */
227 .collab-invite {
228 display: grid;
229 grid-template-columns: 1.4fr 1fr auto;
230 gap: 10px;
231 align-items: end;
232 }
233 @media (max-width: 700px) {
234 .collab-invite { grid-template-columns: 1fr; }
235 }
236 .collab-field-label {
237 display: block;
238 font-size: 11.5px;
239 color: var(--text-muted);
240 font-weight: 600;
241 text-transform: uppercase;
242 letter-spacing: 0.06em;
243 margin-bottom: 6px;
244 }
245 .collab-input,
246 .collab-select {
247 width: 100%;
248 box-sizing: border-box;
249 padding: 9px 12px;
250 font: inherit;
251 font-size: 13.5px;
252 color: var(--text);
253 background: rgba(255,255,255,0.03);
254 border: 1px solid var(--border-strong);
255 border-radius: 10px;
256 transition: border-color 120ms ease, background 120ms ease, box-shadow 120ms ease;
257 }
258 .collab-input:focus,
259 .collab-select:focus {
260 outline: none;
261 border-color: rgba(140,109,255,0.55);
262 background: rgba(255,255,255,0.05);
263 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
264 }
265 .collab-select { appearance: none; padding-right: 28px; background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%), linear-gradient(135deg, var(--text-muted) 50%, transparent 50%); background-position: right 12px top 50%, right 7px top 50%; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; }
266
267 /* ─── Buttons ─── */
268 .collab-btn {
269 display: inline-flex;
270 align-items: center;
271 justify-content: center;
272 gap: 6px;
273 padding: 9px 16px;
274 border-radius: 10px;
275 font-size: 13px;
276 font-weight: 600;
277 text-decoration: none;
278 border: 1px solid transparent;
279 cursor: pointer;
280 font: inherit;
281 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
282 line-height: 1;
283 white-space: nowrap;
284 }
285 .collab-btn-primary {
286 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
287 color: #ffffff;
288 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
289 }
290 .collab-btn-primary:hover {
291 transform: translateY(-1px);
292 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
293 text-decoration: none;
294 color: #ffffff;
295 }
296 .collab-btn-ghost {
297 background: transparent;
298 color: var(--text);
299 border-color: var(--border-strong);
300 }
301 .collab-btn-ghost:hover {
302 background: rgba(140,109,255,0.06);
303 border-color: rgba(140,109,255,0.45);
304 color: var(--text-strong);
305 text-decoration: none;
306 }
307 .collab-btn-danger {
308 background: transparent;
309 color: #fca5a5;
310 border-color: rgba(248,113,113,0.35);
311 }
312 .collab-btn-danger:hover {
313 border-style: dashed;
314 border-color: rgba(248,113,113,0.70);
315 background: rgba(248,113,113,0.06);
316 color: #fecaca;
317 text-decoration: none;
318 }
319
320 /* ─── Crumb links above title ─── */
321 .collab-crumbs {
322 display: flex;
323 align-items: center;
324 gap: 12px;
325 flex-wrap: wrap;
326 margin-bottom: var(--space-4);
327 font-size: 12.5px;
328 color: var(--text-muted);
329 }
330 .collab-crumbs a {
331 display: inline-flex;
332 align-items: center;
333 gap: 5px;
334 padding: 6px 11px;
335 background: rgba(255,255,255,0.025);
336 border: 1px solid var(--border);
337 border-radius: 8px;
338 color: var(--text-muted);
339 text-decoration: none;
340 font-weight: 500;
341 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
342 }
343 .collab-crumbs a:hover {
344 border-color: var(--border-strong);
345 color: var(--text-strong);
346 background: rgba(255,255,255,0.04);
347 text-decoration: none;
348 }
349
350 /* ─── People grid ─── */
351 .collab-grid {
352 display: grid;
353 grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
354 gap: 12px;
355 }
356 .collab-card {
357 display: flex;
358 align-items: flex-start;
359 gap: 14px;
360 padding: 14px;
361 background: rgba(255,255,255,0.018);
362 border: 1px solid var(--border);
363 border-radius: 12px;
364 transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
365 }
366 .collab-card:hover {
367 border-color: var(--border-strong);
368 background: rgba(255,255,255,0.03);
369 }
370 .collab-avatar {
371 width: 44px; height: 44px;
372 border-radius: 9999px;
373 background: linear-gradient(135deg, rgba(140,109,255,0.30), rgba(54,197,214,0.25));
374 color: #ffffff;
375 display: inline-flex;
376 align-items: center;
377 justify-content: center;
378 font-family: var(--font-display);
379 font-weight: 700;
380 font-size: 17px;
381 flex-shrink: 0;
382 overflow: hidden;
383 box-shadow: inset 0 0 0 1px rgba(255,255,255,0.10);
384 }
385 .collab-avatar img {
386 width: 100%; height: 100%;
387 object-fit: cover;
388 display: block;
389 }
390 .collab-card-body { flex: 1; min-width: 0; }
391 .collab-card-row {
392 display: flex;
393 align-items: baseline;
394 justify-content: space-between;
395 gap: 10px;
396 flex-wrap: wrap;
397 }
398 .collab-card-name {
399 font-family: var(--font-display);
400 font-weight: 700;
401 font-size: 14.5px;
402 color: var(--text-strong);
403 text-decoration: none;
404 letter-spacing: -0.005em;
405 }
406 .collab-card-name:hover { color: var(--text-strong); text-decoration: underline; }
407 .collab-card-handle {
408 font-family: var(--font-mono);
409 font-size: 12px;
410 color: var(--text-muted);
411 }
412 .collab-meta-row {
413 margin-top: 8px;
414 display: flex;
415 align-items: center;
416 gap: 8px;
417 flex-wrap: wrap;
418 font-size: 12px;
419 color: var(--text-muted);
420 font-variant-numeric: tabular-nums;
421 }
422 .collab-meta-row .sep { opacity: 0.4; }
423
424 /* ─── Role pills ─── */
425 .collab-pill {
426 display: inline-flex;
427 align-items: center;
428 gap: 6px;
429 padding: 3px 9px;
430 border-radius: 9999px;
431 font-size: 11px;
432 font-weight: 600;
433 letter-spacing: 0.02em;
434 text-transform: capitalize;
435 }
436 .collab-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
437 .collab-pill.is-admin {
438 background: rgba(140,109,255,0.16);
439 color: #c4b5fd;
440 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
441 }
442 .collab-pill.is-write {
443 background: rgba(54,197,214,0.14);
444 color: #67e8f9;
445 box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32);
446 }
447 .collab-pill.is-read {
448 background: rgba(148,163,184,0.16);
449 color: #cbd5e1;
450 box-shadow: inset 0 0 0 1px rgba(148,163,184,0.30);
451 }
452 .collab-pill.is-pending {
453 background: rgba(251,191,36,0.12);
454 color: #fde68a;
455 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
456 }
457 .collab-pill.is-accepted {
458 background: rgba(52,211,153,0.14);
459 color: #6ee7b7;
460 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
461 }
462
463 /* ─── Card action ─── */
464 .collab-card-actions { margin-top: 12px; }
465 .collab-card-actions form { margin: 0; }
466
467 /* ─── Empty state ─── */
468 .collab-empty {
469 position: relative;
470 overflow: hidden;
471 padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 36px);
472 text-align: center;
473 background: var(--bg-elevated);
474 border: 1px dashed var(--border-strong);
475 border-radius: 16px;
476 }
477 .collab-empty-orb {
478 position: absolute;
479 inset: -40% 30% auto 30%;
480 height: 280px;
481 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
482 filter: blur(70px);
483 opacity: 0.7;
484 pointer-events: none;
485 z-index: 0;
486 }
487 .collab-empty-inner { position: relative; z-index: 1; }
488 .collab-empty-icon {
489 width: 56px; height: 56px;
490 border-radius: 9999px;
491 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
492 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
493 display: inline-flex;
494 align-items: center;
495 justify-content: center;
496 color: #c4b5fd;
497 margin-bottom: 14px;
498 }
499 .collab-empty-title {
500 font-family: var(--font-display);
501 font-size: 18px;
502 font-weight: 700;
503 margin: 0 0 6px;
504 color: var(--text-strong);
505 }
506 .collab-empty-sub {
507 margin: 0 auto 0;
508 font-size: 13.5px;
509 color: var(--text-muted);
510 max-width: 420px;
511 line-height: 1.5;
512 }
513`;
514
515function IconUsers() {
516 return (
517 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
518 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
519 <circle cx="9" cy="7" r="4" />
520 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
521 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
522 </svg>
523 );
524}
525function IconPlus() {
526 return (
527 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
528 <line x1="12" y1="5" x2="12" y2="19" />
529 <line x1="5" y1="12" x2="19" y2="12" />
530 </svg>
531 );
532}
533function IconArrowLeft() {
534 return (
535 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
536 <line x1="19" y1="12" x2="5" y2="12" />
537 <polyline points="12 19 5 12 12 5" />
538 </svg>
539 );
540}
541function IconArrowRight() {
542 return (
543 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
544 <line x1="5" y1="12" x2="19" y2="12" />
545 <polyline points="12 5 19 12 12 19" />
546 </svg>
547 );
548}
549
550type CollabRole = "read" | "write" | "admin";
551
552function rolePillClass(role: string): string {
553 if (role === "admin") return "collab-pill is-admin";
554 if (role === "write") return "collab-pill is-write";
555 return "collab-pill is-read";
556}
557
558function roleLabel(role: string): string {
559 if (role === "admin") return "Admin";
560 if (role === "write") return "Write";
561 return "Read";
562}
563
564/** ISO-style short date with tabular-nums-friendly format. */
565function shortDate(d: Date | string | null | undefined): string {
566 if (!d) return "—";
567 const dt = d instanceof Date ? d : new Date(d);
568 if (Number.isNaN(dt.getTime())) return "—";
569 return dt.toISOString().slice(0, 10);
570}
571
23d1a81Claude572// ─── List collaborators ─────────────────────────────────────────────────────
573
574collaboratorRoutes.get(
575 "/:owner/:repo/settings/collaborators",
576 requireAuth,
577 async (c) => {
578 const { owner: ownerName, repo: repoName } = c.req.param();
579 const success = c.req.query("success");
580 const error = c.req.query("error");
581
582 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
583 if ("error" in resolved) return resolved.error;
584 const { repo, user } = resolved;
585
586 // Join collaborators with users to get username + avatar.
587 const rows = await db
588 .select({
589 id: repoCollaborators.id,
590 role: repoCollaborators.role,
591 invitedAt: repoCollaborators.invitedAt,
592 acceptedAt: repoCollaborators.acceptedAt,
593 username: users.username,
594 avatarUrl: users.avatarUrl,
595 userId: users.id,
596 })
597 .from(repoCollaborators)
598 .innerJoin(users, eq(users.id, repoCollaborators.userId))
599 .where(eq(repoCollaborators.repositoryId, repo.id));
600
601 return c.html(
602 <Layout title={`Collaborators — ${ownerName}/${repoName}`} user={user}>
603 <RepoHeader owner={ownerName} repo={repoName} />
17f6cacClaude604 <div class="collab-wrap">
605 <div class="collab-crumbs">
606 <a href={`/${ownerName}/${repoName}/settings`}>
607 <IconArrowLeft />
608 Back to settings
609 </a>
610 <a href={`/${ownerName}/${repoName}/settings/collaborators/teams`}>
611 Invite a team
612 <IconArrowRight />
febd4f0Claude613 </a>
17f6cacClaude614 </div>
615
616 <header class="collab-head">
617 <div class="collab-eyebrow">
618 <span class="collab-eyebrow-dot" aria-hidden="true" />
619 Repository · Collaborators
620 </div>
621 <h1 class="collab-title">
622 <span class="collab-title-grad">People with access.</span>
623 </h1>
624 <p class="collab-sub">
625 Owners can grant read, write, or admin scopes. Invitees confirm via
626 email before they appear as active.
627 </p>
628 </header>
629
23d1a81Claude630 {success && (
17f6cacClaude631 <div class="collab-banner is-ok" role="status">
632 <span class="collab-banner-dot" aria-hidden="true" />
633 {decodeURIComponent(success)}
634 </div>
635 )}
636 {error && (
637 <div class="collab-banner is-error" role="alert">
638 <span class="collab-banner-dot" aria-hidden="true" />
639 {decodeURIComponent(error)}
640 </div>
23d1a81Claude641 )}
642
17f6cacClaude643 {/* ─── Invite section ─── */}
644 <section class="collab-section">
645 <header class="collab-section-head">
646 <div class="collab-section-head-text">
647 <h2 class="collab-section-title">
648 <span class="collab-section-title-icon" aria-hidden="true">
649 <IconPlus />
650 </span>
651 Invite a collaborator
652 </h2>
653 <p class="collab-section-sub">
654 Enter a Gluecron username. They'll receive an email with a
655 one-time invite link.
656 </p>
657 </div>
658 </header>
659 <div class="collab-section-body">
660 <form
661 method="post"
662 action={`/${ownerName}/${repoName}/settings/collaborators/add`}
663 class="collab-invite"
664 >
665 <div>
666 <label class="collab-field-label" for="collab-username">Username</label>
667 <input
668 class="collab-input"
669 name="username"
670 id="collab-username"
671 placeholder="github-username"
672 required
673 />
674 </div>
675 <div>
676 <label class="collab-field-label" for="collab-role">Role</label>
677 <select class="collab-select" name="role" id="collab-role">
678 <option value="read">Read — clone + pull</option>
679 <option value="write">Write — push + merge</option>
680 <option value="admin">Admin — full control</option>
681 </select>
682 </div>
683 <div>
684 <button type="submit" class="collab-btn collab-btn-primary">
685 <IconPlus />
686 Send invite
687 </button>
688 </div>
689 </form>
690 </div>
691 </section>
692
693 {/* ─── People section ─── */}
694 <section class="collab-section">
695 <header class="collab-section-head">
696 <div class="collab-section-head-text">
697 <h2 class="collab-section-title">
698 <span class="collab-section-title-icon" aria-hidden="true">
699 <IconUsers />
700 </span>
701 Active &amp; pending
702 <span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
703 {" "}({rows.length})
704 </span>
705 </h2>
706 <p class="collab-section-sub">
707 Click a name to view their profile. Removing a collaborator
708 revokes access immediately.
709 </p>
710 </div>
711 </header>
712 <div class="collab-section-body">
713 {rows.length === 0 ? (
714 <div class="collab-empty">
715 <div class="collab-empty-orb" aria-hidden="true" />
716 <div class="collab-empty-inner">
717 <div class="collab-empty-icon" aria-hidden="true">
718 <IconUsers />
23d1a81Claude719 </div>
17f6cacClaude720 <h3 class="collab-empty-title">Invite your first collaborator</h3>
721 <p class="collab-empty-sub">
722 Add a teammate above to grant them clone, push, or admin
723 access to this repository.
724 </p>
23d1a81Claude725 </div>
726 </div>
17f6cacClaude727 ) : (
728 <div class="collab-grid">
729 {rows.map((row) => {
730 const accepted = !!row.acceptedAt;
731 return (
732 <div class="collab-card">
733 <div class="collab-avatar" aria-hidden="true">
734 {row.avatarUrl ? (
735 <img src={row.avatarUrl} alt="" loading="lazy" />
736 ) : (
737 row.username[0]?.toUpperCase() ?? "?"
738 )}
739 </div>
740 <div class="collab-card-body">
741 <div class="collab-card-row">
742 <a href={`/${row.username}`} class="collab-card-name">
743 {row.username}
744 </a>
745 <span class={rolePillClass(row.role)}>
746 <span class="dot" aria-hidden="true" />
747 {roleLabel(row.role)}
748 </span>
749 </div>
750 <div class="collab-card-handle">@{row.username}</div>
751 <div class="collab-meta-row">
752 <span class={accepted ? "collab-pill is-accepted" : "collab-pill is-pending"}>
753 <span class="dot" aria-hidden="true" />
754 {accepted ? "Accepted" : "Pending"}
755 </span>
756 <span class="sep">·</span>
757 <span>Invited {shortDate(row.invitedAt)}</span>
758 {accepted && row.acceptedAt && (
759 <>
760 <span class="sep">·</span>
761 <span>Joined {shortDate(row.acceptedAt)}</span>
762 </>
763 )}
764 </div>
765 <div class="collab-card-actions">
766 <form
767 method="post"
768 action={`/${ownerName}/${repoName}/settings/collaborators/${row.id}/remove`}
769 onsubmit="return confirm('Remove this collaborator?')"
770 >
771 <button type="submit" class="collab-btn collab-btn-danger">
772 Remove access
773 </button>
774 </form>
775 </div>
776 </div>
777 </div>
778 );
779 })}
780 </div>
781 )}
23d1a81Claude782 </div>
17f6cacClaude783 </section>
784 </div>
785 <style dangerouslySetInnerHTML={{ __html: collabStyles }} />
23d1a81Claude786 </Layout>
787 );
788 }
789);
790
791// ─── Add collaborator ───────────────────────────────────────────────────────
792
793collaboratorRoutes.post(
794 "/:owner/:repo/settings/collaborators/add",
795 requireAuth,
796 async (c) => {
797 const { owner: ownerName, repo: repoName } = c.req.param();
798 const body = await c.req.parseBody();
799
800 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
801 if ("error" in resolved) return resolved.error;
802 const { repo, user } = resolved;
803
804 const username = String(body.username || "").trim();
805 const roleRaw = String(body.role || "read");
17f6cacClaude806 const role: CollabRole =
23d1a81Claude807 roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read";
808
809 if (!username) {
810 return c.redirect(
811 `/${ownerName}/${repoName}/settings/collaborators?error=Username+required`
812 );
813 }
814
815 const [invitee] = await db
816 .select()
817 .from(users)
818 .where(eq(users.username, username))
819 .limit(1);
820 if (!invitee) {
821 return c.redirect(
822 `/${ownerName}/${repoName}/settings/collaborators?error=User+not+found`
823 );
824 }
825 if (invitee.id === user.id) {
826 return c.redirect(
827 `/${ownerName}/${repoName}/settings/collaborators?error=Owner+is+already+a+collaborator`
828 );
829 }
830
831 // If a row already exists for (repo, user), update the role instead of
832 // erroring. Mirrors the "upsert" contract so the owner can re-invite
833 // with a different role without first removing the prior row.
834 const [existing] = await db
835 .select()
836 .from(repoCollaborators)
837 .where(
838 and(
839 eq(repoCollaborators.repositoryId, repo.id),
840 eq(repoCollaborators.userId, invitee.id)
841 )
842 )
843 .limit(1);
844
845 if (existing) {
febd4f0Claude846 // Re-inviting an existing collaborator just updates the role. We don't
847 // re-issue a token here — if the prior invite hasn't been accepted the
848 // existing token is still valid; if it has, they're already in.
23d1a81Claude849 await db
850 .update(repoCollaborators)
febd4f0Claude851 .set({ role })
23d1a81Claude852 .where(eq(repoCollaborators.id, existing.id));
853 return c.redirect(
854 `/${ownerName}/${repoName}/settings/collaborators?success=Role+updated`
855 );
856 }
857
febd4f0Claude858 // Fresh invite: generate a single-use token, store only its hash, and
859 // email the plaintext to the invitee. acceptedAt stays NULL until they
860 // click through /invites/:token.
861 const token = generateInviteToken();
862 const tokenHash = hashInviteToken(token);
863
23d1a81Claude864 await db.insert(repoCollaborators).values({
865 repositoryId: repo.id,
866 userId: invitee.id,
867 role,
868 invitedBy: user.id,
febd4f0Claude869 inviteTokenHash: tokenHash,
23d1a81Claude870 });
871
febd4f0Claude872 // Email delivery degrades gracefully — a failed send should never block
873 // the invite row from existing. Owner can resend / share the URL by hand.
874 const inviteUrl = absoluteUrl(`/invites/${token}`);
875 try {
876 const result = await sendEmail({
877 to: invitee.email,
878 subject: `You've been invited to ${ownerName}/${repoName}`,
879 text: `You've been invited to ${ownerName}/${repoName}. Click: ${inviteUrl}`,
880 });
881 if (!result.ok) {
882 console.error(
883 `[collaborators] invite email send failed for ${invitee.username}:`,
884 result.error || result.skipped
885 );
886 }
887 } catch (err) {
888 console.error(
889 `[collaborators] invite email threw for ${invitee.username}:`,
890 err
891 );
892 }
893
23d1a81Claude894 return c.redirect(
febd4f0Claude895 `/${ownerName}/${repoName}/settings/collaborators?success=Invite+sent`
23d1a81Claude896 );
897 }
898);
899
900// ─── Remove collaborator ────────────────────────────────────────────────────
901
902collaboratorRoutes.post(
903 "/:owner/:repo/settings/collaborators/:collaboratorId/remove",
904 requireAuth,
905 async (c) => {
906 const { owner: ownerName, repo: repoName, collaboratorId } =
907 c.req.param();
908
909 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
910 if ("error" in resolved) return resolved.error;
911 const { repo } = resolved;
912
913 // Scope the delete to this repo so an owner can't remove a collaborator
914 // from some other repo by crafting a URL.
915 await db
916 .delete(repoCollaborators)
917 .where(
918 and(
919 eq(repoCollaborators.id, collaboratorId),
920 eq(repoCollaborators.repositoryId, repo.id)
921 )
922 );
923
924 return c.redirect(
925 `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+removed`
926 );
927 }
928);
929
930export default collaboratorRoutes;