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.tsxBlame893 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) => {
976d7f7Claude336 const user = c.get("user");
6563f0aClaude337 const slug = c.req.param("slug");
976d7f7Claude338 // Anonymous users get bounced to login; org membership is non-public.
339 // Previously dereferenced `user!.id` immediately and crashed for anon
340 // (smoke crawl: TypeError, null is not an object — orgs.tsx:338).
341 if (!user) return c.redirect(`/login?redirect=/orgs/${slug}/people`);
6563f0aClaude342 const { org, role } = await loadOrgForUser(slug, user.id);
343 if (!org) return c.notFound();
344 if (!role) return c.redirect(`/orgs/${slug}`);
345
346 const members = await listOrgMembers(org.id);
347 const error = c.req.query("error");
348 const success = c.req.query("success");
349 const canAdmin = orgRoleAtLeast(role, "admin");
350 const canOwner = orgRoleAtLeast(role, "owner");
351
352 return c.html(
353 <Layout title={`${org.name} — people`} user={user}>
354 <div style="max-width: 800px">
355 <div class="breadcrumb">
356 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
357 <span>/</span>
358 <span>people</span>
359 </div>
360 <h2>People ({members.length})</h2>
361 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
362 {success && (
363 <div class="auth-success">{decodeURIComponent(success)}</div>
364 )}
365
366 {canAdmin && (
367 <form
e7e240eClaude368 method="post"
6563f0aClaude369 action={`/orgs/${org.slug}/people/add`}
370 style="display: flex; gap: 8px; margin-bottom: 16px"
371 >
372 <input
373 type="text"
374 name="username"
375 placeholder="username to add"
376 required
377 maxLength={64}
5db1b25copilot-swe-agent[bot]378 aria-label="Username to add"
6563f0aClaude379 style="flex: 1"
380 />
381 <select name="role">
382 <option value="member">member</option>
383 <option value="admin">admin</option>
384 {canOwner && <option value="owner">owner</option>}
385 </select>
386 <button type="submit" class="btn btn-primary">
387 Add
388 </button>
389 </form>
390 )}
391
392 <div
393 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
394 >
395 {members.map((m) => (
396 <div
397 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
398 >
399 <div>
400 <a href={`/${m.username}`}>
401 <strong>{m.username}</strong>
402 </a>
403 {m.displayName && (
404 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
405 {m.displayName}
406 </span>
407 )}
408 </div>
409 <div style="display: flex; gap: 8px; align-items: center">
410 {canOwner && m.userId !== user.id ? (
411 <form
e7e240eClaude412 method="post"
6563f0aClaude413 action={`/orgs/${org.slug}/people/${m.userId}/role`}
414 style="display: flex; gap: 4px"
415 >
416 <select name="role">
417 <option value="member" selected={m.role === "member"}>
418 member
419 </option>
420 <option value="admin" selected={m.role === "admin"}>
421 admin
422 </option>
423 <option value="owner" selected={m.role === "owner"}>
424 owner
425 </option>
426 </select>
427 <button type="submit" class="btn btn-sm">
428 save
429 </button>
430 </form>
431 ) : (
432 <span
433 class="gate-status"
434 style="font-size: 11px; text-transform: uppercase"
435 >
436 {m.role}
437 </span>
438 )}
439 {canAdmin && m.userId !== user.id && (
440 <form
e7e240eClaude441 method="post"
6563f0aClaude442 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
443 style="display: inline"
444 onsubmit="return confirm('Remove this member?')"
445 >
446 <button type="submit" class="btn btn-sm btn-danger">
447 remove
448 </button>
449 </form>
450 )}
451 </div>
452 </div>
453 ))}
454 </div>
455 </div>
456 </Layout>
457 );
458});
459
59b6fb2Claude460orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
461 const orgName = c.req.param("org");
6563f0aClaude462 const user = c.get("user")!;
463
59b6fb2Claude464 let org: any;
6563f0aClaude465 try {
59b6fb2Claude466 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
467 org = found;
468 } catch {
469 return c.notFound();
6563f0aClaude470 }
471 if (!org) return c.notFound();
472
59b6fb2Claude473 // Check owner
474 let isOwner = false;
6563f0aClaude475 try {
59b6fb2Claude476 const [member] = await db.select().from(orgMembers)
477 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude478 .limit(1);
59b6fb2Claude479 isOwner = !!member;
480 } catch { /* */ }
481 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
6563f0aClaude482
483 const success = c.req.query("success");
0316dbbClaude484 const error = c.req.query("error");
485 const canAdmin = isOwner;
486 let orgTeams: any[] = [];
487 try {
488 orgTeams = await db.select().from(teams).where(eq(teams.orgId, org.id));
489 } catch { /* */ }
6563f0aClaude490
491 return c.html(
492 <Layout title={`${org.name} — teams`} user={user}>
493 <div style="max-width: 800px">
494 <div class="breadcrumb">
495 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
496 <span>/</span>
497 <span>teams</span>
498 </div>
499 <h2>Teams ({orgTeams.length})</h2>
500 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
501 {success && (
502 <div class="auth-success">{decodeURIComponent(success)}</div>
503 )}
504
505 {canAdmin && (
506 <form
e7e240eClaude507 method="post"
6563f0aClaude508 action={`/orgs/${org.slug}/teams/new`}
509 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
510 >
511 <input
512 type="text"
513 name="slug"
514 placeholder="team-slug"
515 required
516 maxLength={39}
517 pattern="[a-z0-9][a-z0-9-]{0,38}"
5db1b25copilot-swe-agent[bot]518 aria-label="Team slug"
6563f0aClaude519 />
520 <input
521 type="text"
522 name="name"
523 placeholder="Team name"
524 required
525 maxLength={80}
5db1b25copilot-swe-agent[bot]526 aria-label="Team name"
6563f0aClaude527 />
528 <button type="submit" class="btn btn-primary">
529 Create team
530 </button>
531 </form>
532 )}
533
534 <div
535 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
536 >
537 {orgTeams.length === 0 ? (
538 <div
539 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
540 >
541 No teams yet.
542 </div>
543 ) : (
544 orgTeams.map((t) => (
545 <a
546 href={`/orgs/${org.slug}/teams/${t.slug}`}
547 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
548 >
549 <strong>{t.name}</strong>{" "}
550 <span style="color: var(--text-muted); font-size: 12px">
551 @{t.slug}
552 </span>
553 {t.description && (
554 <div style="color: var(--text-muted); font-size: 12px">
555 {t.description}
556 </div>
557 )}
558 </a>
559 ))
560 )}
561 </div>
0316dbbClaude562 </div>
6563f0aClaude563 </Layout>
564 );
565});
566
59b6fb2Claude567orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
568 const orgName = c.req.param("org");
6563f0aClaude569 const user = c.get("user")!;
570 const body = await c.req.parseBody();
571
572 try {
59b6fb2Claude573 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
574 if (!org) return c.redirect("/");
575
576 const [member] = await db.select().from(orgMembers)
577 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
578 .limit(1);
579 if (!member) return c.redirect(`/orgs/${orgName}`);
580
581 await db.update(organizations).set({
582 displayName: String(body.displayName || "").trim() || org.name,
583 description: String(body.description || "").trim() || null,
584 website: String(body.website || "").trim() || null,
585 location: String(body.location || "").trim() || null,
586 updatedAt: new Date(),
587 }).where(eq(organizations.id, org.id));
588 } catch { /* */ }
589
590 return c.redirect(`/orgs/${orgName}/settings?success=1`);
6563f0aClaude591});
592
59b6fb2Claude593orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
594 const orgName = c.req.param("org");
6563f0aClaude595 const user = c.get("user")!;
596
597 try {
59b6fb2Claude598 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
599 if (!org) return c.redirect("/");
600
601 const [member] = await db.select().from(orgMembers)
602 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude603 .limit(1);
59b6fb2Claude604 if (!member) return c.redirect(`/orgs/${orgName}`);
605
606 await db.delete(organizations).where(eq(organizations.id, org.id));
607 } catch { /* */ }
6563f0aClaude608
59b6fb2Claude609 return c.redirect("/");
610});
611
612// ─── Member Invite ──────────────────────────────────────────────────────────
613
614orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
615 const orgName = c.req.param("org");
616 const user = c.get("user")!;
6563f0aClaude617 const error = c.req.query("error");
618 const success = c.req.query("success");
619
620 return c.html(
0316dbbClaude621 <Layout title={`Invite — ${orgName}`} user={user}>
622 <div style="max-width: 560px">
6563f0aClaude623 <div class="breadcrumb">
0316dbbClaude624 <a href={`/orgs/${orgName}`}>{orgName}</a>
6563f0aClaude625 <span>/</span>
0316dbbClaude626 <span>invite</span>
6563f0aClaude627 </div>
0316dbbClaude628 <h2>Invite a member</h2>
6563f0aClaude629 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
630 {success && (
631 <div class="auth-success">{decodeURIComponent(success)}</div>
632 )}
0316dbbClaude633 <form
634 method="post"
635 action={`/orgs/${orgName}/members/invite`}
636 style="display: flex; gap: 8px; margin-bottom: 16px"
6563f0aClaude637 >
0316dbbClaude638 <input
639 type="text"
640 name="username"
641 placeholder="username"
642 required
643 maxLength={64}
5db1b25copilot-swe-agent[bot]644 aria-label="Username to invite"
0316dbbClaude645 style="flex: 1"
646 />
647 <select name="role">
648 <option value="member">member</option>
649 <option value="admin">admin</option>
650 </select>
651 <button type="submit" class="btn btn-primary">
652 Invite
653 </button>
654 </form>
6563f0aClaude655 </div>
656 </Layout>
657 );
658});
659
59b6fb2Claude660orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
661 const orgName = c.req.param("org");
6563f0aClaude662 const user = c.get("user")!;
663 const body = await c.req.parseBody();
59b6fb2Claude664 const username = String(body.username || "").trim();
665 const role = String(body.role || "member");
6563f0aClaude666
667 try {
59b6fb2Claude668 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
669 if (!org) return c.redirect("/");
6563f0aClaude670
59b6fb2Claude671 // Check inviter is owner or admin
672 const [inviter] = await db.select().from(orgMembers)
673 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
6563f0aClaude674 .limit(1);
59b6fb2Claude675 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
676 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
6563f0aClaude677 }
678
59b6fb2Claude679 // Find user
680 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
681 if (!targetUser) {
682 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
6563f0aClaude683 }
684
59b6fb2Claude685 // Check if already member
686 const [existing] = await db.select().from(orgMembers)
687 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
6563f0aClaude688 .limit(1);
59b6fb2Claude689 if (existing) {
690 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
691 }
6563f0aClaude692
59b6fb2Claude693 await db.insert(orgMembers).values({
694 orgId: org.id,
695 userId: targetUser.id,
696 role: ["owner", "admin", "member"].includes(role) ? role : "member",
6563f0aClaude697 });
59b6fb2Claude698
699 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
700 } catch (err: any) {
701 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
6563f0aClaude702 }
703});
704
59b6fb2Claude705// ─── Team Create ────────────────────────────────────────────────────────────
7437605Claude706
59b6fb2Claude707orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
708 const orgName = c.req.param("org");
7437605Claude709 const user = c.get("user")!;
710 const error = c.req.query("error");
711
0316dbbClaude712 let org: any;
713 try {
714 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
715 org = found;
716 } catch {
717 return c.notFound();
718 }
719 if (!org) return c.notFound();
720
7437605Claude721 return c.html(
0316dbbClaude722 <Layout title={`New team — ${org.name}`} user={user}>
7437605Claude723 <div class="settings-container" style="max-width: 560px">
724 <div class="breadcrumb">
725 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
726 <span>/</span>
0316dbbClaude727 <span>new team</span>
7437605Claude728 </div>
0316dbbClaude729 <h2>Create team in {org.name}</h2>
7437605Claude730 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude731 <form method="post" action={`/orgs/${org.slug}/teams/new`}>
7437605Claude732 <div class="form-group">
0316dbbClaude733 <label for="name">Team name</label>
7437605Claude734 <input
735 type="text"
736 id="name"
737 name="name"
738 required
0316dbbClaude739 maxLength={80}
740 placeholder="Platform engineers"
7437605Claude741 autocomplete="off"
742 />
743 </div>
744 <div class="form-group">
745 <label for="description">Description (optional)</label>
746 <textarea
747 id="description"
748 name="description"
749 rows={3}
750 maxLength={500}
751 />
752 </div>
753 <div class="form-group">
0316dbbClaude754 <label for="permission">Default permission</label>
755 <select id="permission" name="permission">
756 <option value="read">read</option>
757 <option value="write">write</option>
758 <option value="admin">admin</option>
759 </select>
7437605Claude760 </div>
761 <button type="submit" class="btn btn-primary">
0316dbbClaude762 Create team
7437605Claude763 </button>
764 </form>
765 </div>
766 </Layout>
767 );
768});
769
59b6fb2Claude770orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
771 const orgName = c.req.param("org");
7437605Claude772 const user = c.get("user")!;
773 const body = await c.req.parseBody();
774 const name = String(body.name || "").trim();
59b6fb2Claude775 const description = String(body.description || "").trim();
776 const permission = String(body.permission || "read");
7437605Claude777
778 try {
59b6fb2Claude779 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
780 if (!org) return c.redirect("/");
781
782 const [member] = await db.select().from(orgMembers)
783 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
7437605Claude784 .limit(1);
59b6fb2Claude785 if (!member || member.role === "member") {
786 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
7437605Claude787 }
788
59b6fb2Claude789 if (!name) {
790 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
7437605Claude791 }
792
59b6fb2Claude793 await db.insert(teams).values({
794 orgId: org.id,
795 name,
796 description: description || null,
797 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
798 });
799
800 return c.redirect(`/orgs/${orgName}`);
801 } catch (err: any) {
802 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
7437605Claude803 }
804});
805
59b6fb2Claude806// ─── Team Detail ────────────────────────────────────────────────────────────
7437605Claude807
59b6fb2Claude808orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
809 const orgName = c.req.param("org");
810 const teamName = c.req.param("team");
811 const user = c.get("user");
812
813 let org: any, team: any;
7437605Claude814 try {
59b6fb2Claude815 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
816 if (!org) return c.notFound();
817 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
818 if (!team) return c.notFound();
819 } catch {
820 return c.notFound();
7437605Claude821 }
822
59b6fb2Claude823 let members: any[] = [];
824 try {
825 members = await db
826 .select({ member: teamMembers, user: { username: users.username } })
827 .from(teamMembers)
828 .innerJoin(users, eq(teamMembers.userId, users.id))
829 .where(eq(teamMembers.teamId, team.id));
830 } catch { /* */ }
831
832 let repos: any[] = [];
833 try {
834 repos = await db
835 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
836 .from(teamRepos)
837 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
838 .where(eq(teamRepos.teamId, team.id));
839 } catch { /* */ }
840
7437605Claude841 return c.html(
59b6fb2Claude842 <Layout title={`${teamName} — ${orgName}`} user={user}>
3e8f8e8Claude843 <Container maxWidth={800}>
844 <Section style="margin-bottom:24px">
845 <Text size={14} muted>
59b6fb2Claude846 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
3e8f8e8Claude847 </Text>
59b6fb2Claude848 <h2>{team.name}</h2>
3e8f8e8Claude849 {team.description && <p style="margin-top:4px"><Text muted>{team.description}</Text></p>}
850 <div style="margin-top:8px">
851 <Badge>{team.permission} access</Badge>
7437605Claude852 </div>
3e8f8e8Claude853 </Section>
59b6fb2Claude854
3e8f8e8Claude855 <Grid cols="1fr 1fr" gap={24}>
59b6fb2Claude856 <div>
3e8f8e8Claude857 <Section title={`Members (${members.length})`}>
858 {members.length === 0 ? (
859 <EmptyState><Text size={14} muted>No members yet.</Text></EmptyState>
860 ) : (
861 <List>
862 {members.map((m: any) => (
863 <ListItem>
864 <a href={`/${m.user.username}`}>{m.user.username}</a>
865 </ListItem>
866 ))}
867 </List>
868 )}
869 </Section>
7437605Claude870 </div>
59b6fb2Claude871 <div>
3e8f8e8Claude872 <Section title={`Repositories (${repos.length})`}>
873 {repos.length === 0 ? (
874 <EmptyState><Text size={14} muted>No repositories assigned.</Text></EmptyState>
875 ) : (
876 <List>
877 {repos.map((r: any) => (
878 <ListItem>
879 <span>{r.repo.name}</span>
880 <Badge style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</Badge>
881 </ListItem>
882 ))}
883 </List>
884 )}
885 </Section>
59b6fb2Claude886 </div>
3e8f8e8Claude887 </Grid>
888 </Container>
7437605Claude889 </Layout>
890 );
891});
892
59b6fb2Claude893export default orgRoutes;