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

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

invites.tsxBlame802 lines · 1 contributor
febd4f0Claude1/**
2 * Collaborator invite acceptance — the flip side of POST /add.
3 *
4 * When an owner invites a user, `src/routes/collaborators.tsx` generates a
5 * random token, stores its sha256 on the `repo_collaborators` row, and
6 * emails the plaintext link. This file handles that link being clicked.
7 *
8 * Flow:
7581253Claude9 * GET /invites → list of pending repo + org invites for
10 * the signed-in user. The repo invites
11 * section can only display rows the user
12 * can prove they own (we don't store the
13 * plaintext token, so direct accept is
14 * still link-based). Org invites accept
15 * inline because `org_members` rows are
16 * keyed on the user directly.
17 * GET /invites/:token → accept page for a specific token
18 * POST /invites/:token → accept the invite (one-shot)
febd4f0Claude19 *
7581253Claude20 * 2026 polish: scoped `.inv-*` classes mirror the gradient-hairline hero +
21 * card patterns from settings-2fa.tsx. Every existing token flow, ownership
22 * assertion, and one-shot accept guarantee is preserved verbatim.
febd4f0Claude23 */
24
25import { Hono } from "hono";
7581253Claude26import { eq, and, isNull, asc } from "drizzle-orm";
febd4f0Claude27import { db } from "../db";
28import { repositories, users, repoCollaborators } from "../db/schema";
29import { Layout } from "../views/layout";
30import { softAuth, requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import { hashInviteToken } from "../lib/invite-tokens";
33
34const inviteRoutes = new Hono<AuthEnv>();
35
36inviteRoutes.use("*", softAuth);
37
7581253Claude38// ─── Scoped CSS (.inv-*) ────────────────────────────────────────────────────
39const invStyles = `
eed4684Claude40 .inv-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
7581253Claude41
42 .inv-hero {
43 position: relative;
44 margin-bottom: var(--space-5);
45 padding: var(--space-5) var(--space-6);
46 background: var(--bg-elevated);
47 border: 1px solid var(--border);
48 border-radius: 16px;
49 overflow: hidden;
50 }
51 .inv-hero::before {
52 content: '';
53 position: absolute;
54 top: 0; left: 0; right: 0;
55 height: 2px;
6fd5915Claude56 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude57 opacity: 0.7;
58 pointer-events: none;
59 }
60 .inv-hero-orb {
61 position: absolute;
62 inset: -20% -10% auto auto;
63 width: 380px; height: 380px;
6fd5915Claude64 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
7581253Claude65 filter: blur(80px);
66 opacity: 0.7;
67 pointer-events: none;
68 z-index: 0;
69 }
70 .inv-hero-inner { position: relative; z-index: 1; max-width: 720px; }
71 .inv-eyebrow {
72 font-size: 12px;
73 color: var(--text-muted);
74 margin-bottom: var(--space-2);
75 letter-spacing: 0.02em;
76 display: inline-flex;
77 align-items: center;
78 gap: 8px;
79 text-transform: uppercase;
80 font-family: var(--font-mono);
81 font-weight: 600;
82 }
83 .inv-eyebrow-pill {
84 display: inline-flex;
85 align-items: center;
86 justify-content: center;
87 width: 18px; height: 18px;
88 border-radius: 6px;
6fd5915Claude89 background: rgba(91,110,232,0.14);
90 color: #5b6ee8;
91 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
7581253Claude92 }
93 .inv-title {
94 font-size: clamp(28px, 4vw, 40px);
95 font-family: var(--font-display);
96 font-weight: 800;
97 letter-spacing: -0.028em;
98 line-height: 1.05;
99 margin: 0 0 var(--space-2);
100 color: var(--text-strong);
101 }
102 .inv-title-grad {
6fd5915Claude103 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7581253Claude104 -webkit-background-clip: text;
105 background-clip: text;
106 -webkit-text-fill-color: transparent;
107 color: transparent;
108 }
109 .inv-sub {
110 font-size: 15px;
111 color: var(--text-muted);
112 margin: 0;
113 line-height: 1.55;
114 max-width: 620px;
115 }
116
117 /* ─── Banner ─── */
118 .inv-banner {
119 margin-bottom: var(--space-4);
120 padding: 10px 14px;
121 border-radius: 10px;
122 font-size: 13.5px;
123 border: 1px solid var(--border);
124 background: rgba(255,255,255,0.025);
125 color: var(--text);
126 display: flex;
127 align-items: center;
128 gap: 10px;
129 }
130 .inv-banner.is-error {
131 border-color: rgba(248,113,113,0.40);
132 background: rgba(248,113,113,0.08);
133 color: #fecaca;
134 }
135 .inv-banner.is-info {
6fd5915Claude136 border-color: rgba(95,143,160,0.40);
137 background: rgba(95,143,160,0.08);
7581253Claude138 color: #a5f3fc;
139 }
140 .inv-banner-dot {
141 width: 8px; height: 8px;
142 border-radius: 9999px;
143 background: currentColor;
144 flex-shrink: 0;
145 }
146
147 /* ─── Section card ─── */
148 .inv-section {
149 margin-bottom: var(--space-5);
150 background: var(--bg-elevated);
151 border: 1px solid var(--border);
152 border-radius: 14px;
153 overflow: hidden;
154 }
155 .inv-section-head {
156 padding: var(--space-4) var(--space-5);
157 border-bottom: 1px solid var(--border);
158 }
159 .inv-section-title {
160 margin: 0;
161 font-family: var(--font-display);
162 font-size: 17px;
163 font-weight: 700;
164 letter-spacing: -0.018em;
165 color: var(--text-strong);
166 display: flex;
167 align-items: center;
168 gap: 10px;
169 }
170 .inv-section-title-icon {
171 display: inline-flex;
172 align-items: center;
173 justify-content: center;
174 width: 26px; height: 26px;
175 border-radius: 8px;
6fd5915Claude176 background: rgba(91,110,232,0.12);
177 color: #5b6ee8;
178 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
7581253Claude179 flex-shrink: 0;
180 }
181 .inv-section-sub {
182 margin: 6px 0 0 36px;
183 font-size: 12.5px;
184 color: var(--text-muted);
185 line-height: 1.5;
186 }
187 .inv-section-body { padding: var(--space-4) var(--space-5); }
188
189 /* ─── Invite card ─── */
190 .inv-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-3); }
191 .inv-card {
192 display: flex;
193 gap: var(--space-3);
194 align-items: center;
195 padding: var(--space-4);
196 background: var(--bg);
197 border: 1px solid var(--border-strong);
198 border-radius: 12px;
199 transition: border-color 120ms ease, box-shadow 120ms ease;
200 }
201 .inv-card:hover {
6fd5915Claude202 border-color: rgba(91,110,232,0.32);
7581253Claude203 box-shadow: 0 8px 24px -10px rgba(0,0,0,0.32);
204 }
205 .inv-logo {
206 flex-shrink: 0;
207 width: 44px;
208 height: 44px;
209 border-radius: 10px;
6fd5915Claude210 background: linear-gradient(135deg, rgba(91,110,232,0.22), rgba(95,143,160,0.16));
211 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
7581253Claude212 display: flex;
213 align-items: center;
214 justify-content: center;
215 font-family: var(--font-display);
216 font-size: 18px;
217 font-weight: 800;
218 color: #e9d5ff;
219 text-transform: uppercase;
220 }
221 .inv-card-body { flex: 1; min-width: 0; }
222 .inv-card-title {
223 margin: 0;
224 font-family: var(--font-display);
225 font-size: 15px;
226 font-weight: 700;
227 letter-spacing: -0.012em;
228 color: var(--text-strong);
229 }
230 .inv-card-title a { color: inherit; text-decoration: none; }
231 .inv-card-title a:hover { color: var(--accent); }
232 .inv-card-meta {
233 margin: 4px 0 0;
234 font-size: 12.5px;
235 color: var(--text-muted);
236 display: flex;
237 gap: 6px;
238 flex-wrap: wrap;
239 align-items: center;
240 }
241 .inv-pill {
242 display: inline-flex;
243 align-items: center;
244 gap: 6px;
245 padding: 2px 9px;
246 border-radius: 9999px;
247 font-size: 10.5px;
248 font-weight: 700;
249 letter-spacing: 0.05em;
250 text-transform: uppercase;
6fd5915Claude251 background: rgba(91,110,232,0.14);
7581253Claude252 color: #c4b5fd;
6fd5915Claude253 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
7581253Claude254 }
255 .inv-pill .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
256 .inv-card-actions { flex-shrink: 0; display: flex; gap: 8px; flex-wrap: wrap; }
257
258 /* ─── Buttons ─── */
259 .inv-btn {
260 display: inline-flex;
261 align-items: center;
262 justify-content: center;
263 gap: 6px;
264 padding: 9px 16px;
265 border-radius: 10px;
266 font-size: 13px;
267 font-weight: 600;
268 text-decoration: none;
269 border: 1px solid transparent;
270 cursor: pointer;
271 font-family: inherit;
272 line-height: 1;
273 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
274 }
275 .inv-btn-primary {
6fd5915Claude276 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
7581253Claude277 color: #fff;
6fd5915Claude278 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
7581253Claude279 }
280 .inv-btn-primary:hover {
281 transform: translateY(-1px);
6fd5915Claude282 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
7581253Claude283 color: #fff;
284 text-decoration: none;
285 }
286 .inv-btn-ghost {
287 background: rgba(255,255,255,0.025);
288 color: var(--text);
289 border-color: var(--border-strong);
290 }
291 .inv-btn-ghost:hover {
6fd5915Claude292 background: rgba(91,110,232,0.06);
293 border-color: rgba(91,110,232,0.45);
7581253Claude294 color: var(--text-strong);
295 text-decoration: none;
296 }
297 .inv-btn-danger {
298 background: transparent;
299 color: #fecaca;
300 border-color: rgba(248,113,113,0.40);
301 }
302 .inv-btn-danger:hover {
303 background: rgba(248,113,113,0.10);
304 border-color: rgba(248,113,113,0.65);
305 color: #fee2e2;
306 }
307
308 /* ─── Empty state ─── */
309 .inv-empty {
310 position: relative;
311 padding: 56px 32px;
312 background: var(--bg-elevated);
313 border: 1px solid var(--border);
314 border-radius: 16px;
315 text-align: center;
316 overflow: hidden;
317 }
318 .inv-empty::before {
319 content: '';
320 position: absolute;
321 top: 0; left: 0; right: 0;
322 height: 2px;
6fd5915Claude323 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude324 opacity: 0.55;
325 pointer-events: none;
326 }
327 .inv-empty-orb {
328 width: 96px; height: 96px;
329 margin: 0 auto 18px;
330 border-radius: 9999px;
331 background:
6fd5915Claude332 radial-gradient(circle at 35% 35%, rgba(91,110,232,0.55), rgba(95,143,160,0.25) 55%, transparent 75%);
7581253Claude333 box-shadow:
6fd5915Claude334 0 0 32px rgba(91,110,232,0.35),
335 inset 0 0 0 1px rgba(91,110,232,0.35);
7581253Claude336 display: flex;
337 align-items: center;
338 justify-content: center;
339 color: #fff;
340 }
341 .inv-empty-title {
342 font-family: var(--font-display);
343 font-size: 22px;
344 font-weight: 700;
345 letter-spacing: -0.018em;
346 color: var(--text-strong);
347 margin: 0 0 8px;
348 }
349 .inv-empty-sub {
350 font-size: 14.5px;
351 color: var(--text-muted);
352 line-height: 1.55;
353 margin: 0 auto 18px;
354 max-width: 480px;
355 }
356`;
357
358const InvIcon = () => (
359 <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">
360 <path d="M21 8V7l-3 2-3-2v1l3 2 3-2z" />
361 <path d="M22 6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6z" />
362 <polyline points="22,6 12,13 2,6" />
363 </svg>
364);
365const InvRepoIcon = () => (
366 <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">
367 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
368 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
369 </svg>
370);
371const InvEmptyIcon = () => (
372 <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
373 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
374 <polyline points="22,6 12,13 2,6" />
375 </svg>
376);
377
febd4f0Claude378/**
379 * Resolve the pending invite by token hash + join repo/owner for display.
380 * Returns null for not-found, already-accepted, or DB errors — the caller
381 * surfaces a single 404 in all cases so we don't leak invite existence.
382 */
383async function resolvePendingInvite(token: string) {
384 if (!token) return null;
385 let hash: string;
386 try {
387 hash = hashInviteToken(token);
388 } catch {
389 return null;
390 }
391 try {
392 const [row] = await db
393 .select({
394 id: repoCollaborators.id,
395 userId: repoCollaborators.userId,
396 acceptedAt: repoCollaborators.acceptedAt,
397 inviteTokenHash: repoCollaborators.inviteTokenHash,
398 repositoryId: repoCollaborators.repositoryId,
399 role: repoCollaborators.role,
400 repoName: repositories.name,
401 ownerId: repositories.ownerId,
402 })
403 .from(repoCollaborators)
404 .innerJoin(
405 repositories,
406 eq(repositories.id, repoCollaborators.repositoryId)
407 )
408 .where(eq(repoCollaborators.inviteTokenHash, hash))
409 .limit(1);
410 if (!row) return null;
411 if (row.acceptedAt) return null;
412 const [owner] = await db
413 .select({ username: users.username })
414 .from(users)
415 .where(eq(users.id, row.ownerId))
416 .limit(1);
417 if (!owner) return null;
418 return { ...row, ownerName: owner.username };
419 } catch {
420 return null;
421 }
422}
423
7581253Claude424// ─── List pending invites for the current user ─────────────────────────────
425
426inviteRoutes.get("/invites", requireAuth, async (c) => {
427 const user = c.get("user")!;
428 const declined = c.req.query("declined");
429
430 type RepoInvite = {
431 id: string;
432 role: string;
433 repoName: string;
434 ownerName: string;
435 };
436 let repoInvites: RepoInvite[] = [];
437 try {
438 const rows = await db
439 .select({
440 id: repoCollaborators.id,
441 role: repoCollaborators.role,
442 repoName: repositories.name,
443 ownerId: repositories.ownerId,
444 })
445 .from(repoCollaborators)
446 .innerJoin(
447 repositories,
448 eq(repositories.id, repoCollaborators.repositoryId)
449 )
450 .where(
451 and(
452 eq(repoCollaborators.userId, user.id),
453 isNull(repoCollaborators.acceptedAt)
454 )
455 )
456 .orderBy(asc(repositories.name));
457
458 // Resolve owner usernames in a single batch.
459 const ownerIds = Array.from(new Set(rows.map((r) => r.ownerId)));
460 const ownerByIdEntries = await Promise.all(
461 ownerIds.map(async (id) => {
462 const [u] = await db
463 .select({ username: users.username })
464 .from(users)
465 .where(eq(users.id, id))
466 .limit(1);
467 return [id, u?.username || "unknown"] as const;
468 })
469 );
470 const ownerById = new Map(ownerByIdEntries);
471 repoInvites = rows.map((r) => ({
472 id: r.id,
473 role: r.role,
474 repoName: r.repoName,
475 ownerName: ownerById.get(r.ownerId) || "unknown",
476 }));
477 } catch (err) {
478 console.error("[invites] list:", err);
479 }
480
481 const totalCount = repoInvites.length;
482
483 return c.html(
484 <Layout title="Your invitations" user={user}>
485 <div class="inv-wrap">
486 <section class="inv-hero">
487 <div class="inv-hero-orb" aria-hidden="true" />
488 <div class="inv-hero-inner">
489 <div class="inv-eyebrow">
490 <span class="inv-eyebrow-pill" aria-hidden="true">
491 <InvIcon />
492 </span>
493 <span>Invitations</span>
494 <span>·</span>
495 <span>@{user.username}</span>
496 </div>
497 <h2 class="inv-title">
498 <span class="inv-title-grad">Your invitations.</span>
499 </h2>
500 <p class="inv-sub">
501 Repositories you've been invited to collaborate on. Accept to
502 gain access, or decline to clear the row — declined invites can
503 be re-sent by the repo owner.
504 </p>
505 </div>
506 </section>
507
508 {declined && (
509 <div class="inv-banner is-info" role="status">
510 <span class="inv-banner-dot" aria-hidden="true" />
511 Invitation declined.
512 </div>
513 )}
514
515 {totalCount === 0 ? (
516 <div class="inv-empty">
517 <div class="inv-empty-orb" aria-hidden="true">
518 <InvEmptyIcon />
519 </div>
520 <h2 class="inv-empty-title">No pending invitations</h2>
521 <p class="inv-empty-sub">
522 When someone invites you as a collaborator on a repository or
523 organization, the invite will show up here. You can also accept
524 directly from the email link you receive.
525 </p>
526 <a href="/explore" class="inv-btn inv-btn-primary">
527 Explore public repositories
528 </a>
529 </div>
530 ) : (
531 <section class="inv-section">
532 <header class="inv-section-head">
533 <h3 class="inv-section-title">
534 <span class="inv-section-title-icon" aria-hidden="true">
535 <InvRepoIcon />
536 </span>
537 Repository invitations ({totalCount})
538 </h3>
539 <p class="inv-section-sub">
540 Each invite is tied to a single repository and role. Declining
541 removes the invite row but doesn't block future invitations.
542 </p>
543 </header>
544 <div class="inv-section-body">
545 <ul class="inv-list">
546 {repoInvites.map((inv) => {
547 const initial = (inv.repoName.charAt(0) || "?").toUpperCase();
548 return (
549 <li>
550 <div class="inv-card">
551 <div class="inv-logo" aria-hidden="true">{initial}</div>
552 <div class="inv-card-body">
553 <h4 class="inv-card-title">
554 <a href={`/${inv.ownerName}/${inv.repoName}`}>
555 {inv.ownerName}/{inv.repoName}
556 </a>
557 </h4>
558 <div class="inv-card-meta">
559 <span class="inv-pill">
560 <span class="dot" aria-hidden="true" />
561 {inv.role}
562 </span>
563 <span>invited by {inv.ownerName}</span>
564 </div>
565 </div>
566 <div class="inv-card-actions">
567 <form
568 method="post"
569 action={`/invites/repo/${inv.id}/accept`}
570 style="margin:0"
571 >
572 <button type="submit" class="inv-btn inv-btn-primary">
573 Accept
574 </button>
575 </form>
576 <form
577 method="post"
578 action={`/invites/repo/${inv.id}/decline`}
579 style="margin:0"
580 onsubmit="return confirm('Decline this invitation?')"
581 >
582 <button type="submit" class="inv-btn inv-btn-ghost">
583 Decline
584 </button>
585 </form>
586 </div>
587 </div>
588 </li>
589 );
590 })}
591 </ul>
592 </div>
593 </section>
594 )}
595 </div>
596 <style dangerouslySetInnerHTML={{ __html: invStyles }} />
597 </Layout>
598 );
599});
600
601// ─── Accept / decline by row id (inline list flow) ─────────────────────────
602//
603// These complement the original /invites/:token email flow: when the user is
604// looking at the dashboard list, they already passed authn, so we can act
605// directly on `repo_collaborators.id` instead of round-tripping a one-time
606// token. We still require the invite to be (a) unaccepted and (b) addressed
607// to the signed-in user, otherwise we 404.
608
609inviteRoutes.post("/invites/repo/:id/accept", requireAuth, async (c) => {
610 const user = c.get("user")!;
611 const id = c.req.param("id");
612 try {
613 const [row] = await db
614 .select({
615 id: repoCollaborators.id,
616 userId: repoCollaborators.userId,
617 acceptedAt: repoCollaborators.acceptedAt,
618 repositoryId: repoCollaborators.repositoryId,
619 })
620 .from(repoCollaborators)
621 .where(eq(repoCollaborators.id, id))
622 .limit(1);
623 if (!row || row.userId !== user.id || row.acceptedAt) {
624 return c.notFound();
625 }
626 await db
627 .update(repoCollaborators)
628 .set({ acceptedAt: new Date(), inviteTokenHash: null })
629 .where(eq(repoCollaborators.id, row.id));
630
631 const [repo] = await db
632 .select({ name: repositories.name, ownerId: repositories.ownerId })
633 .from(repositories)
634 .where(eq(repositories.id, row.repositoryId))
635 .limit(1);
636 if (!repo) return c.redirect("/invites");
637 const [owner] = await db
638 .select({ username: users.username })
639 .from(users)
640 .where(eq(users.id, repo.ownerId))
641 .limit(1);
642 if (!owner) return c.redirect("/invites");
643 return c.redirect(`/${owner.username}/${repo.name}`);
644 } catch (err) {
645 console.error("[invites] accept:", err);
646 return c.redirect("/invites");
647 }
648});
649
650inviteRoutes.post("/invites/repo/:id/decline", requireAuth, async (c) => {
651 const user = c.get("user")!;
652 const id = c.req.param("id");
653 try {
654 const [row] = await db
655 .select({
656 id: repoCollaborators.id,
657 userId: repoCollaborators.userId,
658 acceptedAt: repoCollaborators.acceptedAt,
659 })
660 .from(repoCollaborators)
661 .where(eq(repoCollaborators.id, id))
662 .limit(1);
663 if (!row || row.userId !== user.id || row.acceptedAt) {
664 return c.notFound();
665 }
666 await db
667 .delete(repoCollaborators)
668 .where(eq(repoCollaborators.id, row.id));
669 } catch (err) {
670 console.error("[invites] decline:", err);
671 }
672 return c.redirect("/invites?declined=1");
673});
674
675// ─── Display accept page (single-token email link) ─────────────────────────
febd4f0Claude676
677inviteRoutes.get("/invites/:token", async (c) => {
678 const { token } = c.req.param();
679 const user = c.get("user");
680 const invite = await resolvePendingInvite(token);
681 if (!invite) return c.notFound();
682
683 return c.html(
684 <Layout title="Accept invitation" user={user}>
7581253Claude685 <div class="inv-wrap">
686 <section class="inv-hero">
687 <div class="inv-hero-orb" aria-hidden="true" />
688 <div class="inv-hero-inner">
689 <div class="inv-eyebrow">
690 <span class="inv-eyebrow-pill" aria-hidden="true">
691 <InvIcon />
692 </span>
693 <a href="/invites" style="color:var(--text-muted);text-decoration:none">
694 Invitations
695 </a>
696 <span>/</span>
697 <span>Accept</span>
698 </div>
699 <h2 class="inv-title">
700 <span class="inv-title-grad">You've been invited.</span>
701 </h2>
702 <p class="inv-sub">
703 {invite.ownerName} invited you to collaborate on{" "}
704 <strong>{invite.ownerName}/{invite.repoName}</strong> as a{" "}
705 <strong>{invite.role}</strong>. Accepting grants you access
706 immediately; the link is one-shot.
707 </p>
708 </div>
709 </section>
710
febd4f0Claude711 {!user && (
7581253Claude712 <div class="inv-banner is-info" role="status">
713 <span class="inv-banner-dot" aria-hidden="true" />
febd4f0Claude714 You need to{" "}
7581253Claude715 <a href={`/login?next=/invites/${token}`} style="color:inherit;text-decoration:underline">
716 sign in
717 </a>{" "}
718 before accepting this invitation.
719 </div>
febd4f0Claude720 )}
7581253Claude721
722 <section class="inv-section">
723 <header class="inv-section-head">
724 <h3 class="inv-section-title">
725 <span class="inv-section-title-icon" aria-hidden="true">
726 <InvRepoIcon />
727 </span>
728 {invite.ownerName}/{invite.repoName}
729 </h3>
730 <p class="inv-section-sub">
731 Role on accept: <strong>{invite.role}</strong>.
732 </p>
733 </header>
734 <div class="inv-section-body">
735 {user ? (
736 <form method="post" action={`/invites/${token}`} style="margin:0">
737 <button type="submit" class="inv-btn inv-btn-primary">
738 Accept invitation
739 </button>
740 </form>
741 ) : (
742 <a href={`/login?next=/invites/${token}`} class="inv-btn inv-btn-primary">
743 Sign in to accept
744 </a>
745 )}
746 </div>
747 </section>
748 </div>
749 <style dangerouslySetInnerHTML={{ __html: invStyles }} />
febd4f0Claude750 </Layout>
751 );
752});
753
754// ─── Accept (POST) ──────────────────────────────────────────────────────────
755
756inviteRoutes.post("/invites/:token", requireAuth, async (c) => {
757 const { token } = c.req.param();
758 const user = c.get("user")!;
759 const invite = await resolvePendingInvite(token);
760 if (!invite) return c.notFound();
761
762 // The invite is bound to a specific user at creation time — reject if
763 // someone else is clicking the link from a shared inbox.
764 if (invite.userId !== user.id) {
765 return c.html(
766 <Layout title="Forbidden" user={user}>
7581253Claude767 <div class="inv-wrap">
768 <section class="inv-hero">
769 <div class="inv-hero-orb" aria-hidden="true" />
770 <div class="inv-hero-inner">
771 <div class="inv-eyebrow">
772 <span class="inv-eyebrow-pill" aria-hidden="true">
773 <InvIcon />
774 </span>
775 <span>Invitations</span>
776 </div>
777 <h2 class="inv-title">
778 <span class="inv-title-grad">Not your invitation.</span>
779 </h2>
780 <p class="inv-sub">
781 This invitation was sent to a different account. Sign in as
782 the addressee to accept, or ask the inviter for a fresh link.
783 </p>
784 </div>
785 </section>
786 <a href="/invites" class="inv-btn inv-btn-primary">View your invitations</a>
787 </div>
788 <style dangerouslySetInnerHTML={{ __html: invStyles }} />
febd4f0Claude789 </Layout>,
790 403
791 );
792 }
793
794 await db
795 .update(repoCollaborators)
796 .set({ acceptedAt: new Date(), inviteTokenHash: null })
797 .where(eq(repoCollaborators.id, invite.id));
798
799 return c.redirect(`/${invite.ownerName}/${invite.repoName}`);
800});
801
802export default inviteRoutes;