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

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