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.tsxBlame889 lines · 2 contributors
6563f0aClaude1/**
59b6fb2Claude2 * Organization and team routes — create orgs, manage members, teams, permissions.
6563f0aClaude3 */
4
5import { Hono } from "hono";
59b6fb2Claude6import { eq, and, desc, asc, sql } from "drizzle-orm";
6563f0aClaude7import { db } from "../db";
59b6fb2Claude8import { organizations, orgMembers, teams, teamMembers, teamRepos } from "../db/schema-extensions";
9import { users, repositories } from "../db/schema";
6563f0aClaude10import { Layout } from "../views/layout";
59b6fb2Claude11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
0316dbbClaude13import { loadOrgForUser, listOrgMembers, orgRoleAtLeast } from "../lib/orgs";
6563f0aClaude14import {
3e8f8e8Claude15 Container,
16 PageHeader,
17 Form,
18 FormGroup,
19 Input,
20 TextArea,
21 Select,
22 Button,
23 LinkButton,
24 Alert,
25 EmptyState,
26 Flex,
27 Grid,
28 Text,
29 Badge,
30 Section,
31 Avatar,
32 List,
33 ListItem,
34} from "../views/ui";
59b6fb2Claude35
36const orgRoutes = new Hono<AuthEnv>();
37
699e5c7Claude38// ─── Organization List (index) ──────────────────────────────────────────────
39// GET /orgs — auth-required directory of the viewer's organizations.
40orgRoutes.get("/orgs", softAuth, requireAuth, async (c) => {
41 const user = c.get("user")!;
42 let rows: any[] = [];
43 try {
44 rows = await db
45 .select({ org: organizations, role: orgMembers.role })
46 .from(orgMembers)
47 .innerJoin(organizations, eq(orgMembers.orgId, organizations.id))
48 .where(eq(orgMembers.userId, user.id))
49 .orderBy(asc(organizations.name));
50 } catch {
51 rows = [];
52 }
53 return c.html(
54 <Layout title="Your organizations" user={user}>
55 <Container>
56 <PageHeader
57 title="Your organizations"
58 actions={<LinkButton href="/orgs/new" variant="primary">New organization</LinkButton>}
59 />
60 {rows.length === 0 ? (
61 <EmptyState title="No organizations yet">
62 <Text muted>Create one to share repositories with teammates.</Text>
63 <div style="margin-top:12px">
64 <LinkButton href="/orgs/new" variant="primary">New organization</LinkButton>
65 </div>
66 </EmptyState>
67 ) : (
68 <List>
69 {rows.map((r) => (
70 <ListItem>
71 <a href={`/orgs/${r.org.slug}`} style="color:var(--text)">
72 <strong>{r.org.name}</strong>
73 </a>
74 <Text muted> — {r.role}</Text>
75 </ListItem>
76 ))}
77 </List>
78 )}
79 </Container>
80 </Layout>
81 );
82});
83
84// ─── Org-scoped repos stubs — require auth, delegate to existing flows ──────
85orgRoutes.get("/orgs/:org/repos", softAuth, requireAuth, async (c) => {
86 return c.redirect(`/orgs/${c.req.param("org")}`);
87});
88orgRoutes.get("/orgs/:org/repos/new", softAuth, requireAuth, (c) => {
89 const org = c.req.param("org");
90 return c.redirect(`/new?org=${encodeURIComponent(org)}`);
91});
92orgRoutes.post("/orgs/:org/repos/new", softAuth, requireAuth, (c) => {
93 const org = c.req.param("org");
94 return c.redirect(`/new?org=${encodeURIComponent(org)}`);
95});
96
97// ─── Org people mutation stub — require auth ────────────────────────────────
98orgRoutes.post("/orgs/:org/people/add", softAuth, requireAuth, (c) => {
99 return c.redirect(`/orgs/${c.req.param("org")}/people`);
100});
101
59b6fb2Claude102// ─── Organization List / Create ─────────────────────────────────────────────
103
104orgRoutes.get("/orgs/new", softAuth, requireAuth, (c) => {
6563f0aClaude105 const user = c.get("user")!;
106 const error = c.req.query("error");
107
108 return c.html(
109 <Layout title="New organization" user={user}>
110 <div class="settings-container" style="max-width: 560px">
111 <h2>Create organization</h2>
112 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
113 Organizations are multi-user namespaces. You'll be the owner and can
114 invite teammates after creation.
115 </p>
116 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
e7e240eClaude117 <form method="post" action="/orgs/new">
6563f0aClaude118 <div class="form-group">
119 <label for="slug">Slug</label>
120 <input
121 type="text"
122 id="slug"
123 name="slug"
124 required
125 maxLength={39}
126 pattern="[a-z0-9][a-z0-9-]{0,38}"
127 placeholder="acme-corp"
128 autocomplete="off"
129 />
130 <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px">
131 2–39 chars, lowercase letters, numbers, hyphens. Cannot start or
132 end with a hyphen.
133 </div>
134 </div>
135 <div class="form-group">
136 <label for="name">Display name</label>
137 <input
138 type="text"
139 id="name"
140 name="name"
141 required
142 maxLength={120}
143 placeholder="Acme Corp"
144 />
145 </div>
146 <div class="form-group">
147 <label for="description">Description (optional)</label>
148 <textarea
149 id="description"
150 name="description"
151 rows={3}
152 maxLength={500}
153 placeholder="What does this org do?"
154 />
155 </div>
156 <button type="submit" class="btn btn-primary">
157 Create organization
158 </button>
159 </form>
160 </div>
161 </Layout>
162 );
163});
164
59b6fb2Claude165orgRoutes.post("/orgs/new", softAuth, requireAuth, async (c) => {
6563f0aClaude166 const user = c.get("user")!;
167 const body = await c.req.parseBody();
168 const name = String(body.name || "").trim();
59b6fb2Claude169 const displayName = String(body.displayName || "").trim();
170 const description = String(body.description || "").trim();
171 const website = String(body.website || "").trim();
172
173 if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
174 return c.redirect("/orgs/new?error=Invalid+organization+name");
6563f0aClaude175 }
176
177 try {
59b6fb2Claude178 // Check if name is taken (by user or org)
179 const [existingUser] = await db.select().from(users).where(eq(users.username, name)).limit(1);
6563f0aClaude180 if (existingUser) {
59b6fb2Claude181 return c.redirect("/orgs/new?error=Name+already+taken");
182 }
183
184 const [existingOrg] = await db.select().from(organizations).where(eq(organizations.name, name)).limit(1);
185 if (existingOrg) {
186 return c.redirect("/orgs/new?error=Organization+already+exists");
6563f0aClaude187 }
188
189 const [org] = await db
190 .insert(organizations)
191 .values({
192 name,
59b6fb2Claude193 displayName: displayName || name,
194 description: description || null,
195 website: website || null,
6563f0aClaude196 })
197 .returning();
198
59b6fb2Claude199 // Add creator as owner
6563f0aClaude200 await db.insert(orgMembers).values({
201 orgId: org.id,
202 userId: user.id,
203 role: "owner",
204 });
205
59b6fb2Claude206 return c.redirect(`/orgs/${name}`);
6563f0aClaude207 } catch (err: any) {
59b6fb2Claude208 return c.redirect(`/orgs/new?error=${encodeURIComponent(err.message || "Failed to create organization")}`);
6563f0aClaude209 }
210});
211
59b6fb2Claude212// ─── Organization Profile ───────────────────────────────────────────────────
213
699e5c7Claude214orgRoutes.get("/orgs/:org", softAuth, requireAuth, async (c) => {
59b6fb2Claude215 const orgName = c.req.param("org");
216 const user = c.get("user");
217
218 let org: any;
219 try {
220 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
221 org = found;
222 } catch {
223 return c.notFound();
224 }
6563f0aClaude225
226 if (!org) return c.notFound();
227
59b6fb2Claude228 // Get members
229 let members: any[] = [];
230 try {
231 members = await db
232 .select({ member: orgMembers, user: { username: users.username, displayName: users.displayName } })
233 .from(orgMembers)
234 .innerJoin(users, eq(orgMembers.userId, users.id))
235 .where(eq(orgMembers.orgId, org.id))
236 .orderBy(asc(orgMembers.role));
237 } catch {
238 // Table may not exist
239 }
240
241 // Get teams
242 let teamList: any[] = [];
243 try {
244 teamList = await db.select().from(teams).where(eq(teams.orgId, org.id)).orderBy(asc(teams.name));
245 } catch {
246 // Table may not exist
247 }
248
249 const isMember = user && members.some((m: any) => m.member.userId === user.id);
250 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
6563f0aClaude251
252 return c.html(
59b6fb2Claude253 <Layout title={org.displayName || org.name} user={user}>
3e8f8e8Claude254 <Container maxWidth={900}>
255 <Flex gap={24} style="margin-bottom:32px">
256 <Avatar name={org.displayName || org.name} size={80} />
59b6fb2Claude257 <div>
258 <h2>{org.displayName || org.name}</h2>
3e8f8e8Claude259 <Text size={14} muted>@{org.name}</Text>
260 {org.description && <p style="margin-top:8px"><Text size={14} muted>{org.description}</Text></p>}
59b6fb2Claude261 {org.website && (
262 <a href={org.website} style="font-size:13px" target="_blank" rel="noopener noreferrer">
263 {org.website}
7437605Claude264 </a>
265 )}
266 </div>
59b6fb2Claude267 {isOwner && (
268 <div style="margin-left:auto">
3e8f8e8Claude269 <LinkButton href={`/orgs/${org.name}/settings`} size="sm">Settings</LinkButton>
59b6fb2Claude270 </div>
271 )}
3e8f8e8Claude272 </Flex>
6563f0aClaude273
3e8f8e8Claude274 <Grid cols="1fr 300px" gap={32}>
6563f0aClaude275 <div>
3e8f8e8Claude276 <Section title="Teams">
277 {teamList.length === 0 ? (
278 <EmptyState>
279 <p>No teams yet.</p>
280 {isOwner && <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create a team</LinkButton>}
281 </EmptyState>
282 ) : (
283 <List>
284 {teamList.map((team: any) => (
285 <ListItem>
286 <div>
287 <div style="font-weight:500;font-size:15px">
288 <a href={`/orgs/${org.name}/teams/${team.name}`} style="color:var(--text)">{team.name}</a>
289 </div>
290 {team.description && <Text size={13} muted>{team.description}</Text>}
59b6fb2Claude291 </div>
3e8f8e8Claude292 <Badge>{team.permission}</Badge>
293 </ListItem>
294 ))}
295 </List>
296 )}
297 {isOwner && teamList.length > 0 && (
298 <div style="margin-top:12px">
299 <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create team</LinkButton>
6563f0aClaude300 </div>
301 )}
3e8f8e8Claude302 </Section>
6563f0aClaude303 </div>
59b6fb2Claude304
6563f0aClaude305 <div>
3e8f8e8Claude306 <Section title={`Members (${members.length})`}>
307 <List>
308 {members.map((m: any) => (
309 <ListItem>
310 <Flex align="center" gap={8} style="width:100%">
311 <Avatar name={m.user.displayName || m.user.username} size={32} />
312 <div style="flex:1">
313 <a href={`/${m.user.username}`} style="font-size:14px;font-weight:500">{m.user.username}</a>
314 </div>
315 <Badge style="font-size:11px">{m.member.role}</Badge>
316 </Flex>
317 </ListItem>
318 ))}
319 </List>
320 {isOwner && (
321 <div style="margin-top:12px">
322 <LinkButton href={`/orgs/${org.name}/members/invite`} size="sm">Invite member</LinkButton>
6563f0aClaude323 </div>
324 )}
3e8f8e8Claude325 </Section>
6563f0aClaude326 </div>
3e8f8e8Claude327 </Grid>
328 </Container>
6563f0aClaude329 </Layout>
330 );
331});
332
333// --- PEOPLE -----------------------------------------------------------------
334
0316dbbClaude335orgRoutes.get("/orgs/:slug/people", async (c) => {
6563f0aClaude336 const user = c.get("user")!;
337 const slug = c.req.param("slug");
338 const { org, role } = await loadOrgForUser(slug, user.id);
339 if (!org) return c.notFound();
340 if (!role) return c.redirect(`/orgs/${slug}`);
341
342 const members = await listOrgMembers(org.id);
343 const error = c.req.query("error");
344 const success = c.req.query("success");
345 const canAdmin = orgRoleAtLeast(role, "admin");
346 const canOwner = orgRoleAtLeast(role, "owner");
347
348 return c.html(
349 <Layout title={`${org.name} — people`} user={user}>
350 <div style="max-width: 800px">
351 <div class="breadcrumb">
352 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
353 <span>/</span>
354 <span>people</span>
355 </div>
356 <h2>People ({members.length})</h2>
357 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
358 {success && (
359 <div class="auth-success">{decodeURIComponent(success)}</div>
360 )}
361
362 {canAdmin && (
363 <form
e7e240eClaude364 method="post"
6563f0aClaude365 action={`/orgs/${org.slug}/people/add`}
366 style="display: flex; gap: 8px; margin-bottom: 16px"
367 >
368 <input
369 type="text"
370 name="username"
371 placeholder="username to add"
372 required
373 maxLength={64}
5db1b25copilot-swe-agent[bot]374 aria-label="Username to add"
6563f0aClaude375 style="flex: 1"
376 />
377 <select name="role">
378 <option value="member">member</option>
379 <option value="admin">admin</option>
380 {canOwner && <option value="owner">owner</option>}
381 </select>
382 <button type="submit" class="btn btn-primary">
383 Add
384 </button>
385 </form>
386 )}
387
388 <div
389 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
390 >
391 {members.map((m) => (
392 <div
393 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
394 >
395 <div>
396 <a href={`/${m.username}`}>
397 <strong>{m.username}</strong>
398 </a>
399 {m.displayName && (
400 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
401 {m.displayName}
402 </span>
403 )}
404 </div>
405 <div style="display: flex; gap: 8px; align-items: center">
406 {canOwner && m.userId !== user.id ? (
407 <form
e7e240eClaude408 method="post"
6563f0aClaude409 action={`/orgs/${org.slug}/people/${m.userId}/role`}
410 style="display: flex; gap: 4px"
411 >
412 <select name="role">
413 <option value="member" selected={m.role === "member"}>
414 member
415 </option>
416 <option value="admin" selected={m.role === "admin"}>
417 admin
418 </option>
419 <option value="owner" selected={m.role === "owner"}>
420 owner
421 </option>
422 </select>
423 <button type="submit" class="btn btn-sm">
424 save
425 </button>
426 </form>
427 ) : (
428 <span
429 class="gate-status"
430 style="font-size: 11px; text-transform: uppercase"
431 >
432 {m.role}
433 </span>
434 )}
435 {canAdmin && m.userId !== user.id && (
436 <form
e7e240eClaude437 method="post"
6563f0aClaude438 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
439 style="display: inline"
440 onsubmit="return confirm('Remove this member?')"
441 >
442 <button type="submit" class="btn btn-sm btn-danger">
443 remove
444 </button>
445 </form>
446 )}
447 </div>
448 </div>
449 ))}
450 </div>
451 </div>
452 </Layout>
453 );
454});
455
59b6fb2Claude456orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
457 const orgName = c.req.param("org");
6563f0aClaude458 const user = c.get("user")!;
459
59b6fb2Claude460 let org: any;
6563f0aClaude461 try {
59b6fb2Claude462 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
463 org = found;
464 } catch {
465 return c.notFound();
6563f0aClaude466 }
467 if (!org) return c.notFound();
468
59b6fb2Claude469 // Check owner
470 let isOwner = false;
6563f0aClaude471 try {
59b6fb2Claude472 const [member] = await db.select().from(orgMembers)
473 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude474 .limit(1);
59b6fb2Claude475 isOwner = !!member;
476 } catch { /* */ }
477 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
6563f0aClaude478
479 const success = c.req.query("success");
0316dbbClaude480 const error = c.req.query("error");
481 const canAdmin = isOwner;
482 let orgTeams: any[] = [];
483 try {
484 orgTeams = await db.select().from(teams).where(eq(teams.orgId, org.id));
485 } catch { /* */ }
6563f0aClaude486
487 return c.html(
488 <Layout title={`${org.name} — teams`} user={user}>
489 <div style="max-width: 800px">
490 <div class="breadcrumb">
491 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
492 <span>/</span>
493 <span>teams</span>
494 </div>
495 <h2>Teams ({orgTeams.length})</h2>
496 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
497 {success && (
498 <div class="auth-success">{decodeURIComponent(success)}</div>
499 )}
500
501 {canAdmin && (
502 <form
e7e240eClaude503 method="post"
6563f0aClaude504 action={`/orgs/${org.slug}/teams/new`}
505 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
506 >
507 <input
508 type="text"
509 name="slug"
510 placeholder="team-slug"
511 required
512 maxLength={39}
513 pattern="[a-z0-9][a-z0-9-]{0,38}"
5db1b25copilot-swe-agent[bot]514 aria-label="Team slug"
6563f0aClaude515 />
516 <input
517 type="text"
518 name="name"
519 placeholder="Team name"
520 required
521 maxLength={80}
5db1b25copilot-swe-agent[bot]522 aria-label="Team name"
6563f0aClaude523 />
524 <button type="submit" class="btn btn-primary">
525 Create team
526 </button>
527 </form>
528 )}
529
530 <div
531 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
532 >
533 {orgTeams.length === 0 ? (
534 <div
535 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
536 >
537 No teams yet.
538 </div>
539 ) : (
540 orgTeams.map((t) => (
541 <a
542 href={`/orgs/${org.slug}/teams/${t.slug}`}
543 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
544 >
545 <strong>{t.name}</strong>{" "}
546 <span style="color: var(--text-muted); font-size: 12px">
547 @{t.slug}
548 </span>
549 {t.description && (
550 <div style="color: var(--text-muted); font-size: 12px">
551 {t.description}
552 </div>
553 )}
554 </a>
555 ))
556 )}
557 </div>
0316dbbClaude558 </div>
6563f0aClaude559 </Layout>
560 );
561});
562
59b6fb2Claude563orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
564 const orgName = c.req.param("org");
6563f0aClaude565 const user = c.get("user")!;
566 const body = await c.req.parseBody();
567
568 try {
59b6fb2Claude569 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
570 if (!org) return c.redirect("/");
571
572 const [member] = await db.select().from(orgMembers)
573 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
574 .limit(1);
575 if (!member) return c.redirect(`/orgs/${orgName}`);
576
577 await db.update(organizations).set({
578 displayName: String(body.displayName || "").trim() || org.name,
579 description: String(body.description || "").trim() || null,
580 website: String(body.website || "").trim() || null,
581 location: String(body.location || "").trim() || null,
582 updatedAt: new Date(),
583 }).where(eq(organizations.id, org.id));
584 } catch { /* */ }
585
586 return c.redirect(`/orgs/${orgName}/settings?success=1`);
6563f0aClaude587});
588
59b6fb2Claude589orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
590 const orgName = c.req.param("org");
6563f0aClaude591 const user = c.get("user")!;
592
593 try {
59b6fb2Claude594 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
595 if (!org) return c.redirect("/");
596
597 const [member] = await db.select().from(orgMembers)
598 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude599 .limit(1);
59b6fb2Claude600 if (!member) return c.redirect(`/orgs/${orgName}`);
601
602 await db.delete(organizations).where(eq(organizations.id, org.id));
603 } catch { /* */ }
6563f0aClaude604
59b6fb2Claude605 return c.redirect("/");
606});
607
608// ─── Member Invite ──────────────────────────────────────────────────────────
609
610orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
611 const orgName = c.req.param("org");
612 const user = c.get("user")!;
6563f0aClaude613 const error = c.req.query("error");
614 const success = c.req.query("success");
615
616 return c.html(
0316dbbClaude617 <Layout title={`Invite — ${orgName}`} user={user}>
618 <div style="max-width: 560px">
6563f0aClaude619 <div class="breadcrumb">
0316dbbClaude620 <a href={`/orgs/${orgName}`}>{orgName}</a>
6563f0aClaude621 <span>/</span>
0316dbbClaude622 <span>invite</span>
6563f0aClaude623 </div>
0316dbbClaude624 <h2>Invite a member</h2>
6563f0aClaude625 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
626 {success && (
627 <div class="auth-success">{decodeURIComponent(success)}</div>
628 )}
0316dbbClaude629 <form
630 method="post"
631 action={`/orgs/${orgName}/members/invite`}
632 style="display: flex; gap: 8px; margin-bottom: 16px"
6563f0aClaude633 >
0316dbbClaude634 <input
635 type="text"
636 name="username"
637 placeholder="username"
638 required
639 maxLength={64}
5db1b25copilot-swe-agent[bot]640 aria-label="Username to invite"
0316dbbClaude641 style="flex: 1"
642 />
643 <select name="role">
644 <option value="member">member</option>
645 <option value="admin">admin</option>
646 </select>
647 <button type="submit" class="btn btn-primary">
648 Invite
649 </button>
650 </form>
6563f0aClaude651 </div>
652 </Layout>
653 );
654});
655
59b6fb2Claude656orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
657 const orgName = c.req.param("org");
6563f0aClaude658 const user = c.get("user")!;
659 const body = await c.req.parseBody();
59b6fb2Claude660 const username = String(body.username || "").trim();
661 const role = String(body.role || "member");
6563f0aClaude662
663 try {
59b6fb2Claude664 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
665 if (!org) return c.redirect("/");
6563f0aClaude666
59b6fb2Claude667 // Check inviter is owner or admin
668 const [inviter] = await db.select().from(orgMembers)
669 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
6563f0aClaude670 .limit(1);
59b6fb2Claude671 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
672 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
6563f0aClaude673 }
674
59b6fb2Claude675 // Find user
676 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
677 if (!targetUser) {
678 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
6563f0aClaude679 }
680
59b6fb2Claude681 // Check if already member
682 const [existing] = await db.select().from(orgMembers)
683 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
6563f0aClaude684 .limit(1);
59b6fb2Claude685 if (existing) {
686 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
687 }
6563f0aClaude688
59b6fb2Claude689 await db.insert(orgMembers).values({
690 orgId: org.id,
691 userId: targetUser.id,
692 role: ["owner", "admin", "member"].includes(role) ? role : "member",
6563f0aClaude693 });
59b6fb2Claude694
695 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
696 } catch (err: any) {
697 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
6563f0aClaude698 }
699});
700
59b6fb2Claude701// ─── Team Create ────────────────────────────────────────────────────────────
7437605Claude702
59b6fb2Claude703orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
704 const orgName = c.req.param("org");
7437605Claude705 const user = c.get("user")!;
706 const error = c.req.query("error");
707
0316dbbClaude708 let org: any;
709 try {
710 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
711 org = found;
712 } catch {
713 return c.notFound();
714 }
715 if (!org) return c.notFound();
716
7437605Claude717 return c.html(
0316dbbClaude718 <Layout title={`New team — ${org.name}`} user={user}>
7437605Claude719 <div class="settings-container" style="max-width: 560px">
720 <div class="breadcrumb">
721 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
722 <span>/</span>
0316dbbClaude723 <span>new team</span>
7437605Claude724 </div>
0316dbbClaude725 <h2>Create team in {org.name}</h2>
7437605Claude726 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude727 <form method="post" action={`/orgs/${org.slug}/teams/new`}>
7437605Claude728 <div class="form-group">
0316dbbClaude729 <label for="name">Team name</label>
7437605Claude730 <input
731 type="text"
732 id="name"
733 name="name"
734 required
0316dbbClaude735 maxLength={80}
736 placeholder="Platform engineers"
7437605Claude737 autocomplete="off"
738 />
739 </div>
740 <div class="form-group">
741 <label for="description">Description (optional)</label>
742 <textarea
743 id="description"
744 name="description"
745 rows={3}
746 maxLength={500}
747 />
748 </div>
749 <div class="form-group">
0316dbbClaude750 <label for="permission">Default permission</label>
751 <select id="permission" name="permission">
752 <option value="read">read</option>
753 <option value="write">write</option>
754 <option value="admin">admin</option>
755 </select>
7437605Claude756 </div>
757 <button type="submit" class="btn btn-primary">
0316dbbClaude758 Create team
7437605Claude759 </button>
760 </form>
761 </div>
762 </Layout>
763 );
764});
765
59b6fb2Claude766orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
767 const orgName = c.req.param("org");
7437605Claude768 const user = c.get("user")!;
769 const body = await c.req.parseBody();
770 const name = String(body.name || "").trim();
59b6fb2Claude771 const description = String(body.description || "").trim();
772 const permission = String(body.permission || "read");
7437605Claude773
774 try {
59b6fb2Claude775 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
776 if (!org) return c.redirect("/");
777
778 const [member] = await db.select().from(orgMembers)
779 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
7437605Claude780 .limit(1);
59b6fb2Claude781 if (!member || member.role === "member") {
782 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
7437605Claude783 }
784
59b6fb2Claude785 if (!name) {
786 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
7437605Claude787 }
788
59b6fb2Claude789 await db.insert(teams).values({
790 orgId: org.id,
791 name,
792 description: description || null,
793 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
794 });
795
796 return c.redirect(`/orgs/${orgName}`);
797 } catch (err: any) {
798 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
7437605Claude799 }
800});
801
59b6fb2Claude802// ─── Team Detail ────────────────────────────────────────────────────────────
7437605Claude803
59b6fb2Claude804orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
805 const orgName = c.req.param("org");
806 const teamName = c.req.param("team");
807 const user = c.get("user");
808
809 let org: any, team: any;
7437605Claude810 try {
59b6fb2Claude811 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
812 if (!org) return c.notFound();
813 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
814 if (!team) return c.notFound();
815 } catch {
816 return c.notFound();
7437605Claude817 }
818
59b6fb2Claude819 let members: any[] = [];
820 try {
821 members = await db
822 .select({ member: teamMembers, user: { username: users.username } })
823 .from(teamMembers)
824 .innerJoin(users, eq(teamMembers.userId, users.id))
825 .where(eq(teamMembers.teamId, team.id));
826 } catch { /* */ }
827
828 let repos: any[] = [];
829 try {
830 repos = await db
831 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
832 .from(teamRepos)
833 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
834 .where(eq(teamRepos.teamId, team.id));
835 } catch { /* */ }
836
7437605Claude837 return c.html(
59b6fb2Claude838 <Layout title={`${teamName} — ${orgName}`} user={user}>
3e8f8e8Claude839 <Container maxWidth={800}>
840 <Section style="margin-bottom:24px">
841 <Text size={14} muted>
59b6fb2Claude842 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
3e8f8e8Claude843 </Text>
59b6fb2Claude844 <h2>{team.name}</h2>
3e8f8e8Claude845 {team.description && <p style="margin-top:4px"><Text muted>{team.description}</Text></p>}
846 <div style="margin-top:8px">
847 <Badge>{team.permission} access</Badge>
7437605Claude848 </div>
3e8f8e8Claude849 </Section>
59b6fb2Claude850
3e8f8e8Claude851 <Grid cols="1fr 1fr" gap={24}>
59b6fb2Claude852 <div>
3e8f8e8Claude853 <Section title={`Members (${members.length})`}>
854 {members.length === 0 ? (
855 <EmptyState><Text size={14} muted>No members yet.</Text></EmptyState>
856 ) : (
857 <List>
858 {members.map((m: any) => (
859 <ListItem>
860 <a href={`/${m.user.username}`}>{m.user.username}</a>
861 </ListItem>
862 ))}
863 </List>
864 )}
865 </Section>
7437605Claude866 </div>
59b6fb2Claude867 <div>
3e8f8e8Claude868 <Section title={`Repositories (${repos.length})`}>
869 {repos.length === 0 ? (
870 <EmptyState><Text size={14} muted>No repositories assigned.</Text></EmptyState>
871 ) : (
872 <List>
873 {repos.map((r: any) => (
874 <ListItem>
875 <span>{r.repo.name}</span>
876 <Badge style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</Badge>
877 </ListItem>
878 ))}
879 </List>
880 )}
881 </Section>
59b6fb2Claude882 </div>
3e8f8e8Claude883 </Grid>
884 </Container>
7437605Claude885 </Layout>
886 );
887});
888
59b6fb2Claude889export default orgRoutes;