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.tsxBlame1129 lines · 1 contributor
6563f0aClaude1/**
2 * Organizations (Block B1).
3 *
4 * Routes:
5 * GET /orgs list orgs the current user belongs to
6 * GET /orgs/new create-org form
7 * POST /orgs/new create org; creator becomes owner
8 * GET /orgs/:slug org profile (people + teams summary)
9 * GET /orgs/:slug/people full people list
10 * POST /orgs/:slug/people/add add member by username (admin+)
11 * POST /orgs/:slug/people/:uid/role change role (owner only; last-owner guard)
12 * POST /orgs/:slug/people/:uid/remove remove member (admin+; cannot self-demote if last owner)
13 * GET /orgs/:slug/teams teams list
14 * POST /orgs/:slug/teams/new create team (admin+)
15 * GET /orgs/:slug/teams/:teamSlug team detail + member mgmt
16 * POST /orgs/:slug/teams/:teamSlug/members/add add team member (maintainer+ of team OR org admin+)
17 * POST /orgs/:slug/teams/:teamSlug/members/:uid/remove
18 *
19 * Auth: /orgs and sub-paths require auth.
20 * Authorization is role-based and checked inside each handler.
21 */
22
23import { Hono } from "hono";
24import { and, eq } from "drizzle-orm";
25import { db } from "../db";
26import {
27 organizations,
28 orgMembers,
29 teams,
30 teamMembers,
31 users,
32} from "../db/schema";
33import type { AuthEnv } from "../middleware/auth";
34import { requireAuth } from "../middleware/auth";
35import { Layout } from "../views/layout";
36import {
37 isValidSlug,
38 normalizeSlug,
39 orgRoleAtLeast,
40 isValidOrgRole,
41 isValidTeamRole,
42 loadOrgForUser,
43 listOrgsForUser,
44 listOrgMembers,
45 listTeamsForOrg,
46 listTeamMembers,
47} from "../lib/orgs";
48import { audit } from "../lib/notify";
49
50const orgs = new Hono<AuthEnv>();
51
52orgs.use("/orgs", requireAuth);
53orgs.use("/orgs/*", requireAuth);
54
55// --- helpers ----------------------------------------------------------------
56
57function errorRedirect(path: string, msg: string) {
58 return `${path}?error=${encodeURIComponent(msg)}`;
59}
60function successRedirect(path: string, msg: string) {
61 return `${path}?success=${encodeURIComponent(msg)}`;
62}
63
64/**
65 * Count owners in an org. Used to block the last-owner from being demoted
66 * or removed (that would orphan the org).
67 */
68async function ownerCount(orgId: string): Promise<number> {
69 try {
70 const rows = await db
71 .select({ userId: orgMembers.userId })
72 .from(orgMembers)
73 .where(and(eq(orgMembers.orgId, orgId), eq(orgMembers.role, "owner")));
74 return rows.length;
75 } catch (err) {
76 console.error("[orgs] ownerCount:", err);
77 // Fail-safe: pretend there's more than one so we never accidentally
78 // allow the last owner to be removed.
79 return 2;
80 }
81}
82
83async function findUserByUsername(username: string) {
84 try {
85 const [u] = await db
86 .select()
87 .from(users)
88 .where(eq(users.username, username))
89 .limit(1);
90 return u || null;
91 } catch (err) {
92 console.error("[orgs] findUserByUsername:", err);
93 return null;
94 }
95}
96
97// --- LIST -------------------------------------------------------------------
98
99orgs.get("/orgs", async (c) => {
100 const user = c.get("user")!;
101 const rows = await listOrgsForUser(user.id);
102
103 return c.html(
104 <Layout title="Organizations" user={user}>
105 <div class="settings-container" style="max-width: 800px">
106 <div
107 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px"
108 >
109 <h2 style="margin: 0">Your organizations</h2>
110 <a href="/orgs/new" class="btn btn-primary">
111 New organization
112 </a>
113 </div>
114 {rows.length === 0 ? (
115 <div class="empty-state">
116 <h2>No organizations yet</h2>
117 <p>
118 Organizations let multiple users collaborate on shared repos
119 with team-based permissions.
120 </p>
121 <a href="/orgs/new" class="btn btn-primary" style="margin-top: 8px">
122 Create your first org
123 </a>
124 </div>
125 ) : (
126 <div style="display: flex; flex-direction: column; gap: 8px">
127 {rows.map((r) => (
128 <a
129 href={`/orgs/${r.slug}`}
130 style="display: flex; padding: 12px 16px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); text-decoration: none; color: var(--text); align-items: center; gap: 12px"
131 >
132 <div style="flex: 1">
133 <strong>{r.name}</strong>{" "}
134 <span style="color: var(--text-muted); font-size: 12px">
135 @{r.slug}
136 </span>
137 {r.description && (
138 <div style="color: var(--text-muted); font-size: 13px; margin-top: 2px">
139 {r.description}
140 </div>
141 )}
142 </div>
143 <span
144 class="gate-status"
145 style="font-size: 11px; text-transform: uppercase"
146 >
147 {r.role}
148 </span>
149 </a>
150 ))}
151 </div>
152 )}
153 </div>
154 </Layout>
155 );
156});
157
158// --- CREATE -----------------------------------------------------------------
159
160orgs.get("/orgs/new", async (c) => {
161 const user = c.get("user")!;
162 const error = c.req.query("error");
163
164 return c.html(
165 <Layout title="New organization" user={user}>
166 <div class="settings-container" style="max-width: 560px">
167 <h2>Create organization</h2>
168 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
169 Organizations are multi-user namespaces. You'll be the owner and can
170 invite teammates after creation.
171 </p>
172 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
173 <form method="POST" action="/orgs/new">
174 <div class="form-group">
175 <label for="slug">Slug</label>
176 <input
177 type="text"
178 id="slug"
179 name="slug"
180 required
181 maxLength={39}
182 pattern="[a-z0-9][a-z0-9-]{0,38}"
183 placeholder="acme-corp"
184 autocomplete="off"
185 />
186 <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px">
187 2–39 chars, lowercase letters, numbers, hyphens. Cannot start or
188 end with a hyphen.
189 </div>
190 </div>
191 <div class="form-group">
192 <label for="name">Display name</label>
193 <input
194 type="text"
195 id="name"
196 name="name"
197 required
198 maxLength={120}
199 placeholder="Acme Corp"
200 />
201 </div>
202 <div class="form-group">
203 <label for="description">Description (optional)</label>
204 <textarea
205 id="description"
206 name="description"
207 rows={3}
208 maxLength={500}
209 placeholder="What does this org do?"
210 />
211 </div>
212 <button type="submit" class="btn btn-primary">
213 Create organization
214 </button>
215 </form>
216 </div>
217 </Layout>
218 );
219});
220
221orgs.post("/orgs/new", async (c) => {
222 const user = c.get("user")!;
223 const body = await c.req.parseBody();
224 const slug = normalizeSlug(String(body.slug || ""));
225 const name = String(body.name || "").trim();
226 const description = String(body.description || "").trim() || null;
227
228 if (!isValidSlug(slug)) {
229 return c.redirect(
230 errorRedirect(
231 "/orgs/new",
232 "Invalid slug. 2–39 chars, lowercase a-z, 0-9, hyphens. No reserved words."
233 )
234 );
235 }
236 if (!name) {
237 return c.redirect(errorRedirect("/orgs/new", "Display name is required"));
238 }
239
240 try {
241 // Collision check against usernames (separate namespace but shared URLs)
242 const [existingUser] = await db
243 .select({ id: users.id })
244 .from(users)
245 .where(eq(users.username, slug))
246 .limit(1);
247 if (existingUser) {
248 return c.redirect(
249 errorRedirect(
250 "/orgs/new",
251 "That slug is already taken by a user account"
252 )
253 );
254 }
255
256 const [org] = await db
257 .insert(organizations)
258 .values({
259 slug,
260 name,
261 description,
262 createdById: user.id,
263 })
264 .returning();
265
266 if (!org) {
267 return c.redirect(
268 errorRedirect("/orgs/new", "Failed to create organization")
269 );
270 }
271
272 // Creator is the first owner
273 await db.insert(orgMembers).values({
274 orgId: org.id,
275 userId: user.id,
276 role: "owner",
277 });
278
279 await audit({
280 userId: user.id,
281 action: "org.create",
282 targetType: "organization",
283 targetId: org.id,
284 metadata: { slug, name },
285 });
286
287 return c.redirect(`/orgs/${slug}`);
288 } catch (err: any) {
289 if (String(err?.message || err).includes("organizations_slug")) {
290 return c.redirect(
291 errorRedirect("/orgs/new", "That slug is already taken")
292 );
293 }
294 console.error("[orgs] create:", err);
295 return c.redirect(
296 errorRedirect("/orgs/new", "Failed to create organization")
297 );
298 }
299});
300
301// --- PROFILE ----------------------------------------------------------------
302
303orgs.get("/orgs/:slug", async (c) => {
304 const user = c.get("user")!;
305 const slug = c.req.param("slug");
306 const { org, role } = await loadOrgForUser(slug, user.id);
307 if (!org) return c.notFound();
308 const error = c.req.query("error");
309 const success = c.req.query("success");
310
311 const [members, orgTeams] = await Promise.all([
312 listOrgMembers(org.id),
313 listTeamsForOrg(org.id),
314 ]);
315
316 return c.html(
317 <Layout title={`${org.name} (@${org.slug})`} user={user}>
318 <div style="max-width: 900px">
319 <div style="display: flex; align-items: center; gap: 16px; margin-bottom: 16px">
320 <div style="flex: 1">
321 <h2 style="margin: 0">{org.name}</h2>
322 <div style="color: var(--text-muted); font-size: 13px">
323 @{org.slug}
324 {role && (
325 <>
326 {" · "}
327 <span class="gate-status" style="font-size: 10px">
328 {role}
329 </span>
330 </>
331 )}
332 </div>
333 </div>
334 {role && orgRoleAtLeast(role, "admin") && (
335 <a href={`/orgs/${org.slug}/people`} class="btn">
336 Manage
337 </a>
338 )}
339 </div>
340 {org.description && (
341 <p style="color: var(--text-muted); margin-bottom: 16px">
342 {org.description}
343 </p>
344 )}
345 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
346 {success && (
347 <div class="auth-success">{decodeURIComponent(success)}</div>
348 )}
349
350 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px">
351 <div>
352 <div
353 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px"
354 >
355 <h3 style="font-size: 15px; margin: 0">
356 People ({members.length})
357 </h3>
358 <a
359 href={`/orgs/${org.slug}/people`}
360 style="font-size: 12px"
361 >
362 view all
363 </a>
364 </div>
365 <div
366 style="border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); padding: 8px 12px"
367 >
368 {members.slice(0, 8).map((m) => (
369 <div
370 style="padding: 4px 0; display: flex; justify-content: space-between; font-size: 13px"
371 >
372 <span>
373 <a href={`/${m.username}`}>{m.username}</a>
374 </span>
375 <span
376 style="color: var(--text-muted); font-size: 11px; text-transform: uppercase"
377 >
378 {m.role}
379 </span>
380 </div>
381 ))}
382 {members.length === 0 && (
383 <div style="color: var(--text-muted); font-size: 12px">
384 No members yet
385 </div>
386 )}
387 </div>
388 </div>
389 <div>
390 <div
391 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px"
392 >
393 <h3 style="font-size: 15px; margin: 0">
394 Teams ({orgTeams.length})
395 </h3>
396 <a href={`/orgs/${org.slug}/teams`} style="font-size: 12px">
397 view all
398 </a>
399 </div>
400 <div
401 style="border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); padding: 8px 12px"
402 >
403 {orgTeams.slice(0, 8).map((t) => (
404 <div
405 style="padding: 4px 0; display: flex; justify-content: space-between; font-size: 13px"
406 >
407 <a href={`/orgs/${org.slug}/teams/${t.slug}`}>
408 {t.name}
409 </a>
410 <span
411 style="color: var(--text-muted); font-size: 11px"
412 >
413 @{t.slug}
414 </span>
415 </div>
416 ))}
417 {orgTeams.length === 0 && (
418 <div style="color: var(--text-muted); font-size: 12px">
419 No teams yet
420 </div>
421 )}
422 </div>
423 </div>
424 </div>
425 </div>
426 </Layout>
427 );
428});
429
430// --- PEOPLE -----------------------------------------------------------------
431
432orgs.get("/orgs/:slug/people", async (c) => {
433 const user = c.get("user")!;
434 const slug = c.req.param("slug");
435 const { org, role } = await loadOrgForUser(slug, user.id);
436 if (!org) return c.notFound();
437 if (!role) return c.redirect(`/orgs/${slug}`);
438
439 const members = await listOrgMembers(org.id);
440 const error = c.req.query("error");
441 const success = c.req.query("success");
442 const canAdmin = orgRoleAtLeast(role, "admin");
443 const canOwner = orgRoleAtLeast(role, "owner");
444
445 return c.html(
446 <Layout title={`${org.name} — people`} user={user}>
447 <div style="max-width: 800px">
448 <div class="breadcrumb">
449 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
450 <span>/</span>
451 <span>people</span>
452 </div>
453 <h2>People ({members.length})</h2>
454 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
455 {success && (
456 <div class="auth-success">{decodeURIComponent(success)}</div>
457 )}
458
459 {canAdmin && (
460 <form
461 method="POST"
462 action={`/orgs/${org.slug}/people/add`}
463 style="display: flex; gap: 8px; margin-bottom: 16px"
464 >
465 <input
466 type="text"
467 name="username"
468 placeholder="username to add"
469 required
470 maxLength={64}
471 style="flex: 1"
472 />
473 <select name="role">
474 <option value="member">member</option>
475 <option value="admin">admin</option>
476 {canOwner && <option value="owner">owner</option>}
477 </select>
478 <button type="submit" class="btn btn-primary">
479 Add
480 </button>
481 </form>
482 )}
483
484 <div
485 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
486 >
487 {members.map((m) => (
488 <div
489 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
490 >
491 <div>
492 <a href={`/${m.username}`}>
493 <strong>{m.username}</strong>
494 </a>
495 {m.displayName && (
496 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
497 {m.displayName}
498 </span>
499 )}
500 </div>
501 <div style="display: flex; gap: 8px; align-items: center">
502 {canOwner && m.userId !== user.id ? (
503 <form
504 method="POST"
505 action={`/orgs/${org.slug}/people/${m.userId}/role`}
506 style="display: flex; gap: 4px"
507 >
508 <select name="role">
509 <option value="member" selected={m.role === "member"}>
510 member
511 </option>
512 <option value="admin" selected={m.role === "admin"}>
513 admin
514 </option>
515 <option value="owner" selected={m.role === "owner"}>
516 owner
517 </option>
518 </select>
519 <button type="submit" class="btn btn-sm">
520 save
521 </button>
522 </form>
523 ) : (
524 <span
525 class="gate-status"
526 style="font-size: 11px; text-transform: uppercase"
527 >
528 {m.role}
529 </span>
530 )}
531 {canAdmin && m.userId !== user.id && (
532 <form
533 method="POST"
534 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
535 style="display: inline"
536 onsubmit="return confirm('Remove this member?')"
537 >
538 <button type="submit" class="btn btn-sm btn-danger">
539 remove
540 </button>
541 </form>
542 )}
543 </div>
544 </div>
545 ))}
546 </div>
547 </div>
548 </Layout>
549 );
550});
551
552orgs.post("/orgs/:slug/people/add", async (c) => {
553 const user = c.get("user")!;
554 const slug = c.req.param("slug");
555 const { org, role } = await loadOrgForUser(slug, user.id);
556 if (!org) return c.notFound();
557 if (!role || !orgRoleAtLeast(role, "admin")) {
558 return c.redirect(
559 errorRedirect(`/orgs/${slug}/people`, "You need admin rights")
560 );
561 }
562
563 const body = await c.req.parseBody();
564 const username = String(body.username || "").trim().toLowerCase();
565 const newRole = String(body.role || "member");
566
567 if (!username || !isValidOrgRole(newRole)) {
568 return c.redirect(
569 errorRedirect(`/orgs/${slug}/people`, "Invalid input")
570 );
571 }
572 // Only owners can grant owner
573 if (newRole === "owner" && !orgRoleAtLeast(role, "owner")) {
574 return c.redirect(
575 errorRedirect(`/orgs/${slug}/people`, "Only owners can grant owner role")
576 );
577 }
578
579 const target = await findUserByUsername(username);
580 if (!target) {
581 return c.redirect(
582 errorRedirect(`/orgs/${slug}/people`, `User ${username} not found`)
583 );
584 }
585
586 try {
587 await db.insert(orgMembers).values({
588 orgId: org.id,
589 userId: target.id,
590 role: newRole,
591 });
592 await audit({
593 userId: user.id,
594 action: "org.member.add",
595 targetType: "org_member",
596 targetId: target.id,
597 metadata: { orgSlug: slug, role: newRole },
598 });
599 } catch (err: any) {
600 if (String(err?.message || err).includes("org_members_unique")) {
601 return c.redirect(
602 errorRedirect(`/orgs/${slug}/people`, "Already a member")
603 );
604 }
605 console.error("[orgs] add member:", err);
606 return c.redirect(
607 errorRedirect(`/orgs/${slug}/people`, "Failed to add member")
608 );
609 }
610 return c.redirect(successRedirect(`/orgs/${slug}/people`, "Member added"));
611});
612
613orgs.post("/orgs/:slug/people/:uid/role", async (c) => {
614 const user = c.get("user")!;
615 const slug = c.req.param("slug");
616 const targetId = c.req.param("uid");
617 const { org, role } = await loadOrgForUser(slug, user.id);
618 if (!org) return c.notFound();
619 if (!role || !orgRoleAtLeast(role, "owner")) {
620 return c.redirect(
621 errorRedirect(`/orgs/${slug}/people`, "Owner-only action")
622 );
623 }
624
625 const body = await c.req.parseBody();
626 const newRole = String(body.role || "member");
627 if (!isValidOrgRole(newRole)) {
628 return c.redirect(errorRedirect(`/orgs/${slug}/people`, "Invalid role"));
629 }
630
631 try {
632 // Last-owner guard: demoting the final owner would orphan the org.
633 if (newRole !== "owner") {
634 const [existing] = await db
635 .select({ role: orgMembers.role })
636 .from(orgMembers)
637 .where(
638 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetId))
639 )
640 .limit(1);
641 if (
642 existing?.role === "owner" &&
643 (await ownerCount(org.id)) <= 1
644 ) {
645 return c.redirect(
646 errorRedirect(
647 `/orgs/${slug}/people`,
648 "Cannot demote the last owner"
649 )
650 );
651 }
652 }
653
654 await db
655 .update(orgMembers)
656 .set({ role: newRole })
657 .where(
658 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetId))
659 );
660 await audit({
661 userId: user.id,
662 action: "org.member.role",
663 targetType: "org_member",
664 targetId,
665 metadata: { orgSlug: slug, role: newRole },
666 });
667 } catch (err) {
668 console.error("[orgs] role change:", err);
669 return c.redirect(
670 errorRedirect(`/orgs/${slug}/people`, "Failed to change role")
671 );
672 }
673 return c.redirect(successRedirect(`/orgs/${slug}/people`, "Role updated"));
674});
675
676orgs.post("/orgs/:slug/people/:uid/remove", async (c) => {
677 const user = c.get("user")!;
678 const slug = c.req.param("slug");
679 const targetId = c.req.param("uid");
680 const { org, role } = await loadOrgForUser(slug, user.id);
681 if (!org) return c.notFound();
682 if (!role || !orgRoleAtLeast(role, "admin")) {
683 return c.redirect(
684 errorRedirect(`/orgs/${slug}/people`, "Admin-only action")
685 );
686 }
687 if (targetId === user.id) {
688 return c.redirect(
689 errorRedirect(
690 `/orgs/${slug}/people`,
691 "Leave the org from your settings instead"
692 )
693 );
694 }
695
696 try {
697 const [existing] = await db
698 .select({ role: orgMembers.role })
699 .from(orgMembers)
700 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetId)))
701 .limit(1);
702 if (!existing) {
703 return c.redirect(
704 errorRedirect(`/orgs/${slug}/people`, "Member not found")
705 );
706 }
707 if (existing.role === "owner" && (await ownerCount(org.id)) <= 1) {
708 return c.redirect(
709 errorRedirect(`/orgs/${slug}/people`, "Cannot remove the last owner")
710 );
711 }
712 // Admin cannot remove an owner.
713 if (existing.role === "owner" && !orgRoleAtLeast(role, "owner")) {
714 return c.redirect(
715 errorRedirect(
716 `/orgs/${slug}/people`,
717 "Only an owner can remove another owner"
718 )
719 );
720 }
721
722 await db
723 .delete(orgMembers)
724 .where(
725 and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetId))
726 );
727 await audit({
728 userId: user.id,
729 action: "org.member.remove",
730 targetType: "org_member",
731 targetId,
732 metadata: { orgSlug: slug },
733 });
734 } catch (err) {
735 console.error("[orgs] remove member:", err);
736 return c.redirect(
737 errorRedirect(`/orgs/${slug}/people`, "Failed to remove member")
738 );
739 }
740 return c.redirect(successRedirect(`/orgs/${slug}/people`, "Member removed"));
741});
742
743// --- TEAMS ------------------------------------------------------------------
744
745orgs.get("/orgs/:slug/teams", async (c) => {
746 const user = c.get("user")!;
747 const slug = c.req.param("slug");
748 const { org, role } = await loadOrgForUser(slug, user.id);
749 if (!org) return c.notFound();
750
751 const orgTeams = await listTeamsForOrg(org.id);
752 const error = c.req.query("error");
753 const success = c.req.query("success");
754 const canAdmin = role && orgRoleAtLeast(role, "admin");
755
756 return c.html(
757 <Layout title={`${org.name} — teams`} user={user}>
758 <div style="max-width: 800px">
759 <div class="breadcrumb">
760 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
761 <span>/</span>
762 <span>teams</span>
763 </div>
764 <h2>Teams ({orgTeams.length})</h2>
765 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
766 {success && (
767 <div class="auth-success">{decodeURIComponent(success)}</div>
768 )}
769
770 {canAdmin && (
771 <form
772 method="POST"
773 action={`/orgs/${org.slug}/teams/new`}
774 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
775 >
776 <input
777 type="text"
778 name="slug"
779 placeholder="team-slug"
780 required
781 maxLength={39}
782 pattern="[a-z0-9][a-z0-9-]{0,38}"
783 />
784 <input
785 type="text"
786 name="name"
787 placeholder="Team name"
788 required
789 maxLength={80}
790 />
791 <button type="submit" class="btn btn-primary">
792 Create team
793 </button>
794 </form>
795 )}
796
797 <div
798 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
799 >
800 {orgTeams.length === 0 ? (
801 <div
802 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
803 >
804 No teams yet.
805 </div>
806 ) : (
807 orgTeams.map((t) => (
808 <a
809 href={`/orgs/${org.slug}/teams/${t.slug}`}
810 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
811 >
812 <strong>{t.name}</strong>{" "}
813 <span style="color: var(--text-muted); font-size: 12px">
814 @{t.slug}
815 </span>
816 {t.description && (
817 <div style="color: var(--text-muted); font-size: 12px">
818 {t.description}
819 </div>
820 )}
821 </a>
822 ))
823 )}
824 </div>
825 </div>
826 </Layout>
827 );
828});
829
830orgs.post("/orgs/:slug/teams/new", async (c) => {
831 const user = c.get("user")!;
832 const slug = c.req.param("slug");
833 const { org, role } = await loadOrgForUser(slug, user.id);
834 if (!org) return c.notFound();
835 if (!role || !orgRoleAtLeast(role, "admin")) {
836 return c.redirect(
837 errorRedirect(`/orgs/${slug}/teams`, "Admin-only action")
838 );
839 }
840
841 const body = await c.req.parseBody();
842 const teamSlug = normalizeSlug(String(body.slug || ""));
843 const name = String(body.name || "").trim();
844 const description = String(body.description || "").trim() || null;
845
846 if (!isValidSlug(teamSlug) || !name) {
847 return c.redirect(
848 errorRedirect(`/orgs/${slug}/teams`, "Invalid slug or name")
849 );
850 }
851
852 try {
853 await db.insert(teams).values({
854 orgId: org.id,
855 slug: teamSlug,
856 name,
857 description,
858 });
859 await audit({
860 userId: user.id,
861 action: "org.team.create",
862 targetType: "team",
863 metadata: { orgSlug: slug, teamSlug },
864 });
865 } catch (err: any) {
866 if (String(err?.message || err).includes("teams_org_slug")) {
867 return c.redirect(
868 errorRedirect(
869 `/orgs/${slug}/teams`,
870 "A team with that slug already exists"
871 )
872 );
873 }
874 console.error("[orgs] team create:", err);
875 return c.redirect(
876 errorRedirect(`/orgs/${slug}/teams`, "Failed to create team")
877 );
878 }
879 return c.redirect(
880 successRedirect(`/orgs/${slug}/teams`, "Team created")
881 );
882});
883
884orgs.get("/orgs/:slug/teams/:teamSlug", async (c) => {
885 const user = c.get("user")!;
886 const slug = c.req.param("slug");
887 const teamSlug = c.req.param("teamSlug");
888 const { org, role } = await loadOrgForUser(slug, user.id);
889 if (!org) return c.notFound();
890
891 let team: typeof teams.$inferSelect | null = null;
892 try {
893 const [t] = await db
894 .select()
895 .from(teams)
896 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
897 .limit(1);
898 team = t || null;
899 } catch (err) {
900 console.error("[orgs] team detail:", err);
901 }
902 if (!team) return c.notFound();
903
904 const members = await listTeamMembers(team.id);
905 const canAdmin = role && orgRoleAtLeast(role, "admin");
906 const error = c.req.query("error");
907 const success = c.req.query("success");
908
909 return c.html(
910 <Layout title={`${team.name} — ${org.name}`} user={user}>
911 <div style="max-width: 800px">
912 <div class="breadcrumb">
913 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
914 <span>/</span>
915 <a href={`/orgs/${org.slug}/teams`}>teams</a>
916 <span>/</span>
917 <span>{team.slug}</span>
918 </div>
919 <h2>{team.name}</h2>
920 {team.description && (
921 <p style="color: var(--text-muted)">{team.description}</p>
922 )}
923 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
924 {success && (
925 <div class="auth-success">{decodeURIComponent(success)}</div>
926 )}
927
928 <h3 style="font-size: 15px; margin-top: 16px">
929 Members ({members.length})
930 </h3>
931
932 {canAdmin && (
933 <form
934 method="POST"
935 action={`/orgs/${org.slug}/teams/${team.slug}/members/add`}
936 style="display: flex; gap: 8px; margin-bottom: 16px"
937 >
938 <input
939 type="text"
940 name="username"
941 placeholder="username"
942 required
943 maxLength={64}
944 style="flex: 1"
945 />
946 <select name="role">
947 <option value="member">member</option>
948 <option value="maintainer">maintainer</option>
949 </select>
950 <button type="submit" class="btn btn-primary">
951 Add
952 </button>
953 </form>
954 )}
955
956 <div
957 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
958 >
959 {members.length === 0 ? (
960 <div
961 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
962 >
963 No members yet.
964 </div>
965 ) : (
966 members.map((m) => (
967 <div
968 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
969 >
970 <a href={`/${m.username}`}>{m.username}</a>
971 <div style="display: flex; gap: 8px; align-items: center">
972 <span
973 class="gate-status"
974 style="font-size: 11px; text-transform: uppercase"
975 >
976 {m.role}
977 </span>
978 {canAdmin && (
979 <form
980 method="POST"
981 action={`/orgs/${org.slug}/teams/${team.slug}/members/${m.userId}/remove`}
982 style="display: inline"
983 >
984 <button type="submit" class="btn btn-sm btn-danger">
985 remove
986 </button>
987 </form>
988 )}
989 </div>
990 </div>
991 ))
992 )}
993 </div>
994 </div>
995 </Layout>
996 );
997});
998
999orgs.post("/orgs/:slug/teams/:teamSlug/members/add", async (c) => {
1000 const user = c.get("user")!;
1001 const slug = c.req.param("slug");
1002 const teamSlug = c.req.param("teamSlug");
1003 const { org, role } = await loadOrgForUser(slug, user.id);
1004 if (!org) return c.notFound();
1005 if (!role || !orgRoleAtLeast(role, "admin")) {
1006 return c.redirect(
1007 errorRedirect(`/orgs/${slug}/teams/${teamSlug}`, "Admin-only action")
1008 );
1009 }
1010
1011 const body = await c.req.parseBody();
1012 const username = String(body.username || "").trim().toLowerCase();
1013 const teamRole = String(body.role || "member");
1014 if (!username || !isValidTeamRole(teamRole)) {
1015 return c.redirect(
1016 errorRedirect(`/orgs/${slug}/teams/${teamSlug}`, "Invalid input")
1017 );
1018 }
1019
1020 try {
1021 const [team] = await db
1022 .select({ id: teams.id })
1023 .from(teams)
1024 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
1025 .limit(1);
1026 if (!team) return c.notFound();
1027
1028 const target = await findUserByUsername(username);
1029 if (!target) {
1030 return c.redirect(
1031 errorRedirect(
1032 `/orgs/${slug}/teams/${teamSlug}`,
1033 `User ${username} not found`
1034 )
1035 );
1036 }
1037
1038 // Team membership requires org membership.
1039 const [orgMem] = await db
1040 .select({ role: orgMembers.role })
1041 .from(orgMembers)
1042 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, target.id)))
1043 .limit(1);
1044 if (!orgMem) {
1045 return c.redirect(
1046 errorRedirect(
1047 `/orgs/${slug}/teams/${teamSlug}`,
1048 `${username} is not a member of this org`
1049 )
1050 );
1051 }
1052
1053 await db
1054 .insert(teamMembers)
1055 .values({ teamId: team.id, userId: target.id, role: teamRole });
1056 await audit({
1057 userId: user.id,
1058 action: "org.team.member.add",
1059 targetType: "team_member",
1060 targetId: target.id,
1061 metadata: { orgSlug: slug, teamSlug, role: teamRole },
1062 });
1063 } catch (err: any) {
1064 if (String(err?.message || err).includes("team_members_unique")) {
1065 return c.redirect(
1066 errorRedirect(
1067 `/orgs/${slug}/teams/${teamSlug}`,
1068 "Already on this team"
1069 )
1070 );
1071 }
1072 console.error("[orgs] team member add:", err);
1073 return c.redirect(
1074 errorRedirect(`/orgs/${slug}/teams/${teamSlug}`, "Failed to add member")
1075 );
1076 }
1077 return c.redirect(
1078 successRedirect(`/orgs/${slug}/teams/${teamSlug}`, "Member added")
1079 );
1080});
1081
1082orgs.post("/orgs/:slug/teams/:teamSlug/members/:uid/remove", async (c) => {
1083 const user = c.get("user")!;
1084 const slug = c.req.param("slug");
1085 const teamSlug = c.req.param("teamSlug");
1086 const targetId = c.req.param("uid");
1087 const { org, role } = await loadOrgForUser(slug, user.id);
1088 if (!org) return c.notFound();
1089 if (!role || !orgRoleAtLeast(role, "admin")) {
1090 return c.redirect(
1091 errorRedirect(`/orgs/${slug}/teams/${teamSlug}`, "Admin-only action")
1092 );
1093 }
1094
1095 try {
1096 const [team] = await db
1097 .select({ id: teams.id })
1098 .from(teams)
1099 .where(and(eq(teams.orgId, org.id), eq(teams.slug, teamSlug)))
1100 .limit(1);
1101 if (!team) return c.notFound();
1102
1103 await db
1104 .delete(teamMembers)
1105 .where(
1106 and(eq(teamMembers.teamId, team.id), eq(teamMembers.userId, targetId))
1107 );
1108 await audit({
1109 userId: user.id,
1110 action: "org.team.member.remove",
1111 targetType: "team_member",
1112 targetId,
1113 metadata: { orgSlug: slug, teamSlug },
1114 });
1115 } catch (err) {
1116 console.error("[orgs] team member remove:", err);
1117 return c.redirect(
1118 errorRedirect(
1119 `/orgs/${slug}/teams/${teamSlug}`,
1120 "Failed to remove member"
1121 )
1122 );
1123 }
1124 return c.redirect(
1125 successRedirect(`/orgs/${slug}/teams/${teamSlug}`, "Member removed")
1126 );
1127});
1128
1129export default orgs;