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.tsxBlame146 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:
9 * GET /invites/:token
10 * → hash the presented token, find the pending row, render "Accept"
11 * POST /invites/:token
12 * → same lookup, assert the invite is for the authed user, flip
13 * `acceptedAt` to now() and null the hash so the link is one-shot.
14 * Redirect to /:owner/:repo on success.
15 *
16 * Not-found / already-accepted / wrong-user paths all degrade safely (404 /
17 * 403) without leaking which of those branches triggered.
18 */
19
20import { Hono } from "hono";
21import { eq, and } from "drizzle-orm";
22import { db } from "../db";
23import { repositories, users, repoCollaborators } from "../db/schema";
24import { Layout } from "../views/layout";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { Container, Form, Button, EmptyState, Alert } from "../views/ui";
28import { hashInviteToken } from "../lib/invite-tokens";
29
30const inviteRoutes = new Hono<AuthEnv>();
31
32inviteRoutes.use("*", softAuth);
33
34/**
35 * Resolve the pending invite by token hash + join repo/owner for display.
36 * Returns null for not-found, already-accepted, or DB errors — the caller
37 * surfaces a single 404 in all cases so we don't leak invite existence.
38 */
39async function resolvePendingInvite(token: string) {
40 if (!token) return null;
41 let hash: string;
42 try {
43 hash = hashInviteToken(token);
44 } catch {
45 return null;
46 }
47 try {
48 const [row] = await db
49 .select({
50 id: repoCollaborators.id,
51 userId: repoCollaborators.userId,
52 acceptedAt: repoCollaborators.acceptedAt,
53 inviteTokenHash: repoCollaborators.inviteTokenHash,
54 repositoryId: repoCollaborators.repositoryId,
55 role: repoCollaborators.role,
56 repoName: repositories.name,
57 ownerId: repositories.ownerId,
58 })
59 .from(repoCollaborators)
60 .innerJoin(
61 repositories,
62 eq(repositories.id, repoCollaborators.repositoryId)
63 )
64 .where(eq(repoCollaborators.inviteTokenHash, hash))
65 .limit(1);
66 if (!row) return null;
67 if (row.acceptedAt) return null;
68 const [owner] = await db
69 .select({ username: users.username })
70 .from(users)
71 .where(eq(users.id, row.ownerId))
72 .limit(1);
73 if (!owner) return null;
74 return { ...row, ownerName: owner.username };
75 } catch {
76 return null;
77 }
78}
79
80// ─── Display accept page ────────────────────────────────────────────────────
81
82inviteRoutes.get("/invites/:token", async (c) => {
83 const { token } = c.req.param();
84 const user = c.get("user");
85 const invite = await resolvePendingInvite(token);
86 if (!invite) return c.notFound();
87
88 return c.html(
89 <Layout title="Accept invitation" user={user}>
90 <Container maxWidth={600}>
91 <h2 style="margin-bottom: 16px">
92 Accept invitation to {invite.ownerName}/{invite.repoName}
93 </h2>
94 <p style="color:var(--text-muted);margin-bottom:24px">
95 You've been invited as a <strong>{invite.role}</strong> collaborator
96 on this repository.
97 </p>
98 {!user && (
99 <Alert variant="info">
100 You need to{" "}
101 <a href={`/login?next=/invites/${token}`}>sign in</a> before
102 accepting this invitation.
103 </Alert>
104 )}
105 {user && (
106 <Form method="post" action={`/invites/${token}`}>
107 <Button type="submit" variant="primary">
108 Accept invitation
109 </Button>
110 </Form>
111 )}
112 </Container>
113 </Layout>
114 );
115});
116
117// ─── Accept (POST) ──────────────────────────────────────────────────────────
118
119inviteRoutes.post("/invites/:token", requireAuth, async (c) => {
120 const { token } = c.req.param();
121 const user = c.get("user")!;
122 const invite = await resolvePendingInvite(token);
123 if (!invite) return c.notFound();
124
125 // The invite is bound to a specific user at creation time — reject if
126 // someone else is clicking the link from a shared inbox.
127 if (invite.userId !== user.id) {
128 return c.html(
129 <Layout title="Forbidden" user={user}>
130 <EmptyState title="Not your invitation">
131 <p>This invitation was sent to a different account.</p>
132 </EmptyState>
133 </Layout>,
134 403
135 );
136 }
137
138 await db
139 .update(repoCollaborators)
140 .set({ acceptedAt: new Date(), inviteTokenHash: null })
141 .where(eq(repoCollaborators.id, invite.id));
142
143 return c.redirect(`/${invite.ownerName}/${invite.repoName}`);
144});
145
146export default inviteRoutes;