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.tsxBlame821 lines · 1 contributor
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
38// ─── Organization List / Create ─────────────────────────────────────────────
39
40orgRoutes.get("/orgs/new", softAuth, requireAuth, (c) => {
6563f0aClaude41 const user = c.get("user")!;
42 const error = c.req.query("error");
43
44 return c.html(
45 <Layout title="New organization" user={user}>
46 <div class="settings-container" style="max-width: 560px">
47 <h2>Create organization</h2>
48 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
49 Organizations are multi-user namespaces. You'll be the owner and can
50 invite teammates after creation.
51 </p>
52 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
e7e240eClaude53 <form method="post" action="/orgs/new">
6563f0aClaude54 <div class="form-group">
55 <label for="slug">Slug</label>
56 <input
57 type="text"
58 id="slug"
59 name="slug"
60 required
61 maxLength={39}
62 pattern="[a-z0-9][a-z0-9-]{0,38}"
63 placeholder="acme-corp"
64 autocomplete="off"
65 />
66 <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px">
67 2–39 chars, lowercase letters, numbers, hyphens. Cannot start or
68 end with a hyphen.
69 </div>
70 </div>
71 <div class="form-group">
72 <label for="name">Display name</label>
73 <input
74 type="text"
75 id="name"
76 name="name"
77 required
78 maxLength={120}
79 placeholder="Acme Corp"
80 />
81 </div>
82 <div class="form-group">
83 <label for="description">Description (optional)</label>
84 <textarea
85 id="description"
86 name="description"
87 rows={3}
88 maxLength={500}
89 placeholder="What does this org do?"
90 />
91 </div>
92 <button type="submit" class="btn btn-primary">
93 Create organization
94 </button>
95 </form>
96 </div>
97 </Layout>
98 );
99});
100
59b6fb2Claude101orgRoutes.post("/orgs/new", softAuth, requireAuth, async (c) => {
6563f0aClaude102 const user = c.get("user")!;
103 const body = await c.req.parseBody();
104 const name = String(body.name || "").trim();
59b6fb2Claude105 const displayName = String(body.displayName || "").trim();
106 const description = String(body.description || "").trim();
107 const website = String(body.website || "").trim();
108
109 if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
110 return c.redirect("/orgs/new?error=Invalid+organization+name");
6563f0aClaude111 }
112
113 try {
59b6fb2Claude114 // Check if name is taken (by user or org)
115 const [existingUser] = await db.select().from(users).where(eq(users.username, name)).limit(1);
6563f0aClaude116 if (existingUser) {
59b6fb2Claude117 return c.redirect("/orgs/new?error=Name+already+taken");
118 }
119
120 const [existingOrg] = await db.select().from(organizations).where(eq(organizations.name, name)).limit(1);
121 if (existingOrg) {
122 return c.redirect("/orgs/new?error=Organization+already+exists");
6563f0aClaude123 }
124
125 const [org] = await db
126 .insert(organizations)
127 .values({
128 name,
59b6fb2Claude129 displayName: displayName || name,
130 description: description || null,
131 website: website || null,
6563f0aClaude132 })
133 .returning();
134
59b6fb2Claude135 // Add creator as owner
6563f0aClaude136 await db.insert(orgMembers).values({
137 orgId: org.id,
138 userId: user.id,
139 role: "owner",
140 });
141
59b6fb2Claude142 return c.redirect(`/orgs/${name}`);
6563f0aClaude143 } catch (err: any) {
59b6fb2Claude144 return c.redirect(`/orgs/new?error=${encodeURIComponent(err.message || "Failed to create organization")}`);
6563f0aClaude145 }
146});
147
59b6fb2Claude148// ─── Organization Profile ───────────────────────────────────────────────────
149
150orgRoutes.get("/orgs/:org", softAuth, async (c) => {
151 const orgName = c.req.param("org");
152 const user = c.get("user");
153
154 let org: any;
155 try {
156 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
157 org = found;
158 } catch {
159 return c.notFound();
160 }
6563f0aClaude161
162 if (!org) return c.notFound();
163
59b6fb2Claude164 // Get members
165 let members: any[] = [];
166 try {
167 members = await db
168 .select({ member: orgMembers, user: { username: users.username, displayName: users.displayName } })
169 .from(orgMembers)
170 .innerJoin(users, eq(orgMembers.userId, users.id))
171 .where(eq(orgMembers.orgId, org.id))
172 .orderBy(asc(orgMembers.role));
173 } catch {
174 // Table may not exist
175 }
176
177 // Get teams
178 let teamList: any[] = [];
179 try {
180 teamList = await db.select().from(teams).where(eq(teams.orgId, org.id)).orderBy(asc(teams.name));
181 } catch {
182 // Table may not exist
183 }
184
185 const isMember = user && members.some((m: any) => m.member.userId === user.id);
186 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
6563f0aClaude187
188 return c.html(
59b6fb2Claude189 <Layout title={org.displayName || org.name} user={user}>
3e8f8e8Claude190 <Container maxWidth={900}>
191 <Flex gap={24} style="margin-bottom:32px">
192 <Avatar name={org.displayName || org.name} size={80} />
59b6fb2Claude193 <div>
194 <h2>{org.displayName || org.name}</h2>
3e8f8e8Claude195 <Text size={14} muted>@{org.name}</Text>
196 {org.description && <p style="margin-top:8px"><Text size={14} muted>{org.description}</Text></p>}
59b6fb2Claude197 {org.website && (
198 <a href={org.website} style="font-size:13px" target="_blank" rel="noopener noreferrer">
199 {org.website}
7437605Claude200 </a>
201 )}
202 </div>
59b6fb2Claude203 {isOwner && (
204 <div style="margin-left:auto">
3e8f8e8Claude205 <LinkButton href={`/orgs/${org.name}/settings`} size="sm">Settings</LinkButton>
59b6fb2Claude206 </div>
207 )}
3e8f8e8Claude208 </Flex>
6563f0aClaude209
3e8f8e8Claude210 <Grid cols="1fr 300px" gap={32}>
6563f0aClaude211 <div>
3e8f8e8Claude212 <Section title="Teams">
213 {teamList.length === 0 ? (
214 <EmptyState>
215 <p>No teams yet.</p>
216 {isOwner && <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create a team</LinkButton>}
217 </EmptyState>
218 ) : (
219 <List>
220 {teamList.map((team: any) => (
221 <ListItem>
222 <div>
223 <div style="font-weight:500;font-size:15px">
224 <a href={`/orgs/${org.name}/teams/${team.name}`} style="color:var(--text)">{team.name}</a>
225 </div>
226 {team.description && <Text size={13} muted>{team.description}</Text>}
59b6fb2Claude227 </div>
3e8f8e8Claude228 <Badge>{team.permission}</Badge>
229 </ListItem>
230 ))}
231 </List>
232 )}
233 {isOwner && teamList.length > 0 && (
234 <div style="margin-top:12px">
235 <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create team</LinkButton>
6563f0aClaude236 </div>
237 )}
3e8f8e8Claude238 </Section>
6563f0aClaude239 </div>
59b6fb2Claude240
6563f0aClaude241 <div>
3e8f8e8Claude242 <Section title={`Members (${members.length})`}>
243 <List>
244 {members.map((m: any) => (
245 <ListItem>
246 <Flex align="center" gap={8} style="width:100%">
247 <Avatar name={m.user.displayName || m.user.username} size={32} />
248 <div style="flex:1">
249 <a href={`/${m.user.username}`} style="font-size:14px;font-weight:500">{m.user.username}</a>
250 </div>
251 <Badge style="font-size:11px">{m.member.role}</Badge>
252 </Flex>
253 </ListItem>
254 ))}
255 </List>
256 {isOwner && (
257 <div style="margin-top:12px">
258 <LinkButton href={`/orgs/${org.name}/members/invite`} size="sm">Invite member</LinkButton>
6563f0aClaude259 </div>
260 )}
3e8f8e8Claude261 </Section>
6563f0aClaude262 </div>
3e8f8e8Claude263 </Grid>
264 </Container>
6563f0aClaude265 </Layout>
266 );
267});
268
269// --- PEOPLE -----------------------------------------------------------------
270
0316dbbClaude271orgRoutes.get("/orgs/:slug/people", async (c) => {
6563f0aClaude272 const user = c.get("user")!;
273 const slug = c.req.param("slug");
274 const { org, role } = await loadOrgForUser(slug, user.id);
275 if (!org) return c.notFound();
276 if (!role) return c.redirect(`/orgs/${slug}`);
277
278 const members = await listOrgMembers(org.id);
279 const error = c.req.query("error");
280 const success = c.req.query("success");
281 const canAdmin = orgRoleAtLeast(role, "admin");
282 const canOwner = orgRoleAtLeast(role, "owner");
283
284 return c.html(
285 <Layout title={`${org.name} — people`} user={user}>
286 <div style="max-width: 800px">
287 <div class="breadcrumb">
288 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
289 <span>/</span>
290 <span>people</span>
291 </div>
292 <h2>People ({members.length})</h2>
293 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
294 {success && (
295 <div class="auth-success">{decodeURIComponent(success)}</div>
296 )}
297
298 {canAdmin && (
299 <form
e7e240eClaude300 method="post"
6563f0aClaude301 action={`/orgs/${org.slug}/people/add`}
302 style="display: flex; gap: 8px; margin-bottom: 16px"
303 >
304 <input
305 type="text"
306 name="username"
307 placeholder="username to add"
308 required
309 maxLength={64}
310 style="flex: 1"
311 />
312 <select name="role">
313 <option value="member">member</option>
314 <option value="admin">admin</option>
315 {canOwner && <option value="owner">owner</option>}
316 </select>
317 <button type="submit" class="btn btn-primary">
318 Add
319 </button>
320 </form>
321 )}
322
323 <div
324 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
325 >
326 {members.map((m) => (
327 <div
328 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
329 >
330 <div>
331 <a href={`/${m.username}`}>
332 <strong>{m.username}</strong>
333 </a>
334 {m.displayName && (
335 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
336 {m.displayName}
337 </span>
338 )}
339 </div>
340 <div style="display: flex; gap: 8px; align-items: center">
341 {canOwner && m.userId !== user.id ? (
342 <form
e7e240eClaude343 method="post"
6563f0aClaude344 action={`/orgs/${org.slug}/people/${m.userId}/role`}
345 style="display: flex; gap: 4px"
346 >
347 <select name="role">
348 <option value="member" selected={m.role === "member"}>
349 member
350 </option>
351 <option value="admin" selected={m.role === "admin"}>
352 admin
353 </option>
354 <option value="owner" selected={m.role === "owner"}>
355 owner
356 </option>
357 </select>
358 <button type="submit" class="btn btn-sm">
359 save
360 </button>
361 </form>
362 ) : (
363 <span
364 class="gate-status"
365 style="font-size: 11px; text-transform: uppercase"
366 >
367 {m.role}
368 </span>
369 )}
370 {canAdmin && m.userId !== user.id && (
371 <form
e7e240eClaude372 method="post"
6563f0aClaude373 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
374 style="display: inline"
375 onsubmit="return confirm('Remove this member?')"
376 >
377 <button type="submit" class="btn btn-sm btn-danger">
378 remove
379 </button>
380 </form>
381 )}
382 </div>
383 </div>
384 ))}
385 </div>
386 </div>
387 </Layout>
388 );
389});
390
59b6fb2Claude391orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
392 const orgName = c.req.param("org");
6563f0aClaude393 const user = c.get("user")!;
394
59b6fb2Claude395 let org: any;
6563f0aClaude396 try {
59b6fb2Claude397 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
398 org = found;
399 } catch {
400 return c.notFound();
6563f0aClaude401 }
402 if (!org) return c.notFound();
403
59b6fb2Claude404 // Check owner
405 let isOwner = false;
6563f0aClaude406 try {
59b6fb2Claude407 const [member] = await db.select().from(orgMembers)
408 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude409 .limit(1);
59b6fb2Claude410 isOwner = !!member;
411 } catch { /* */ }
412 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
6563f0aClaude413
414 const success = c.req.query("success");
0316dbbClaude415 const error = c.req.query("error");
416 const canAdmin = isOwner;
417 let orgTeams: any[] = [];
418 try {
419 orgTeams = await db.select().from(teams).where(eq(teams.orgId, org.id));
420 } catch { /* */ }
6563f0aClaude421
422 return c.html(
423 <Layout title={`${org.name} — teams`} user={user}>
424 <div style="max-width: 800px">
425 <div class="breadcrumb">
426 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
427 <span>/</span>
428 <span>teams</span>
429 </div>
430 <h2>Teams ({orgTeams.length})</h2>
431 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
432 {success && (
433 <div class="auth-success">{decodeURIComponent(success)}</div>
434 )}
435
436 {canAdmin && (
437 <form
e7e240eClaude438 method="post"
6563f0aClaude439 action={`/orgs/${org.slug}/teams/new`}
440 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
441 >
442 <input
443 type="text"
444 name="slug"
445 placeholder="team-slug"
446 required
447 maxLength={39}
448 pattern="[a-z0-9][a-z0-9-]{0,38}"
449 />
450 <input
451 type="text"
452 name="name"
453 placeholder="Team name"
454 required
455 maxLength={80}
456 />
457 <button type="submit" class="btn btn-primary">
458 Create team
459 </button>
460 </form>
461 )}
462
463 <div
464 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
465 >
466 {orgTeams.length === 0 ? (
467 <div
468 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
469 >
470 No teams yet.
471 </div>
472 ) : (
473 orgTeams.map((t) => (
474 <a
475 href={`/orgs/${org.slug}/teams/${t.slug}`}
476 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
477 >
478 <strong>{t.name}</strong>{" "}
479 <span style="color: var(--text-muted); font-size: 12px">
480 @{t.slug}
481 </span>
482 {t.description && (
483 <div style="color: var(--text-muted); font-size: 12px">
484 {t.description}
485 </div>
486 )}
487 </a>
488 ))
489 )}
490 </div>
0316dbbClaude491 </div>
6563f0aClaude492 </Layout>
493 );
494});
495
59b6fb2Claude496orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
497 const orgName = c.req.param("org");
6563f0aClaude498 const user = c.get("user")!;
499 const body = await c.req.parseBody();
500
501 try {
59b6fb2Claude502 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
503 if (!org) return c.redirect("/");
504
505 const [member] = await db.select().from(orgMembers)
506 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
507 .limit(1);
508 if (!member) return c.redirect(`/orgs/${orgName}`);
509
510 await db.update(organizations).set({
511 displayName: String(body.displayName || "").trim() || org.name,
512 description: String(body.description || "").trim() || null,
513 website: String(body.website || "").trim() || null,
514 location: String(body.location || "").trim() || null,
515 updatedAt: new Date(),
516 }).where(eq(organizations.id, org.id));
517 } catch { /* */ }
518
519 return c.redirect(`/orgs/${orgName}/settings?success=1`);
6563f0aClaude520});
521
59b6fb2Claude522orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
523 const orgName = c.req.param("org");
6563f0aClaude524 const user = c.get("user")!;
525
526 try {
59b6fb2Claude527 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
528 if (!org) return c.redirect("/");
529
530 const [member] = await db.select().from(orgMembers)
531 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude532 .limit(1);
59b6fb2Claude533 if (!member) return c.redirect(`/orgs/${orgName}`);
534
535 await db.delete(organizations).where(eq(organizations.id, org.id));
536 } catch { /* */ }
6563f0aClaude537
59b6fb2Claude538 return c.redirect("/");
539});
540
541// ─── Member Invite ──────────────────────────────────────────────────────────
542
543orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
544 const orgName = c.req.param("org");
545 const user = c.get("user")!;
6563f0aClaude546 const error = c.req.query("error");
547 const success = c.req.query("success");
548
549 return c.html(
0316dbbClaude550 <Layout title={`Invite — ${orgName}`} user={user}>
551 <div style="max-width: 560px">
6563f0aClaude552 <div class="breadcrumb">
0316dbbClaude553 <a href={`/orgs/${orgName}`}>{orgName}</a>
6563f0aClaude554 <span>/</span>
0316dbbClaude555 <span>invite</span>
6563f0aClaude556 </div>
0316dbbClaude557 <h2>Invite a member</h2>
6563f0aClaude558 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
559 {success && (
560 <div class="auth-success">{decodeURIComponent(success)}</div>
561 )}
0316dbbClaude562 <form
563 method="post"
564 action={`/orgs/${orgName}/members/invite`}
565 style="display: flex; gap: 8px; margin-bottom: 16px"
6563f0aClaude566 >
0316dbbClaude567 <input
568 type="text"
569 name="username"
570 placeholder="username"
571 required
572 maxLength={64}
573 style="flex: 1"
574 />
575 <select name="role">
576 <option value="member">member</option>
577 <option value="admin">admin</option>
578 </select>
579 <button type="submit" class="btn btn-primary">
580 Invite
581 </button>
582 </form>
6563f0aClaude583 </div>
584 </Layout>
585 );
586});
587
59b6fb2Claude588orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
589 const orgName = c.req.param("org");
6563f0aClaude590 const user = c.get("user")!;
591 const body = await c.req.parseBody();
59b6fb2Claude592 const username = String(body.username || "").trim();
593 const role = String(body.role || "member");
6563f0aClaude594
595 try {
59b6fb2Claude596 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
597 if (!org) return c.redirect("/");
6563f0aClaude598
59b6fb2Claude599 // Check inviter is owner or admin
600 const [inviter] = await db.select().from(orgMembers)
601 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
6563f0aClaude602 .limit(1);
59b6fb2Claude603 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
604 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
6563f0aClaude605 }
606
59b6fb2Claude607 // Find user
608 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
609 if (!targetUser) {
610 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
6563f0aClaude611 }
612
59b6fb2Claude613 // Check if already member
614 const [existing] = await db.select().from(orgMembers)
615 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
6563f0aClaude616 .limit(1);
59b6fb2Claude617 if (existing) {
618 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
619 }
6563f0aClaude620
59b6fb2Claude621 await db.insert(orgMembers).values({
622 orgId: org.id,
623 userId: targetUser.id,
624 role: ["owner", "admin", "member"].includes(role) ? role : "member",
6563f0aClaude625 });
59b6fb2Claude626
627 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
628 } catch (err: any) {
629 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
6563f0aClaude630 }
631});
632
59b6fb2Claude633// ─── Team Create ────────────────────────────────────────────────────────────
7437605Claude634
59b6fb2Claude635orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
636 const orgName = c.req.param("org");
7437605Claude637 const user = c.get("user")!;
638 const error = c.req.query("error");
639
0316dbbClaude640 let org: any;
641 try {
642 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
643 org = found;
644 } catch {
645 return c.notFound();
646 }
647 if (!org) return c.notFound();
648
7437605Claude649 return c.html(
0316dbbClaude650 <Layout title={`New team — ${org.name}`} user={user}>
7437605Claude651 <div class="settings-container" style="max-width: 560px">
652 <div class="breadcrumb">
653 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
654 <span>/</span>
0316dbbClaude655 <span>new team</span>
7437605Claude656 </div>
0316dbbClaude657 <h2>Create team in {org.name}</h2>
7437605Claude658 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude659 <form method="post" action={`/orgs/${org.slug}/teams/new`}>
7437605Claude660 <div class="form-group">
0316dbbClaude661 <label for="name">Team name</label>
7437605Claude662 <input
663 type="text"
664 id="name"
665 name="name"
666 required
0316dbbClaude667 maxLength={80}
668 placeholder="Platform engineers"
7437605Claude669 autocomplete="off"
670 />
671 </div>
672 <div class="form-group">
673 <label for="description">Description (optional)</label>
674 <textarea
675 id="description"
676 name="description"
677 rows={3}
678 maxLength={500}
679 />
680 </div>
681 <div class="form-group">
0316dbbClaude682 <label for="permission">Default permission</label>
683 <select id="permission" name="permission">
684 <option value="read">read</option>
685 <option value="write">write</option>
686 <option value="admin">admin</option>
687 </select>
7437605Claude688 </div>
689 <button type="submit" class="btn btn-primary">
0316dbbClaude690 Create team
7437605Claude691 </button>
692 </form>
693 </div>
694 </Layout>
695 );
696});
697
59b6fb2Claude698orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
699 const orgName = c.req.param("org");
7437605Claude700 const user = c.get("user")!;
701 const body = await c.req.parseBody();
702 const name = String(body.name || "").trim();
59b6fb2Claude703 const description = String(body.description || "").trim();
704 const permission = String(body.permission || "read");
7437605Claude705
706 try {
59b6fb2Claude707 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
708 if (!org) return c.redirect("/");
709
710 const [member] = await db.select().from(orgMembers)
711 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
7437605Claude712 .limit(1);
59b6fb2Claude713 if (!member || member.role === "member") {
714 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
7437605Claude715 }
716
59b6fb2Claude717 if (!name) {
718 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
7437605Claude719 }
720
59b6fb2Claude721 await db.insert(teams).values({
722 orgId: org.id,
723 name,
724 description: description || null,
725 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
726 });
727
728 return c.redirect(`/orgs/${orgName}`);
729 } catch (err: any) {
730 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
7437605Claude731 }
732});
733
59b6fb2Claude734// ─── Team Detail ────────────────────────────────────────────────────────────
7437605Claude735
59b6fb2Claude736orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
737 const orgName = c.req.param("org");
738 const teamName = c.req.param("team");
739 const user = c.get("user");
740
741 let org: any, team: any;
7437605Claude742 try {
59b6fb2Claude743 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
744 if (!org) return c.notFound();
745 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
746 if (!team) return c.notFound();
747 } catch {
748 return c.notFound();
7437605Claude749 }
750
59b6fb2Claude751 let members: any[] = [];
752 try {
753 members = await db
754 .select({ member: teamMembers, user: { username: users.username } })
755 .from(teamMembers)
756 .innerJoin(users, eq(teamMembers.userId, users.id))
757 .where(eq(teamMembers.teamId, team.id));
758 } catch { /* */ }
759
760 let repos: any[] = [];
761 try {
762 repos = await db
763 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
764 .from(teamRepos)
765 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
766 .where(eq(teamRepos.teamId, team.id));
767 } catch { /* */ }
768
7437605Claude769 return c.html(
59b6fb2Claude770 <Layout title={`${teamName} — ${orgName}`} user={user}>
3e8f8e8Claude771 <Container maxWidth={800}>
772 <Section style="margin-bottom:24px">
773 <Text size={14} muted>
59b6fb2Claude774 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
3e8f8e8Claude775 </Text>
59b6fb2Claude776 <h2>{team.name}</h2>
3e8f8e8Claude777 {team.description && <p style="margin-top:4px"><Text muted>{team.description}</Text></p>}
778 <div style="margin-top:8px">
779 <Badge>{team.permission} access</Badge>
7437605Claude780 </div>
3e8f8e8Claude781 </Section>
59b6fb2Claude782
3e8f8e8Claude783 <Grid cols="1fr 1fr" gap={24}>
59b6fb2Claude784 <div>
3e8f8e8Claude785 <Section title={`Members (${members.length})`}>
786 {members.length === 0 ? (
787 <EmptyState><Text size={14} muted>No members yet.</Text></EmptyState>
788 ) : (
789 <List>
790 {members.map((m: any) => (
791 <ListItem>
792 <a href={`/${m.user.username}`}>{m.user.username}</a>
793 </ListItem>
794 ))}
795 </List>
796 )}
797 </Section>
7437605Claude798 </div>
59b6fb2Claude799 <div>
3e8f8e8Claude800 <Section title={`Repositories (${repos.length})`}>
801 {repos.length === 0 ? (
802 <EmptyState><Text size={14} muted>No repositories assigned.</Text></EmptyState>
803 ) : (
804 <List>
805 {repos.map((r: any) => (
806 <ListItem>
807 <span>{r.repo.name}</span>
808 <Badge style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</Badge>
809 </ListItem>
810 ))}
811 </List>
812 )}
813 </Section>
59b6fb2Claude814 </div>
3e8f8e8Claude815 </Grid>
816 </Container>
7437605Claude817 </Layout>
818 );
819});
820
59b6fb2Claude821export default orgRoutes;