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.tsxBlame931 lines · 2 contributors
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";
ae2a071ccanty labs33import { RepoHeader, RepoNav } from "../views/components";
23d1a81Claude34import { 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;
6fd5915Claude111 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
112 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
17f6cacClaude113 }
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 {
6fd5915Claude124 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
17f6cacClaude125 -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;
6fd5915Claude182 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
17f6cacClaude183 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;
6fd5915Claude213 background: rgba(91,110,232,0.12);
214 color: #5b6ee8;
215 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
17f6cacClaude216 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;
6fd5915Claude261 border-color: rgba(91,110,232,0.55);
17f6cacClaude262 background: rgba(255,255,255,0.05);
6fd5915Claude263 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
17f6cacClaude264 }
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 {
6fd5915Claude286 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
17f6cacClaude287 color: #ffffff;
6fd5915Claude288 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
17f6cacClaude289 }
290 .collab-btn-primary:hover {
291 transform: translateY(-1px);
6fd5915Claude292 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
17f6cacClaude293 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 {
6fd5915Claude302 background: rgba(91,110,232,0.06);
303 border-color: rgba(91,110,232,0.45);
17f6cacClaude304 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;
6fd5915Claude373 background: linear-gradient(135deg, rgba(91,110,232,0.30), rgba(95,143,160,0.25));
17f6cacClaude374 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 {
6fd5915Claude438 background: rgba(91,110,232,0.16);
17f6cacClaude439 color: #c4b5fd;
6fd5915Claude440 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
17f6cacClaude441 }
442 .collab-pill.is-write {
6fd5915Claude443 background: rgba(95,143,160,0.14);
17f6cacClaude444 color: #67e8f9;
6fd5915Claude445 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.32);
17f6cacClaude446 }
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;
6fd5915Claude481 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.10) 45%, transparent 70%);
17f6cacClaude482 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;
6fd5915Claude491 background: linear-gradient(135deg, rgba(91,110,232,0.25), rgba(95,143,160,0.20));
492 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.40);
17f6cacClaude493 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} />
ae2a071ccanty labs604 <RepoNav owner={ownerName} repo={repoName} active="settings" />
17f6cacClaude605 <div class="collab-wrap">
606 <div class="collab-crumbs">
607 <a href={`/${ownerName}/${repoName}/settings`}>
608 <IconArrowLeft />
609 Back to settings
610 </a>
611 <a href={`/${ownerName}/${repoName}/settings/collaborators/teams`}>
612 Invite a team
613 <IconArrowRight />
febd4f0Claude614 </a>
17f6cacClaude615 </div>
616
617 <header class="collab-head">
618 <div class="collab-eyebrow">
619 <span class="collab-eyebrow-dot" aria-hidden="true" />
620 Repository · Collaborators
621 </div>
622 <h1 class="collab-title">
623 <span class="collab-title-grad">People with access.</span>
624 </h1>
625 <p class="collab-sub">
626 Owners can grant read, write, or admin scopes. Invitees confirm via
627 email before they appear as active.
628 </p>
629 </header>
630
23d1a81Claude631 {success && (
17f6cacClaude632 <div class="collab-banner is-ok" role="status">
633 <span class="collab-banner-dot" aria-hidden="true" />
634 {decodeURIComponent(success)}
635 </div>
636 )}
637 {error && (
638 <div class="collab-banner is-error" role="alert">
639 <span class="collab-banner-dot" aria-hidden="true" />
640 {decodeURIComponent(error)}
641 </div>
23d1a81Claude642 )}
643
17f6cacClaude644 {/* ─── Invite section ─── */}
645 <section class="collab-section">
646 <header class="collab-section-head">
647 <div class="collab-section-head-text">
648 <h2 class="collab-section-title">
649 <span class="collab-section-title-icon" aria-hidden="true">
650 <IconPlus />
651 </span>
652 Invite a collaborator
653 </h2>
654 <p class="collab-section-sub">
655 Enter a Gluecron username. They'll receive an email with a
656 one-time invite link.
657 </p>
658 </div>
659 </header>
660 <div class="collab-section-body">
661 <form
662 method="post"
663 action={`/${ownerName}/${repoName}/settings/collaborators/add`}
664 class="collab-invite"
665 >
666 <div>
667 <label class="collab-field-label" for="collab-username">Username</label>
668 <input
669 class="collab-input"
670 name="username"
671 id="collab-username"
672 placeholder="github-username"
673 required
674 />
675 </div>
676 <div>
677 <label class="collab-field-label" for="collab-role">Role</label>
678 <select class="collab-select" name="role" id="collab-role">
679 <option value="read">Read — clone + pull</option>
680 <option value="write">Write — push + merge</option>
681 <option value="admin">Admin — full control</option>
682 </select>
683 </div>
684 <div>
685 <button type="submit" class="collab-btn collab-btn-primary">
686 <IconPlus />
687 Send invite
688 </button>
689 </div>
690 </form>
691 </div>
692 </section>
693
694 {/* ─── People section ─── */}
695 <section class="collab-section">
696 <header class="collab-section-head">
697 <div class="collab-section-head-text">
698 <h2 class="collab-section-title">
699 <span class="collab-section-title-icon" aria-hidden="true">
700 <IconUsers />
701 </span>
702 Active &amp; pending
703 <span style="font-family:var(--font-mono);font-size:12px;color:var(--text-muted);font-weight:500;font-variant-numeric:tabular-nums">
704 {" "}({rows.length})
705 </span>
706 </h2>
707 <p class="collab-section-sub">
708 Click a name to view their profile. Removing a collaborator
709 revokes access immediately.
710 </p>
711 </div>
712 </header>
713 <div class="collab-section-body">
714 {rows.length === 0 ? (
715 <div class="collab-empty">
716 <div class="collab-empty-orb" aria-hidden="true" />
717 <div class="collab-empty-inner">
718 <div class="collab-empty-icon" aria-hidden="true">
719 <IconUsers />
23d1a81Claude720 </div>
17f6cacClaude721 <h3 class="collab-empty-title">Invite your first collaborator</h3>
722 <p class="collab-empty-sub">
723 Add a teammate above to grant them clone, push, or admin
724 access to this repository.
725 </p>
23d1a81Claude726 </div>
727 </div>
17f6cacClaude728 ) : (
729 <div class="collab-grid">
730 {rows.map((row) => {
731 const accepted = !!row.acceptedAt;
732 return (
733 <div class="collab-card">
734 <div class="collab-avatar" aria-hidden="true">
735 {row.avatarUrl ? (
736 <img src={row.avatarUrl} alt="" loading="lazy" />
737 ) : (
738 row.username[0]?.toUpperCase() ?? "?"
739 )}
740 </div>
741 <div class="collab-card-body">
742 <div class="collab-card-row">
743 <a href={`/${row.username}`} class="collab-card-name">
744 {row.username}
745 </a>
746 <span class={rolePillClass(row.role)}>
747 <span class="dot" aria-hidden="true" />
748 {roleLabel(row.role)}
749 </span>
750 </div>
751 <div class="collab-card-handle">@{row.username}</div>
752 <div class="collab-meta-row">
753 <span class={accepted ? "collab-pill is-accepted" : "collab-pill is-pending"}>
754 <span class="dot" aria-hidden="true" />
755 {accepted ? "Accepted" : "Pending"}
756 </span>
757 <span class="sep">·</span>
758 <span>Invited {shortDate(row.invitedAt)}</span>
759 {accepted && row.acceptedAt && (
760 <>
761 <span class="sep">·</span>
762 <span>Joined {shortDate(row.acceptedAt)}</span>
763 </>
764 )}
765 </div>
766 <div class="collab-card-actions">
767 <form
768 method="post"
769 action={`/${ownerName}/${repoName}/settings/collaborators/${row.id}/remove`}
770 onsubmit="return confirm('Remove this collaborator?')"
771 >
772 <button type="submit" class="collab-btn collab-btn-danger">
773 Remove access
774 </button>
775 </form>
776 </div>
777 </div>
778 </div>
779 );
780 })}
781 </div>
782 )}
23d1a81Claude783 </div>
17f6cacClaude784 </section>
785 </div>
786 <style dangerouslySetInnerHTML={{ __html: collabStyles }} />
23d1a81Claude787 </Layout>
788 );
789 }
790);
791
792// ─── Add collaborator ───────────────────────────────────────────────────────
793
794collaboratorRoutes.post(
795 "/:owner/:repo/settings/collaborators/add",
796 requireAuth,
797 async (c) => {
798 const { owner: ownerName, repo: repoName } = c.req.param();
799 const body = await c.req.parseBody();
800
801 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
802 if ("error" in resolved) return resolved.error;
803 const { repo, user } = resolved;
804
805 const username = String(body.username || "").trim();
806 const roleRaw = String(body.role || "read");
17f6cacClaude807 const role: CollabRole =
23d1a81Claude808 roleRaw === "write" || roleRaw === "admin" ? roleRaw : "read";
809
810 if (!username) {
811 return c.redirect(
812 `/${ownerName}/${repoName}/settings/collaborators?error=Username+required`
813 );
814 }
815
816 const [invitee] = await db
817 .select()
818 .from(users)
819 .where(eq(users.username, username))
820 .limit(1);
821 if (!invitee) {
822 return c.redirect(
823 `/${ownerName}/${repoName}/settings/collaborators?error=User+not+found`
824 );
825 }
826 if (invitee.id === user.id) {
827 return c.redirect(
828 `/${ownerName}/${repoName}/settings/collaborators?error=Owner+is+already+a+collaborator`
829 );
830 }
831
832 // If a row already exists for (repo, user), update the role instead of
833 // erroring. Mirrors the "upsert" contract so the owner can re-invite
834 // with a different role without first removing the prior row.
835 const [existing] = await db
836 .select()
837 .from(repoCollaborators)
838 .where(
839 and(
840 eq(repoCollaborators.repositoryId, repo.id),
841 eq(repoCollaborators.userId, invitee.id)
842 )
843 )
844 .limit(1);
845
846 if (existing) {
febd4f0Claude847 // Re-inviting an existing collaborator just updates the role. We don't
848 // re-issue a token here — if the prior invite hasn't been accepted the
849 // existing token is still valid; if it has, they're already in.
23d1a81Claude850 await db
851 .update(repoCollaborators)
febd4f0Claude852 .set({ role })
23d1a81Claude853 .where(eq(repoCollaborators.id, existing.id));
854 return c.redirect(
855 `/${ownerName}/${repoName}/settings/collaborators?success=Role+updated`
856 );
857 }
858
febd4f0Claude859 // Fresh invite: generate a single-use token, store only its hash, and
860 // email the plaintext to the invitee. acceptedAt stays NULL until they
861 // click through /invites/:token.
862 const token = generateInviteToken();
863 const tokenHash = hashInviteToken(token);
864
23d1a81Claude865 await db.insert(repoCollaborators).values({
866 repositoryId: repo.id,
867 userId: invitee.id,
868 role,
869 invitedBy: user.id,
febd4f0Claude870 inviteTokenHash: tokenHash,
23d1a81Claude871 });
872
febd4f0Claude873 // Email delivery degrades gracefully — a failed send should never block
874 // the invite row from existing. Owner can resend / share the URL by hand.
875 const inviteUrl = absoluteUrl(`/invites/${token}`);
876 try {
877 const result = await sendEmail({
878 to: invitee.email,
879 subject: `You've been invited to ${ownerName}/${repoName}`,
880 text: `You've been invited to ${ownerName}/${repoName}. Click: ${inviteUrl}`,
881 });
882 if (!result.ok) {
883 console.error(
884 `[collaborators] invite email send failed for ${invitee.username}:`,
885 result.error || result.skipped
886 );
887 }
888 } catch (err) {
889 console.error(
890 `[collaborators] invite email threw for ${invitee.username}:`,
891 err
892 );
893 }
894
23d1a81Claude895 return c.redirect(
febd4f0Claude896 `/${ownerName}/${repoName}/settings/collaborators?success=Invite+sent`
23d1a81Claude897 );
898 }
899);
900
901// ─── Remove collaborator ────────────────────────────────────────────────────
902
903collaboratorRoutes.post(
904 "/:owner/:repo/settings/collaborators/:collaboratorId/remove",
905 requireAuth,
906 async (c) => {
907 const { owner: ownerName, repo: repoName, collaboratorId } =
908 c.req.param();
909
910 const resolved = await resolveOwnerRepo(c, ownerName, repoName);
911 if ("error" in resolved) return resolved.error;
912 const { repo } = resolved;
913
914 // Scope the delete to this repo so an owner can't remove a collaborator
915 // from some other repo by crafting a URL.
916 await db
917 .delete(repoCollaborators)
918 .where(
919 and(
920 eq(repoCollaborators.id, collaboratorId),
921 eq(repoCollaborators.repositoryId, repo.id)
922 )
923 );
924
925 return c.redirect(
926 `/${ownerName}/${repoName}/settings/collaborators?success=Collaborator+removed`
927 );
928 }
929);
930
931export default collaboratorRoutes;