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.tsxBlame885 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
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}
374 style="flex: 1"
375 />
376 <select name="role">
377 <option value="member">member</option>
378 <option value="admin">admin</option>
379 {canOwner && <option value="owner">owner</option>}
380 </select>
381 <button type="submit" class="btn btn-primary">
382 Add
383 </button>
384 </form>
385 )}
386
387 <div
388 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
389 >
390 {members.map((m) => (
391 <div
392 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
393 >
394 <div>
395 <a href={`/${m.username}`}>
396 <strong>{m.username}</strong>
397 </a>
398 {m.displayName && (
399 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
400 {m.displayName}
401 </span>
402 )}
403 </div>
404 <div style="display: flex; gap: 8px; align-items: center">
405 {canOwner && m.userId !== user.id ? (
406 <form
e7e240eClaude407 method="post"
6563f0aClaude408 action={`/orgs/${org.slug}/people/${m.userId}/role`}
409 style="display: flex; gap: 4px"
410 >
411 <select name="role">
412 <option value="member" selected={m.role === "member"}>
413 member
414 </option>
415 <option value="admin" selected={m.role === "admin"}>
416 admin
417 </option>
418 <option value="owner" selected={m.role === "owner"}>
419 owner
420 </option>
421 </select>
422 <button type="submit" class="btn btn-sm">
423 save
424 </button>
425 </form>
426 ) : (
427 <span
428 class="gate-status"
429 style="font-size: 11px; text-transform: uppercase"
430 >
431 {m.role}
432 </span>
433 )}
434 {canAdmin && m.userId !== user.id && (
435 <form
e7e240eClaude436 method="post"
6563f0aClaude437 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
438 style="display: inline"
439 onsubmit="return confirm('Remove this member?')"
440 >
441 <button type="submit" class="btn btn-sm btn-danger">
442 remove
443 </button>
444 </form>
445 )}
446 </div>
447 </div>
448 ))}
449 </div>
450 </div>
451 </Layout>
452 );
453});
454
59b6fb2Claude455orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
456 const orgName = c.req.param("org");
6563f0aClaude457 const user = c.get("user")!;
458
59b6fb2Claude459 let org: any;
6563f0aClaude460 try {
59b6fb2Claude461 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
462 org = found;
463 } catch {
464 return c.notFound();
6563f0aClaude465 }
466 if (!org) return c.notFound();
467
59b6fb2Claude468 // Check owner
469 let isOwner = false;
6563f0aClaude470 try {
59b6fb2Claude471 const [member] = await db.select().from(orgMembers)
472 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude473 .limit(1);
59b6fb2Claude474 isOwner = !!member;
475 } catch { /* */ }
476 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
6563f0aClaude477
478 const success = c.req.query("success");
0316dbbClaude479 const error = c.req.query("error");
480 const canAdmin = isOwner;
481 let orgTeams: any[] = [];
482 try {
483 orgTeams = await db.select().from(teams).where(eq(teams.orgId, org.id));
484 } catch { /* */ }
6563f0aClaude485
486 return c.html(
487 <Layout title={`${org.name} — teams`} user={user}>
488 <div style="max-width: 800px">
489 <div class="breadcrumb">
490 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
491 <span>/</span>
492 <span>teams</span>
493 </div>
494 <h2>Teams ({orgTeams.length})</h2>
495 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
496 {success && (
497 <div class="auth-success">{decodeURIComponent(success)}</div>
498 )}
499
500 {canAdmin && (
501 <form
e7e240eClaude502 method="post"
6563f0aClaude503 action={`/orgs/${org.slug}/teams/new`}
504 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
505 >
506 <input
507 type="text"
508 name="slug"
509 placeholder="team-slug"
510 required
511 maxLength={39}
512 pattern="[a-z0-9][a-z0-9-]{0,38}"
513 />
514 <input
515 type="text"
516 name="name"
517 placeholder="Team name"
518 required
519 maxLength={80}
520 />
521 <button type="submit" class="btn btn-primary">
522 Create team
523 </button>
524 </form>
525 )}
526
527 <div
528 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
529 >
530 {orgTeams.length === 0 ? (
531 <div
532 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
533 >
534 No teams yet.
535 </div>
536 ) : (
537 orgTeams.map((t) => (
538 <a
539 href={`/orgs/${org.slug}/teams/${t.slug}`}
540 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
541 >
542 <strong>{t.name}</strong>{" "}
543 <span style="color: var(--text-muted); font-size: 12px">
544 @{t.slug}
545 </span>
546 {t.description && (
547 <div style="color: var(--text-muted); font-size: 12px">
548 {t.description}
549 </div>
550 )}
551 </a>
552 ))
553 )}
554 </div>
0316dbbClaude555 </div>
6563f0aClaude556 </Layout>
557 );
558});
559
59b6fb2Claude560orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
561 const orgName = c.req.param("org");
6563f0aClaude562 const user = c.get("user")!;
563 const body = await c.req.parseBody();
564
565 try {
59b6fb2Claude566 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
567 if (!org) return c.redirect("/");
568
569 const [member] = await db.select().from(orgMembers)
570 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
571 .limit(1);
572 if (!member) return c.redirect(`/orgs/${orgName}`);
573
574 await db.update(organizations).set({
575 displayName: String(body.displayName || "").trim() || org.name,
576 description: String(body.description || "").trim() || null,
577 website: String(body.website || "").trim() || null,
578 location: String(body.location || "").trim() || null,
579 updatedAt: new Date(),
580 }).where(eq(organizations.id, org.id));
581 } catch { /* */ }
582
583 return c.redirect(`/orgs/${orgName}/settings?success=1`);
6563f0aClaude584});
585
59b6fb2Claude586orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
587 const orgName = c.req.param("org");
6563f0aClaude588 const user = c.get("user")!;
589
590 try {
59b6fb2Claude591 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
592 if (!org) return c.redirect("/");
593
594 const [member] = await db.select().from(orgMembers)
595 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude596 .limit(1);
59b6fb2Claude597 if (!member) return c.redirect(`/orgs/${orgName}`);
598
599 await db.delete(organizations).where(eq(organizations.id, org.id));
600 } catch { /* */ }
6563f0aClaude601
59b6fb2Claude602 return c.redirect("/");
603});
604
605// ─── Member Invite ──────────────────────────────────────────────────────────
606
607orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
608 const orgName = c.req.param("org");
609 const user = c.get("user")!;
6563f0aClaude610 const error = c.req.query("error");
611 const success = c.req.query("success");
612
613 return c.html(
0316dbbClaude614 <Layout title={`Invite — ${orgName}`} user={user}>
615 <div style="max-width: 560px">
6563f0aClaude616 <div class="breadcrumb">
0316dbbClaude617 <a href={`/orgs/${orgName}`}>{orgName}</a>
6563f0aClaude618 <span>/</span>
0316dbbClaude619 <span>invite</span>
6563f0aClaude620 </div>
0316dbbClaude621 <h2>Invite a member</h2>
6563f0aClaude622 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
623 {success && (
624 <div class="auth-success">{decodeURIComponent(success)}</div>
625 )}
0316dbbClaude626 <form
627 method="post"
628 action={`/orgs/${orgName}/members/invite`}
629 style="display: flex; gap: 8px; margin-bottom: 16px"
6563f0aClaude630 >
0316dbbClaude631 <input
632 type="text"
633 name="username"
634 placeholder="username"
635 required
636 maxLength={64}
637 style="flex: 1"
638 />
639 <select name="role">
640 <option value="member">member</option>
641 <option value="admin">admin</option>
642 </select>
643 <button type="submit" class="btn btn-primary">
644 Invite
645 </button>
646 </form>
6563f0aClaude647 </div>
648 </Layout>
649 );
650});
651
59b6fb2Claude652orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
653 const orgName = c.req.param("org");
6563f0aClaude654 const user = c.get("user")!;
655 const body = await c.req.parseBody();
59b6fb2Claude656 const username = String(body.username || "").trim();
657 const role = String(body.role || "member");
6563f0aClaude658
659 try {
59b6fb2Claude660 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
661 if (!org) return c.redirect("/");
6563f0aClaude662
59b6fb2Claude663 // Check inviter is owner or admin
664 const [inviter] = await db.select().from(orgMembers)
665 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
6563f0aClaude666 .limit(1);
59b6fb2Claude667 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
668 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
6563f0aClaude669 }
670
59b6fb2Claude671 // Find user
672 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
673 if (!targetUser) {
674 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
6563f0aClaude675 }
676
59b6fb2Claude677 // Check if already member
678 const [existing] = await db.select().from(orgMembers)
679 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
6563f0aClaude680 .limit(1);
59b6fb2Claude681 if (existing) {
682 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
683 }
6563f0aClaude684
59b6fb2Claude685 await db.insert(orgMembers).values({
686 orgId: org.id,
687 userId: targetUser.id,
688 role: ["owner", "admin", "member"].includes(role) ? role : "member",
6563f0aClaude689 });
59b6fb2Claude690
691 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
692 } catch (err: any) {
693 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
6563f0aClaude694 }
695});
696
59b6fb2Claude697// ─── Team Create ────────────────────────────────────────────────────────────
7437605Claude698
59b6fb2Claude699orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
700 const orgName = c.req.param("org");
7437605Claude701 const user = c.get("user")!;
702 const error = c.req.query("error");
703
0316dbbClaude704 let org: any;
705 try {
706 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
707 org = found;
708 } catch {
709 return c.notFound();
710 }
711 if (!org) return c.notFound();
712
7437605Claude713 return c.html(
0316dbbClaude714 <Layout title={`New team — ${org.name}`} user={user}>
7437605Claude715 <div class="settings-container" style="max-width: 560px">
716 <div class="breadcrumb">
717 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
718 <span>/</span>
0316dbbClaude719 <span>new team</span>
7437605Claude720 </div>
0316dbbClaude721 <h2>Create team in {org.name}</h2>
7437605Claude722 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude723 <form method="post" action={`/orgs/${org.slug}/teams/new`}>
7437605Claude724 <div class="form-group">
0316dbbClaude725 <label for="name">Team name</label>
7437605Claude726 <input
727 type="text"
728 id="name"
729 name="name"
730 required
0316dbbClaude731 maxLength={80}
732 placeholder="Platform engineers"
7437605Claude733 autocomplete="off"
734 />
735 </div>
736 <div class="form-group">
737 <label for="description">Description (optional)</label>
738 <textarea
739 id="description"
740 name="description"
741 rows={3}
742 maxLength={500}
743 />
744 </div>
745 <div class="form-group">
0316dbbClaude746 <label for="permission">Default permission</label>
747 <select id="permission" name="permission">
748 <option value="read">read</option>
749 <option value="write">write</option>
750 <option value="admin">admin</option>
751 </select>
7437605Claude752 </div>
753 <button type="submit" class="btn btn-primary">
0316dbbClaude754 Create team
7437605Claude755 </button>
756 </form>
757 </div>
758 </Layout>
759 );
760});
761
59b6fb2Claude762orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
763 const orgName = c.req.param("org");
7437605Claude764 const user = c.get("user")!;
765 const body = await c.req.parseBody();
766 const name = String(body.name || "").trim();
59b6fb2Claude767 const description = String(body.description || "").trim();
768 const permission = String(body.permission || "read");
7437605Claude769
770 try {
59b6fb2Claude771 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
772 if (!org) return c.redirect("/");
773
774 const [member] = await db.select().from(orgMembers)
775 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
7437605Claude776 .limit(1);
59b6fb2Claude777 if (!member || member.role === "member") {
778 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
7437605Claude779 }
780
59b6fb2Claude781 if (!name) {
782 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
7437605Claude783 }
784
59b6fb2Claude785 await db.insert(teams).values({
786 orgId: org.id,
787 name,
788 description: description || null,
789 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
790 });
791
792 return c.redirect(`/orgs/${orgName}`);
793 } catch (err: any) {
794 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
7437605Claude795 }
796});
797
59b6fb2Claude798// ─── Team Detail ────────────────────────────────────────────────────────────
7437605Claude799
59b6fb2Claude800orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
801 const orgName = c.req.param("org");
802 const teamName = c.req.param("team");
803 const user = c.get("user");
804
805 let org: any, team: any;
7437605Claude806 try {
59b6fb2Claude807 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
808 if (!org) return c.notFound();
809 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
810 if (!team) return c.notFound();
811 } catch {
812 return c.notFound();
7437605Claude813 }
814
59b6fb2Claude815 let members: any[] = [];
816 try {
817 members = await db
818 .select({ member: teamMembers, user: { username: users.username } })
819 .from(teamMembers)
820 .innerJoin(users, eq(teamMembers.userId, users.id))
821 .where(eq(teamMembers.teamId, team.id));
822 } catch { /* */ }
823
824 let repos: any[] = [];
825 try {
826 repos = await db
827 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
828 .from(teamRepos)
829 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
830 .where(eq(teamRepos.teamId, team.id));
831 } catch { /* */ }
832
7437605Claude833 return c.html(
59b6fb2Claude834 <Layout title={`${teamName} — ${orgName}`} user={user}>
3e8f8e8Claude835 <Container maxWidth={800}>
836 <Section style="margin-bottom:24px">
837 <Text size={14} muted>
59b6fb2Claude838 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
3e8f8e8Claude839 </Text>
59b6fb2Claude840 <h2>{team.name}</h2>
3e8f8e8Claude841 {team.description && <p style="margin-top:4px"><Text muted>{team.description}</Text></p>}
842 <div style="margin-top:8px">
843 <Badge>{team.permission} access</Badge>
7437605Claude844 </div>
3e8f8e8Claude845 </Section>
59b6fb2Claude846
3e8f8e8Claude847 <Grid cols="1fr 1fr" gap={24}>
59b6fb2Claude848 <div>
3e8f8e8Claude849 <Section title={`Members (${members.length})`}>
850 {members.length === 0 ? (
851 <EmptyState><Text size={14} muted>No members yet.</Text></EmptyState>
852 ) : (
853 <List>
854 {members.map((m: any) => (
855 <ListItem>
856 <a href={`/${m.user.username}`}>{m.user.username}</a>
857 </ListItem>
858 ))}
859 </List>
860 )}
861 </Section>
7437605Claude862 </div>
59b6fb2Claude863 <div>
3e8f8e8Claude864 <Section title={`Repositories (${repos.length})`}>
865 {repos.length === 0 ? (
866 <EmptyState><Text size={14} muted>No repositories assigned.</Text></EmptyState>
867 ) : (
868 <List>
869 {repos.map((r: any) => (
870 <ListItem>
871 <span>{r.repo.name}</span>
872 <Badge style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</Badge>
873 </ListItem>
874 ))}
875 </List>
876 )}
877 </Section>
59b6fb2Claude878 </div>
3e8f8e8Claude879 </Grid>
880 </Container>
7437605Claude881 </Layout>
882 );
883});
884
59b6fb2Claude885export default orgRoutes;