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.tsxBlame854 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";
6563f0aClaude13import {
3e8f8e8Claude14 Container,
15 PageHeader,
16 Form,
17 FormGroup,
18 Input,
19 TextArea,
20 Select,
21 Button,
22 LinkButton,
23 Alert,
24 EmptyState,
25 Flex,
26 Grid,
27 Text,
28 Badge,
29 Section,
30 Avatar,
31 List,
32 ListItem,
33} from "../views/ui";
59b6fb2Claude34
35const orgRoutes = new Hono<AuthEnv>();
36
37// ─── Organization List / Create ─────────────────────────────────────────────
38
39orgRoutes.get("/orgs/new", softAuth, requireAuth, (c) => {
6563f0aClaude40 const user = c.get("user")!;
41 const error = c.req.query("error");
42
43 return c.html(
44 <Layout title="New organization" user={user}>
45 <div class="settings-container" style="max-width: 560px">
46 <h2>Create organization</h2>
47 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
48 Organizations are multi-user namespaces. You'll be the owner and can
49 invite teammates after creation.
50 </p>
51 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
e7e240eClaude52 <form method="post" action="/orgs/new">
6563f0aClaude53 <div class="form-group">
54 <label for="slug">Slug</label>
55 <input
56 type="text"
57 id="slug"
58 name="slug"
59 required
60 maxLength={39}
61 pattern="[a-z0-9][a-z0-9-]{0,38}"
62 placeholder="acme-corp"
63 autocomplete="off"
64 />
65 <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px">
66 2–39 chars, lowercase letters, numbers, hyphens. Cannot start or
67 end with a hyphen.
68 </div>
69 </div>
70 <div class="form-group">
71 <label for="name">Display name</label>
72 <input
73 type="text"
74 id="name"
75 name="name"
76 required
77 maxLength={120}
78 placeholder="Acme Corp"
79 />
80 </div>
81 <div class="form-group">
82 <label for="description">Description (optional)</label>
83 <textarea
84 id="description"
85 name="description"
86 rows={3}
87 maxLength={500}
88 placeholder="What does this org do?"
89 />
90 </div>
91 <button type="submit" class="btn btn-primary">
92 Create organization
93 </button>
94 </form>
95 </div>
96 </Layout>
97 );
98});
99
59b6fb2Claude100orgRoutes.post("/orgs/new", softAuth, requireAuth, async (c) => {
6563f0aClaude101 const user = c.get("user")!;
102 const body = await c.req.parseBody();
103 const name = String(body.name || "").trim();
59b6fb2Claude104 const displayName = String(body.displayName || "").trim();
105 const description = String(body.description || "").trim();
106 const website = String(body.website || "").trim();
107
108 if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
109 return c.redirect("/orgs/new?error=Invalid+organization+name");
6563f0aClaude110 }
111
112 try {
59b6fb2Claude113 // Check if name is taken (by user or org)
114 const [existingUser] = await db.select().from(users).where(eq(users.username, name)).limit(1);
6563f0aClaude115 if (existingUser) {
59b6fb2Claude116 return c.redirect("/orgs/new?error=Name+already+taken");
117 }
118
119 const [existingOrg] = await db.select().from(organizations).where(eq(organizations.name, name)).limit(1);
120 if (existingOrg) {
121 return c.redirect("/orgs/new?error=Organization+already+exists");
6563f0aClaude122 }
123
124 const [org] = await db
125 .insert(organizations)
126 .values({
127 name,
59b6fb2Claude128 displayName: displayName || name,
129 description: description || null,
130 website: website || null,
6563f0aClaude131 })
132 .returning();
133
59b6fb2Claude134 // Add creator as owner
6563f0aClaude135 await db.insert(orgMembers).values({
136 orgId: org.id,
137 userId: user.id,
138 role: "owner",
139 });
140
59b6fb2Claude141 return c.redirect(`/orgs/${name}`);
6563f0aClaude142 } catch (err: any) {
59b6fb2Claude143 return c.redirect(`/orgs/new?error=${encodeURIComponent(err.message || "Failed to create organization")}`);
6563f0aClaude144 }
145});
146
59b6fb2Claude147// ─── Organization Profile ───────────────────────────────────────────────────
148
149orgRoutes.get("/orgs/:org", softAuth, async (c) => {
150 const orgName = c.req.param("org");
151 const user = c.get("user");
152
153 let org: any;
154 try {
155 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
156 org = found;
157 } catch {
158 return c.notFound();
159 }
6563f0aClaude160
161 if (!org) return c.notFound();
162
59b6fb2Claude163 // Get members
164 let members: any[] = [];
165 try {
166 members = await db
167 .select({ member: orgMembers, user: { username: users.username, displayName: users.displayName } })
168 .from(orgMembers)
169 .innerJoin(users, eq(orgMembers.userId, users.id))
170 .where(eq(orgMembers.orgId, org.id))
171 .orderBy(asc(orgMembers.role));
172 } catch {
173 // Table may not exist
174 }
175
176 // Get teams
177 let teamList: any[] = [];
178 try {
179 teamList = await db.select().from(teams).where(eq(teams.orgId, org.id)).orderBy(asc(teams.name));
180 } catch {
181 // Table may not exist
182 }
183
184 const isMember = user && members.some((m: any) => m.member.userId === user.id);
185 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
6563f0aClaude186
187 return c.html(
59b6fb2Claude188 <Layout title={org.displayName || org.name} user={user}>
3e8f8e8Claude189 <Container maxWidth={900}>
190 <Flex gap={24} style="margin-bottom:32px">
191 <Avatar name={org.displayName || org.name} size={80} />
59b6fb2Claude192 <div>
193 <h2>{org.displayName || org.name}</h2>
3e8f8e8Claude194 <Text size={14} muted>@{org.name}</Text>
195 {org.description && <p style="margin-top:8px"><Text size={14} muted>{org.description}</Text></p>}
59b6fb2Claude196 {org.website && (
197 <a href={org.website} style="font-size:13px" target="_blank" rel="noopener noreferrer">
198 {org.website}
7437605Claude199 </a>
200 )}
201 </div>
59b6fb2Claude202 {isOwner && (
203 <div style="margin-left:auto">
3e8f8e8Claude204 <LinkButton href={`/orgs/${org.name}/settings`} size="sm">Settings</LinkButton>
59b6fb2Claude205 </div>
206 )}
3e8f8e8Claude207 </Flex>
6563f0aClaude208
3e8f8e8Claude209 <Grid cols="1fr 300px" gap={32}>
6563f0aClaude210 <div>
3e8f8e8Claude211 <Section title="Teams">
212 {teamList.length === 0 ? (
213 <EmptyState>
214 <p>No teams yet.</p>
215 {isOwner && <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create a team</LinkButton>}
216 </EmptyState>
217 ) : (
218 <List>
219 {teamList.map((team: any) => (
220 <ListItem>
221 <div>
222 <div style="font-weight:500;font-size:15px">
223 <a href={`/orgs/${org.name}/teams/${team.name}`} style="color:var(--text)">{team.name}</a>
224 </div>
225 {team.description && <Text size={13} muted>{team.description}</Text>}
59b6fb2Claude226 </div>
3e8f8e8Claude227 <Badge>{team.permission}</Badge>
228 </ListItem>
229 ))}
230 </List>
231 )}
232 {isOwner && teamList.length > 0 && (
233 <div style="margin-top:12px">
234 <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create team</LinkButton>
6563f0aClaude235 </div>
236 )}
3e8f8e8Claude237 </Section>
6563f0aClaude238 </div>
59b6fb2Claude239
6563f0aClaude240 <div>
3e8f8e8Claude241 <Section title={`Members (${members.length})`}>
242 <List>
243 {members.map((m: any) => (
244 <ListItem>
245 <Flex align="center" gap={8} style="width:100%">
246 <Avatar name={m.user.displayName || m.user.username} size={32} />
247 <div style="flex:1">
248 <a href={`/${m.user.username}`} style="font-size:14px;font-weight:500">{m.user.username}</a>
249 </div>
250 <Badge style="font-size:11px">{m.member.role}</Badge>
251 </Flex>
252 </ListItem>
253 ))}
254 </List>
255 {isOwner && (
256 <div style="margin-top:12px">
257 <LinkButton href={`/orgs/${org.name}/members/invite`} size="sm">Invite member</LinkButton>
6563f0aClaude258 </div>
259 )}
3e8f8e8Claude260 </Section>
6563f0aClaude261 </div>
3e8f8e8Claude262 </Grid>
263 </Container>
6563f0aClaude264 </Layout>
265 );
266});
267
268// --- PEOPLE -----------------------------------------------------------------
269
270orgs.get("/orgs/:slug/people", async (c) => {
271 const user = c.get("user")!;
272 const slug = c.req.param("slug");
273 const { org, role } = await loadOrgForUser(slug, user.id);
274 if (!org) return c.notFound();
275 if (!role) return c.redirect(`/orgs/${slug}`);
276
277 const members = await listOrgMembers(org.id);
278 const error = c.req.query("error");
279 const success = c.req.query("success");
280 const canAdmin = orgRoleAtLeast(role, "admin");
281 const canOwner = orgRoleAtLeast(role, "owner");
282
283 return c.html(
284 <Layout title={`${org.name} — people`} user={user}>
285 <div style="max-width: 800px">
286 <div class="breadcrumb">
287 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
288 <span>/</span>
289 <span>people</span>
290 </div>
291 <h2>People ({members.length})</h2>
292 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
293 {success && (
294 <div class="auth-success">{decodeURIComponent(success)}</div>
295 )}
296
297 {canAdmin && (
298 <form
e7e240eClaude299 method="post"
6563f0aClaude300 action={`/orgs/${org.slug}/people/add`}
301 style="display: flex; gap: 8px; margin-bottom: 16px"
302 >
303 <input
304 type="text"
305 name="username"
306 placeholder="username to add"
307 required
308 maxLength={64}
309 style="flex: 1"
310 />
311 <select name="role">
312 <option value="member">member</option>
313 <option value="admin">admin</option>
314 {canOwner && <option value="owner">owner</option>}
315 </select>
316 <button type="submit" class="btn btn-primary">
317 Add
318 </button>
319 </form>
320 )}
321
322 <div
323 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
324 >
325 {members.map((m) => (
326 <div
327 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
328 >
329 <div>
330 <a href={`/${m.username}`}>
331 <strong>{m.username}</strong>
332 </a>
333 {m.displayName && (
334 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
335 {m.displayName}
336 </span>
337 )}
338 </div>
339 <div style="display: flex; gap: 8px; align-items: center">
340 {canOwner && m.userId !== user.id ? (
341 <form
e7e240eClaude342 method="post"
6563f0aClaude343 action={`/orgs/${org.slug}/people/${m.userId}/role`}
344 style="display: flex; gap: 4px"
345 >
346 <select name="role">
347 <option value="member" selected={m.role === "member"}>
348 member
349 </option>
350 <option value="admin" selected={m.role === "admin"}>
351 admin
352 </option>
353 <option value="owner" selected={m.role === "owner"}>
354 owner
355 </option>
356 </select>
357 <button type="submit" class="btn btn-sm">
358 save
359 </button>
360 </form>
361 ) : (
362 <span
363 class="gate-status"
364 style="font-size: 11px; text-transform: uppercase"
365 >
366 {m.role}
367 </span>
368 )}
369 {canAdmin && m.userId !== user.id && (
370 <form
e7e240eClaude371 method="post"
6563f0aClaude372 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
373 style="display: inline"
374 onsubmit="return confirm('Remove this member?')"
375 >
376 <button type="submit" class="btn btn-sm btn-danger">
377 remove
378 </button>
379 </form>
380 )}
381 </div>
382 </div>
383 ))}
384 </div>
385 </div>
386 </Layout>
387 );
388});
389
59b6fb2Claude390orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
391 const orgName = c.req.param("org");
6563f0aClaude392 const user = c.get("user")!;
393
59b6fb2Claude394 let org: any;
6563f0aClaude395 try {
59b6fb2Claude396 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
397 org = found;
398 } catch {
399 return c.notFound();
6563f0aClaude400 }
401 if (!org) return c.notFound();
402
59b6fb2Claude403 // Check owner
404 let isOwner = false;
6563f0aClaude405 try {
59b6fb2Claude406 const [member] = await db.select().from(orgMembers)
407 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude408 .limit(1);
59b6fb2Claude409 isOwner = !!member;
410 } catch { /* */ }
411 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
6563f0aClaude412
413 const success = c.req.query("success");
414
415 return c.html(
416 <Layout title={`${org.name} — teams`} user={user}>
417 <div style="max-width: 800px">
418 <div class="breadcrumb">
419 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
420 <span>/</span>
421 <span>teams</span>
422 </div>
423 <h2>Teams ({orgTeams.length})</h2>
424 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
425 {success && (
426 <div class="auth-success">{decodeURIComponent(success)}</div>
427 )}
428
429 {canAdmin && (
430 <form
e7e240eClaude431 method="post"
6563f0aClaude432 action={`/orgs/${org.slug}/teams/new`}
433 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
434 >
435 <input
436 type="text"
437 name="slug"
438 placeholder="team-slug"
439 required
440 maxLength={39}
441 pattern="[a-z0-9][a-z0-9-]{0,38}"
442 />
443 <input
444 type="text"
445 name="name"
446 placeholder="Team name"
447 required
448 maxLength={80}
449 />
450 <button type="submit" class="btn btn-primary">
451 Create team
452 </button>
453 </form>
454 )}
455
456 <div
457 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
458 >
459 {orgTeams.length === 0 ? (
460 <div
461 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
462 >
463 No teams yet.
464 </div>
465 ) : (
466 orgTeams.map((t) => (
467 <a
468 href={`/orgs/${org.slug}/teams/${t.slug}`}
469 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
470 >
471 <strong>{t.name}</strong>{" "}
472 <span style="color: var(--text-muted); font-size: 12px">
473 @{t.slug}
474 </span>
475 {t.description && (
476 <div style="color: var(--text-muted); font-size: 12px">
477 {t.description}
478 </div>
479 )}
480 </a>
481 ))
482 )}
483 </div>
3e8f8e8Claude484 </Container>
6563f0aClaude485 </Layout>
486 );
487});
488
59b6fb2Claude489orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
490 const orgName = c.req.param("org");
6563f0aClaude491 const user = c.get("user")!;
492 const body = await c.req.parseBody();
493
494 try {
59b6fb2Claude495 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
496 if (!org) return c.redirect("/");
497
498 const [member] = await db.select().from(orgMembers)
499 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
500 .limit(1);
501 if (!member) return c.redirect(`/orgs/${orgName}`);
502
503 await db.update(organizations).set({
504 displayName: String(body.displayName || "").trim() || org.name,
505 description: String(body.description || "").trim() || null,
506 website: String(body.website || "").trim() || null,
507 location: String(body.location || "").trim() || null,
508 updatedAt: new Date(),
509 }).where(eq(organizations.id, org.id));
510 } catch { /* */ }
511
512 return c.redirect(`/orgs/${orgName}/settings?success=1`);
6563f0aClaude513});
514
59b6fb2Claude515orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
516 const orgName = c.req.param("org");
6563f0aClaude517 const user = c.get("user")!;
518
519 try {
59b6fb2Claude520 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
521 if (!org) return c.redirect("/");
522
523 const [member] = await db.select().from(orgMembers)
524 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude525 .limit(1);
59b6fb2Claude526 if (!member) return c.redirect(`/orgs/${orgName}`);
527
528 await db.delete(organizations).where(eq(organizations.id, org.id));
529 } catch { /* */ }
6563f0aClaude530
59b6fb2Claude531 return c.redirect("/");
532});
533
534// ─── Member Invite ──────────────────────────────────────────────────────────
535
536orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
537 const orgName = c.req.param("org");
538 const user = c.get("user")!;
6563f0aClaude539 const error = c.req.query("error");
540 const success = c.req.query("success");
541
542 return c.html(
543 <Layout title={`${team.name} — ${org.name}`} user={user}>
544 <div style="max-width: 800px">
545 <div class="breadcrumb">
546 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
547 <span>/</span>
548 <a href={`/orgs/${org.slug}/teams`}>teams</a>
549 <span>/</span>
550 <span>{team.slug}</span>
551 </div>
552 <h2>{team.name}</h2>
553 {team.description && (
554 <p style="color: var(--text-muted)">{team.description}</p>
555 )}
556 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
557 {success && (
558 <div class="auth-success">{decodeURIComponent(success)}</div>
559 )}
560
561 <h3 style="font-size: 15px; margin-top: 16px">
562 Members ({members.length})
563 </h3>
564
565 {canAdmin && (
566 <form
e7e240eClaude567 method="post"
6563f0aClaude568 action={`/orgs/${org.slug}/teams/${team.slug}/members/add`}
569 style="display: flex; gap: 8px; margin-bottom: 16px"
570 >
571 <input
572 type="text"
573 name="username"
574 placeholder="username"
575 required
576 maxLength={64}
577 style="flex: 1"
578 />
579 <select name="role">
580 <option value="member">member</option>
581 <option value="maintainer">maintainer</option>
582 </select>
583 <button type="submit" class="btn btn-primary">
584 Add
585 </button>
586 </form>
587 )}
588
589 <div
590 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
591 >
592 {members.length === 0 ? (
593 <div
594 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
595 >
596 No members yet.
597 </div>
598 ) : (
599 members.map((m) => (
600 <div
601 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
602 >
603 <a href={`/${m.username}`}>{m.username}</a>
604 <div style="display: flex; gap: 8px; align-items: center">
605 <span
606 class="gate-status"
607 style="font-size: 11px; text-transform: uppercase"
608 >
609 {m.role}
610 </span>
611 {canAdmin && (
612 <form
e7e240eClaude613 method="post"
6563f0aClaude614 action={`/orgs/${org.slug}/teams/${team.slug}/members/${m.userId}/remove`}
615 style="display: inline"
616 >
617 <button type="submit" class="btn btn-sm btn-danger">
618 remove
619 </button>
620 </form>
621 )}
622 </div>
623 </div>
624 ))
625 )}
626 </div>
627 </div>
628 </Layout>
629 );
630});
631
59b6fb2Claude632orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
633 const orgName = c.req.param("org");
6563f0aClaude634 const user = c.get("user")!;
635 const body = await c.req.parseBody();
59b6fb2Claude636 const username = String(body.username || "").trim();
637 const role = String(body.role || "member");
6563f0aClaude638
639 try {
59b6fb2Claude640 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
641 if (!org) return c.redirect("/");
6563f0aClaude642
59b6fb2Claude643 // Check inviter is owner or admin
644 const [inviter] = await db.select().from(orgMembers)
645 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
6563f0aClaude646 .limit(1);
59b6fb2Claude647 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
648 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
6563f0aClaude649 }
650
59b6fb2Claude651 // Find user
652 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
653 if (!targetUser) {
654 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
6563f0aClaude655 }
656
59b6fb2Claude657 // Check if already member
658 const [existing] = await db.select().from(orgMembers)
659 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
6563f0aClaude660 .limit(1);
59b6fb2Claude661 if (existing) {
662 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
663 }
6563f0aClaude664
59b6fb2Claude665 await db.insert(orgMembers).values({
666 orgId: org.id,
667 userId: targetUser.id,
668 role: ["owner", "admin", "member"].includes(role) ? role : "member",
6563f0aClaude669 });
59b6fb2Claude670
671 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
672 } catch (err: any) {
673 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
6563f0aClaude674 }
675});
676
59b6fb2Claude677// ─── Team Create ────────────────────────────────────────────────────────────
7437605Claude678
59b6fb2Claude679orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
680 const orgName = c.req.param("org");
7437605Claude681 const user = c.get("user")!;
682 const error = c.req.query("error");
683
684 return c.html(
685 <Layout title={`New repo — ${org.name}`} user={user}>
686 <div class="settings-container" style="max-width: 560px">
687 <div class="breadcrumb">
688 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
689 <span>/</span>
690 <span>new repo</span>
691 </div>
692 <h2>Create repository in {org.name}</h2>
693 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
e7e240eClaude694 <form method="post" action={`/orgs/${org.slug}/repos/new`}>
7437605Claude695 <div class="form-group">
696 <label for="name">Repository name</label>
697 <input
698 type="text"
699 id="name"
700 name="name"
701 required
702 maxLength={100}
703 pattern="[a-zA-Z0-9._-]+"
704 placeholder="my-repo"
705 autocomplete="off"
706 />
707 </div>
708 <div class="form-group">
709 <label for="description">Description (optional)</label>
710 <textarea
711 id="description"
712 name="description"
713 rows={3}
714 maxLength={500}
715 />
716 </div>
717 <div class="form-group">
718 <label>
719 <input type="checkbox" name="isPrivate" value="1" /> Private
720 </label>
721 </div>
722 <button type="submit" class="btn btn-primary">
723 Create repository
724 </button>
725 </form>
726 </div>
727 </Layout>
728 );
729});
730
59b6fb2Claude731orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
732 const orgName = c.req.param("org");
7437605Claude733 const user = c.get("user")!;
734 const body = await c.req.parseBody();
735 const name = String(body.name || "").trim();
59b6fb2Claude736 const description = String(body.description || "").trim();
737 const permission = String(body.permission || "read");
7437605Claude738
739 try {
59b6fb2Claude740 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
741 if (!org) return c.redirect("/");
742
743 const [member] = await db.select().from(orgMembers)
744 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
7437605Claude745 .limit(1);
59b6fb2Claude746 if (!member || member.role === "member") {
747 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
7437605Claude748 }
749
59b6fb2Claude750 if (!name) {
751 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
7437605Claude752 }
753
59b6fb2Claude754 await db.insert(teams).values({
755 orgId: org.id,
756 name,
757 description: description || null,
758 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
759 });
760
761 return c.redirect(`/orgs/${orgName}`);
762 } catch (err: any) {
763 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
7437605Claude764 }
765});
766
59b6fb2Claude767// ─── Team Detail ────────────────────────────────────────────────────────────
7437605Claude768
59b6fb2Claude769orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
770 const orgName = c.req.param("org");
771 const teamName = c.req.param("team");
772 const user = c.get("user");
773
774 let org: any, team: any;
7437605Claude775 try {
59b6fb2Claude776 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
777 if (!org) return c.notFound();
778 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
779 if (!team) return c.notFound();
780 } catch {
781 return c.notFound();
7437605Claude782 }
783
59b6fb2Claude784 let members: any[] = [];
785 try {
786 members = await db
787 .select({ member: teamMembers, user: { username: users.username } })
788 .from(teamMembers)
789 .innerJoin(users, eq(teamMembers.userId, users.id))
790 .where(eq(teamMembers.teamId, team.id));
791 } catch { /* */ }
792
793 let repos: any[] = [];
794 try {
795 repos = await db
796 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
797 .from(teamRepos)
798 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
799 .where(eq(teamRepos.teamId, team.id));
800 } catch { /* */ }
801
7437605Claude802 return c.html(
59b6fb2Claude803 <Layout title={`${teamName} — ${orgName}`} user={user}>
3e8f8e8Claude804 <Container maxWidth={800}>
805 <Section style="margin-bottom:24px">
806 <Text size={14} muted>
59b6fb2Claude807 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
3e8f8e8Claude808 </Text>
59b6fb2Claude809 <h2>{team.name}</h2>
3e8f8e8Claude810 {team.description && <p style="margin-top:4px"><Text muted>{team.description}</Text></p>}
811 <div style="margin-top:8px">
812 <Badge>{team.permission} access</Badge>
7437605Claude813 </div>
3e8f8e8Claude814 </Section>
59b6fb2Claude815
3e8f8e8Claude816 <Grid cols="1fr 1fr" gap={24}>
59b6fb2Claude817 <div>
3e8f8e8Claude818 <Section title={`Members (${members.length})`}>
819 {members.length === 0 ? (
820 <EmptyState><Text size={14} muted>No members yet.</Text></EmptyState>
821 ) : (
822 <List>
823 {members.map((m: any) => (
824 <ListItem>
825 <a href={`/${m.user.username}`}>{m.user.username}</a>
826 </ListItem>
827 ))}
828 </List>
829 )}
830 </Section>
7437605Claude831 </div>
59b6fb2Claude832 <div>
3e8f8e8Claude833 <Section title={`Repositories (${repos.length})`}>
834 {repos.length === 0 ? (
835 <EmptyState><Text size={14} muted>No repositories assigned.</Text></EmptyState>
836 ) : (
837 <List>
838 {repos.map((r: any) => (
839 <ListItem>
840 <span>{r.repo.name}</span>
841 <Badge style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</Badge>
842 </ListItem>
843 ))}
844 </List>
845 )}
846 </Section>
59b6fb2Claude847 </div>
3e8f8e8Claude848 </Grid>
849 </Container>
7437605Claude850 </Layout>
851 );
852});
853
59b6fb2Claude854export default orgRoutes;