Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

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

orgs.tsxBlame565 lines · 1 contributor
59b6fb2Claude1/**
2 * Organization and team routes — create orgs, manage members, teams, permissions.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import { organizations, orgMembers, teams, teamMembers, teamRepos } from "../db/schema-extensions";
9import { users, repositories } from "../db/schema";
10import { Layout } from "../views/layout";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13
14const orgRoutes = new Hono<AuthEnv>();
15
16// ─── Organization List / Create ─────────────────────────────────────────────
17
18orgRoutes.get("/orgs/new", softAuth, requireAuth, (c) => {
19 const user = c.get("user")!;
20 const error = c.req.query("error");
21
22 return c.html(
23 <Layout title="New Organization" user={user}>
24 <div style="max-width:500px">
25 <h2 style="margin-bottom:16px">Create a new organization</h2>
26 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
27 <form method="post" action="/orgs/new">
28 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
29 <div class="form-group">
30 <label for="name">Organization name</label>
31 <input type="text" id="name" name="name" required pattern="^[a-zA-Z0-9._-]+$" placeholder="my-org" autocomplete="off" />
32 <span style="font-size:12px;color:var(--text-muted)">Letters, numbers, hyphens, dots, underscores only</span>
33 </div>
34 <div class="form-group">
35 <label for="displayName">Display name</label>
36 <input type="text" id="displayName" name="displayName" placeholder="My Organization" />
37 </div>
38 <div class="form-group">
39 <label for="description">Description</label>
40 <textarea id="description" name="description" rows={3} placeholder="What does this organization do?" />
41 </div>
42 <div class="form-group">
43 <label for="website">Website</label>
44 <input type="url" id="website" name="website" placeholder="https://example.com" />
45 </div>
46 <button type="submit" class="btn btn-primary">Create organization</button>
47 </form>
48 </div>
49 </Layout>
50 );
51});
52
53orgRoutes.post("/orgs/new", softAuth, requireAuth, async (c) => {
54 const user = c.get("user")!;
55 const body = await c.req.parseBody();
56 const name = String(body.name || "").trim();
57 const displayName = String(body.displayName || "").trim();
58 const description = String(body.description || "").trim();
59 const website = String(body.website || "").trim();
60
61 if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
62 return c.redirect("/orgs/new?error=Invalid+organization+name");
63 }
64
65 try {
66 // Check if name is taken (by user or org)
67 const [existingUser] = await db.select().from(users).where(eq(users.username, name)).limit(1);
68 if (existingUser) {
69 return c.redirect("/orgs/new?error=Name+already+taken");
70 }
71
72 const [existingOrg] = await db.select().from(organizations).where(eq(organizations.name, name)).limit(1);
73 if (existingOrg) {
74 return c.redirect("/orgs/new?error=Organization+already+exists");
75 }
76
77 const [org] = await db
78 .insert(organizations)
79 .values({
80 name,
81 displayName: displayName || name,
82 description: description || null,
83 website: website || null,
84 })
85 .returning();
86
87 // Add creator as owner
88 await db.insert(orgMembers).values({
89 orgId: org.id,
90 userId: user.id,
91 role: "owner",
92 });
93
94 return c.redirect(`/orgs/${name}`);
95 } catch (err: any) {
96 return c.redirect(`/orgs/new?error=${encodeURIComponent(err.message || "Failed to create organization")}`);
97 }
98});
99
100// ─── Organization Profile ───────────────────────────────────────────────────
101
102orgRoutes.get("/orgs/:org", softAuth, async (c) => {
103 const orgName = c.req.param("org");
104 const user = c.get("user");
105
106 let org: any;
107 try {
108 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
109 org = found;
110 } catch {
111 return c.notFound();
112 }
113
114 if (!org) return c.notFound();
115
116 // Get members
117 let members: any[] = [];
118 try {
119 members = await db
120 .select({ member: orgMembers, user: { username: users.username, displayName: users.displayName } })
121 .from(orgMembers)
122 .innerJoin(users, eq(orgMembers.userId, users.id))
123 .where(eq(orgMembers.orgId, org.id))
124 .orderBy(asc(orgMembers.role));
125 } catch {
126 // Table may not exist
127 }
128
129 // Get teams
130 let teamList: any[] = [];
131 try {
132 teamList = await db.select().from(teams).where(eq(teams.orgId, org.id)).orderBy(asc(teams.name));
133 } catch {
134 // Table may not exist
135 }
136
137 const isMember = user && members.some((m: any) => m.member.userId === user.id);
138 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
139
140 return c.html(
141 <Layout title={org.displayName || org.name} user={user}>
142 <div style="max-width:900px">
143 <div style="display:flex;gap:24px;margin-bottom:32px">
144 <div class="user-avatar" style="width:80px;height:80px;font-size:32px">
145 {(org.displayName || org.name)[0].toUpperCase()}
146 </div>
147 <div>
148 <h2>{org.displayName || org.name}</h2>
149 <div style="font-size:14px;color:var(--text-muted)">@{org.name}</div>
150 {org.description && <p style="margin-top:8px;font-size:14px;color:var(--text-muted)">{org.description}</p>}
151 {org.website && (
152 <a href={org.website} style="font-size:13px" target="_blank" rel="noopener noreferrer">
153 {org.website}
154 </a>
155 )}
156 </div>
157 {isOwner && (
158 <div style="margin-left:auto">
159 <a href={`/orgs/${org.name}/settings`} class="btn btn-sm">Settings</a>
160 </div>
161 )}
162 </div>
163
164 <div style="display:grid;grid-template-columns:1fr 300px;gap:32px">
165 <div>
166 <h3 style="margin-bottom:16px">Teams</h3>
167 {teamList.length === 0 ? (
168 <div class="empty-state" style="padding:24px">
169 <p>No teams yet.</p>
170 {isOwner && <a href={`/orgs/${org.name}/teams/new`} class="btn btn-sm btn-primary" style="margin-top:8px">Create a team</a>}
171 </div>
172 ) : (
173 <div class="issue-list">
174 {teamList.map((team: any) => (
175 <div class="issue-item">
176 <div>
177 <div style="font-weight:500;font-size:15px">
178 <a href={`/orgs/${org.name}/teams/${team.name}`} style="color:var(--text)">{team.name}</a>
179 </div>
180 {team.description && <div style="font-size:13px;color:var(--text-muted)">{team.description}</div>}
181 </div>
182 <span class="badge">{team.permission}</span>
183 </div>
184 ))}
185 </div>
186 )}
187 {isOwner && teamList.length > 0 && (
188 <a href={`/orgs/${org.name}/teams/new`} class="btn btn-sm btn-primary" style="margin-top:12px">Create team</a>
189 )}
190 </div>
191
192 <div>
193 <h3 style="margin-bottom:16px">Members ({members.length})</h3>
194 <div style="display:flex;flex-direction:column;gap:8px">
195 {members.map((m: any) => (
196 <div style="display:flex;align-items:center;gap:8px;padding:8px;border:1px solid var(--border);border-radius:var(--radius)">
197 <div class="user-avatar" style="width:32px;height:32px;font-size:14px">
198 {(m.user.displayName || m.user.username)[0].toUpperCase()}
199 </div>
200 <div style="flex:1">
201 <a href={`/${m.user.username}`} style="font-size:14px;font-weight:500">{m.user.username}</a>
202 </div>
203 <span class="badge" style="font-size:11px">{m.member.role}</span>
204 </div>
205 ))}
206 </div>
207 {isOwner && (
208 <a href={`/orgs/${org.name}/members/invite`} class="btn btn-sm" style="margin-top:12px;width:100%;text-align:center">
209 Invite member
210 </a>
211 )}
212 </div>
213 </div>
214 </div>
215 </Layout>
216 );
217});
218
219// ─── Organization Settings ──────────────────────────────────────────────────
220
221orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
222 const orgName = c.req.param("org");
223 const user = c.get("user")!;
224
225 let org: any;
226 try {
227 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
228 org = found;
229 } catch {
230 return c.notFound();
231 }
232 if (!org) return c.notFound();
233
234 // Check owner
235 let isOwner = false;
236 try {
237 const [member] = await db.select().from(orgMembers)
238 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
239 .limit(1);
240 isOwner = !!member;
241 } catch { /* */ }
242 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
243
244 const success = c.req.query("success");
245
246 return c.html(
247 <Layout title={`Settings — ${org.name}`} user={user}>
248 <div style="max-width:600px">
249 <h2 style="margin-bottom:20px">Organization Settings</h2>
250 {success && <div class="auth-success">Settings updated.</div>}
251 <form method="post" action={`/orgs/${orgName}/settings`}>
252 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
253 <div class="form-group">
254 <label for="displayName">Display name</label>
255 <input type="text" id="displayName" name="displayName" value={org.displayName || ""} />
256 </div>
257 <div class="form-group">
258 <label for="description">Description</label>
259 <textarea id="description" name="description" rows={3}>{org.description || ""}</textarea>
260 </div>
261 <div class="form-group">
262 <label for="website">Website</label>
263 <input type="url" id="website" name="website" value={org.website || ""} />
264 </div>
265 <div class="form-group">
266 <label for="location">Location</label>
267 <input type="text" id="location" name="location" value={org.location || ""} />
268 </div>
269 <button type="submit" class="btn btn-primary">Save changes</button>
270 </form>
271
272 <div style="margin-top:40px;padding-top:24px;border-top:1px solid var(--red)">
273 <h3 style="color:var(--red);margin-bottom:12px">Danger Zone</h3>
274 <form method="post" action={`/orgs/${orgName}/delete`} class="confirm-action" data-confirm="This will permanently delete the organization. Are you sure?">
275 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
276 <button type="submit" class="btn btn-danger">Delete organization</button>
277 </form>
278 </div>
279 </div>
280 </Layout>
281 );
282});
283
284orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
285 const orgName = c.req.param("org");
286 const user = c.get("user")!;
287 const body = await c.req.parseBody();
288
289 try {
290 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
291 if (!org) return c.redirect("/");
292
293 const [member] = await db.select().from(orgMembers)
294 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
295 .limit(1);
296 if (!member) return c.redirect(`/orgs/${orgName}`);
297
298 await db.update(organizations).set({
299 displayName: String(body.displayName || "").trim() || org.name,
300 description: String(body.description || "").trim() || null,
301 website: String(body.website || "").trim() || null,
302 location: String(body.location || "").trim() || null,
303 updatedAt: new Date(),
304 }).where(eq(organizations.id, org.id));
305 } catch { /* */ }
306
307 return c.redirect(`/orgs/${orgName}/settings?success=1`);
308});
309
310orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
311 const orgName = c.req.param("org");
312 const user = c.get("user")!;
313
314 try {
315 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
316 if (!org) return c.redirect("/");
317
318 const [member] = await db.select().from(orgMembers)
319 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
320 .limit(1);
321 if (!member) return c.redirect(`/orgs/${orgName}`);
322
323 await db.delete(organizations).where(eq(organizations.id, org.id));
324 } catch { /* */ }
325
326 return c.redirect("/");
327});
328
329// ─── Member Invite ──────────────────────────────────────────────────────────
330
331orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
332 const orgName = c.req.param("org");
333 const user = c.get("user")!;
334 const error = c.req.query("error");
335 const success = c.req.query("success");
336
337 return c.html(
338 <Layout title={`Invite Member — ${orgName}`} user={user}>
339 <div style="max-width:500px">
340 <h2 style="margin-bottom:16px">Invite a member to {orgName}</h2>
341 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
342 {success && <div class="auth-success">Member invited successfully.</div>}
343 <form method="post" action={`/orgs/${orgName}/members/invite`}>
344 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
345 <div class="form-group">
346 <label for="username">Username</label>
347 <input type="text" id="username" name="username" required placeholder="Enter username" autocomplete="off" />
348 </div>
349 <div class="form-group">
350 <label for="role">Role</label>
351 <select id="role" name="role">
352 <option value="member">Member — can view</option>
353 <option value="admin">Admin — can manage teams</option>
354 <option value="owner">Owner — full control</option>
355 </select>
356 </div>
357 <button type="submit" class="btn btn-primary">Send invitation</button>
358 </form>
359 </div>
360 </Layout>
361 );
362});
363
364orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
365 const orgName = c.req.param("org");
366 const user = c.get("user")!;
367 const body = await c.req.parseBody();
368 const username = String(body.username || "").trim();
369 const role = String(body.role || "member");
370
371 try {
372 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
373 if (!org) return c.redirect("/");
374
375 // Check inviter is owner or admin
376 const [inviter] = await db.select().from(orgMembers)
377 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
378 .limit(1);
379 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
380 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
381 }
382
383 // Find user
384 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
385 if (!targetUser) {
386 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
387 }
388
389 // Check if already member
390 const [existing] = await db.select().from(orgMembers)
391 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
392 .limit(1);
393 if (existing) {
394 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
395 }
396
397 await db.insert(orgMembers).values({
398 orgId: org.id,
399 userId: targetUser.id,
400 role: ["owner", "admin", "member"].includes(role) ? role : "member",
401 });
402
403 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
404 } catch (err: any) {
405 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
406 }
407});
408
409// ─── Team Create ────────────────────────────────────────────────────────────
410
411orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
412 const orgName = c.req.param("org");
413 const user = c.get("user")!;
414 const error = c.req.query("error");
415
416 return c.html(
417 <Layout title={`New Team — ${orgName}`} user={user}>
418 <div style="max-width:500px">
419 <h2 style="margin-bottom:16px">Create a new team</h2>
420 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
421 <form method="post" action={`/orgs/${orgName}/teams/new`}>
422 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
423 <div class="form-group">
424 <label for="name">Team name</label>
425 <input type="text" id="name" name="name" required placeholder="engineering" autocomplete="off" />
426 </div>
427 <div class="form-group">
428 <label for="description">Description</label>
429 <textarea id="description" name="description" rows={2} placeholder="What does this team do?" />
430 </div>
431 <div class="form-group">
432 <label for="permission">Default permission</label>
433 <select id="permission" name="permission">
434 <option value="read">Read — view repos</option>
435 <option value="write">Write — push to repos</option>
436 <option value="admin">Admin — manage repos</option>
437 </select>
438 </div>
439 <button type="submit" class="btn btn-primary">Create team</button>
440 </form>
441 </div>
442 </Layout>
443 );
444});
445
446orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
447 const orgName = c.req.param("org");
448 const user = c.get("user")!;
449 const body = await c.req.parseBody();
450 const name = String(body.name || "").trim();
451 const description = String(body.description || "").trim();
452 const permission = String(body.permission || "read");
453
454 try {
455 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
456 if (!org) return c.redirect("/");
457
458 const [member] = await db.select().from(orgMembers)
459 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
460 .limit(1);
461 if (!member || member.role === "member") {
462 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
463 }
464
465 if (!name) {
466 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
467 }
468
469 await db.insert(teams).values({
470 orgId: org.id,
471 name,
472 description: description || null,
473 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
474 });
475
476 return c.redirect(`/orgs/${orgName}`);
477 } catch (err: any) {
478 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
479 }
480});
481
482// ─── Team Detail ────────────────────────────────────────────────────────────
483
484orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
485 const orgName = c.req.param("org");
486 const teamName = c.req.param("team");
487 const user = c.get("user");
488
489 let org: any, team: any;
490 try {
491 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
492 if (!org) return c.notFound();
493 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
494 if (!team) return c.notFound();
495 } catch {
496 return c.notFound();
497 }
498
499 let members: any[] = [];
500 try {
501 members = await db
502 .select({ member: teamMembers, user: { username: users.username } })
503 .from(teamMembers)
504 .innerJoin(users, eq(teamMembers.userId, users.id))
505 .where(eq(teamMembers.teamId, team.id));
506 } catch { /* */ }
507
508 let repos: any[] = [];
509 try {
510 repos = await db
511 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
512 .from(teamRepos)
513 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
514 .where(eq(teamRepos.teamId, team.id));
515 } catch { /* */ }
516
517 return c.html(
518 <Layout title={`${teamName} — ${orgName}`} user={user}>
519 <div style="max-width:800px">
520 <div style="margin-bottom:24px">
521 <div style="font-size:14px;color:var(--text-muted);margin-bottom:4px">
522 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
523 </div>
524 <h2>{team.name}</h2>
525 {team.description && <p style="color:var(--text-muted);margin-top:4px">{team.description}</p>}
526 <span class="badge" style="margin-top:8px">{team.permission} access</span>
527 </div>
528
529 <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
530 <div>
531 <h3 style="margin-bottom:12px">Members ({members.length})</h3>
532 {members.length === 0 ? (
533 <p style="color:var(--text-muted);font-size:14px">No members yet.</p>
534 ) : (
535 <div style="display:flex;flex-direction:column;gap:8px">
536 {members.map((m: any) => (
537 <div style="padding:8px;border:1px solid var(--border);border-radius:var(--radius)">
538 <a href={`/${m.user.username}`}>{m.user.username}</a>
539 </div>
540 ))}
541 </div>
542 )}
543 </div>
544 <div>
545 <h3 style="margin-bottom:12px">Repositories ({repos.length})</h3>
546 {repos.length === 0 ? (
547 <p style="color:var(--text-muted);font-size:14px">No repositories assigned.</p>
548 ) : (
549 <div style="display:flex;flex-direction:column;gap:8px">
550 {repos.map((r: any) => (
551 <div style="padding:8px;border:1px solid var(--border);border-radius:var(--radius)">
552 {r.repo.name}
553 <span class="badge" style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</span>
554 </div>
555 ))}
556 </div>
557 )}
558 </div>
559 </div>
560 </div>
561 </Layout>
562 );
563});
564
565export default orgRoutes;