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.tsxBlame573 lines · 1 contributor
59b6fb2Claude1/**
2 * Organization and team routes — create orgs, manage members, teams, permissions.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import { organizations, orgMembers, teams, teamMembers, teamRepos } from "../db/schema-extensions";
9import { users, repositories } from "../db/schema";
10import { Layout } from "../views/layout";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
3e8f8e8Claude13import {
14 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) => {
40 const user = c.get("user")!;
41 const error = c.req.query("error");
42
43 return c.html(
44 <Layout title="New Organization" user={user}>
3e8f8e8Claude45 <Container maxWidth={500}>
46 <PageHeader title="Create a new organization" />
47 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
48 <Form action="/orgs/new" csrfToken={(c as any).get("csrfToken") || ""}>
49 <FormGroup label="Organization name" htmlFor="name" hint="Letters, numbers, hyphens, dots, underscores only">
50 <Input name="name" required pattern="^[a-zA-Z0-9._-]+$" placeholder="my-org" autocomplete="off" />
51 </FormGroup>
52 <FormGroup label="Display name" htmlFor="displayName">
53 <Input name="displayName" placeholder="My Organization" />
54 </FormGroup>
55 <FormGroup label="Description" htmlFor="description">
56 <TextArea name="description" rows={3} placeholder="What does this organization do?" />
57 </FormGroup>
58 <FormGroup label="Website" htmlFor="website">
59 <Input name="website" type="url" placeholder="https://example.com" />
60 </FormGroup>
61 <Button type="submit" variant="primary">Create organization</Button>
62 </Form>
63 </Container>
59b6fb2Claude64 </Layout>
65 );
66});
67
68orgRoutes.post("/orgs/new", softAuth, requireAuth, async (c) => {
69 const user = c.get("user")!;
70 const body = await c.req.parseBody();
71 const name = String(body.name || "").trim();
72 const displayName = String(body.displayName || "").trim();
73 const description = String(body.description || "").trim();
74 const website = String(body.website || "").trim();
75
76 if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
77 return c.redirect("/orgs/new?error=Invalid+organization+name");
78 }
79
80 try {
81 // Check if name is taken (by user or org)
82 const [existingUser] = await db.select().from(users).where(eq(users.username, name)).limit(1);
83 if (existingUser) {
84 return c.redirect("/orgs/new?error=Name+already+taken");
85 }
86
87 const [existingOrg] = await db.select().from(organizations).where(eq(organizations.name, name)).limit(1);
88 if (existingOrg) {
89 return c.redirect("/orgs/new?error=Organization+already+exists");
90 }
91
92 const [org] = await db
93 .insert(organizations)
94 .values({
95 name,
96 displayName: displayName || name,
97 description: description || null,
98 website: website || null,
99 })
100 .returning();
101
102 // Add creator as owner
103 await db.insert(orgMembers).values({
104 orgId: org.id,
105 userId: user.id,
106 role: "owner",
107 });
108
109 return c.redirect(`/orgs/${name}`);
110 } catch (err: any) {
111 return c.redirect(`/orgs/new?error=${encodeURIComponent(err.message || "Failed to create organization")}`);
112 }
113});
114
115// ─── Organization Profile ───────────────────────────────────────────────────
116
117orgRoutes.get("/orgs/:org", softAuth, async (c) => {
118 const orgName = c.req.param("org");
119 const user = c.get("user");
120
121 let org: any;
122 try {
123 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
124 org = found;
125 } catch {
126 return c.notFound();
127 }
128
129 if (!org) return c.notFound();
130
131 // Get members
132 let members: any[] = [];
133 try {
134 members = await db
135 .select({ member: orgMembers, user: { username: users.username, displayName: users.displayName } })
136 .from(orgMembers)
137 .innerJoin(users, eq(orgMembers.userId, users.id))
138 .where(eq(orgMembers.orgId, org.id))
139 .orderBy(asc(orgMembers.role));
140 } catch {
141 // Table may not exist
142 }
143
144 // Get teams
145 let teamList: any[] = [];
146 try {
147 teamList = await db.select().from(teams).where(eq(teams.orgId, org.id)).orderBy(asc(teams.name));
148 } catch {
149 // Table may not exist
150 }
151
152 const isMember = user && members.some((m: any) => m.member.userId === user.id);
153 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
154
155 return c.html(
156 <Layout title={org.displayName || org.name} user={user}>
3e8f8e8Claude157 <Container maxWidth={900}>
158 <Flex gap={24} style="margin-bottom:32px">
159 <Avatar name={org.displayName || org.name} size={80} />
59b6fb2Claude160 <div>
161 <h2>{org.displayName || org.name}</h2>
3e8f8e8Claude162 <Text size={14} muted>@{org.name}</Text>
163 {org.description && <p style="margin-top:8px"><Text size={14} muted>{org.description}</Text></p>}
59b6fb2Claude164 {org.website && (
165 <a href={org.website} style="font-size:13px" target="_blank" rel="noopener noreferrer">
166 {org.website}
167 </a>
168 )}
169 </div>
170 {isOwner && (
171 <div style="margin-left:auto">
3e8f8e8Claude172 <LinkButton href={`/orgs/${org.name}/settings`} size="sm">Settings</LinkButton>
59b6fb2Claude173 </div>
174 )}
3e8f8e8Claude175 </Flex>
59b6fb2Claude176
3e8f8e8Claude177 <Grid cols="1fr 300px" gap={32}>
59b6fb2Claude178 <div>
3e8f8e8Claude179 <Section title="Teams">
180 {teamList.length === 0 ? (
181 <EmptyState>
182 <p>No teams yet.</p>
183 {isOwner && <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create a team</LinkButton>}
184 </EmptyState>
185 ) : (
186 <List>
187 {teamList.map((team: any) => (
188 <ListItem>
189 <div>
190 <div style="font-weight:500;font-size:15px">
191 <a href={`/orgs/${org.name}/teams/${team.name}`} style="color:var(--text)">{team.name}</a>
192 </div>
193 {team.description && <Text size={13} muted>{team.description}</Text>}
59b6fb2Claude194 </div>
3e8f8e8Claude195 <Badge>{team.permission}</Badge>
196 </ListItem>
197 ))}
198 </List>
199 )}
200 {isOwner && teamList.length > 0 && (
201 <div style="margin-top:12px">
202 <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create team</LinkButton>
203 </div>
204 )}
205 </Section>
59b6fb2Claude206 </div>
207
208 <div>
3e8f8e8Claude209 <Section title={`Members (${members.length})`}>
210 <List>
211 {members.map((m: any) => (
212 <ListItem>
213 <Flex align="center" gap={8} style="width:100%">
214 <Avatar name={m.user.displayName || m.user.username} size={32} />
215 <div style="flex:1">
216 <a href={`/${m.user.username}`} style="font-size:14px;font-weight:500">{m.user.username}</a>
217 </div>
218 <Badge style="font-size:11px">{m.member.role}</Badge>
219 </Flex>
220 </ListItem>
221 ))}
222 </List>
223 {isOwner && (
224 <div style="margin-top:12px">
225 <LinkButton href={`/orgs/${org.name}/members/invite`} size="sm">Invite member</LinkButton>
59b6fb2Claude226 </div>
3e8f8e8Claude227 )}
228 </Section>
59b6fb2Claude229 </div>
3e8f8e8Claude230 </Grid>
231 </Container>
59b6fb2Claude232 </Layout>
233 );
234});
235
236// ─── Organization Settings ──────────────────────────────────────────────────
237
238orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
239 const orgName = c.req.param("org");
240 const user = c.get("user")!;
241
242 let org: any;
243 try {
244 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
245 org = found;
246 } catch {
247 return c.notFound();
248 }
249 if (!org) return c.notFound();
250
251 // Check owner
252 let isOwner = false;
253 try {
254 const [member] = await db.select().from(orgMembers)
255 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
256 .limit(1);
257 isOwner = !!member;
258 } catch { /* */ }
259 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
260
261 const success = c.req.query("success");
262
263 return c.html(
264 <Layout title={`Settings — ${org.name}`} user={user}>
3e8f8e8Claude265 <Container maxWidth={600}>
266 <PageHeader title="Organization Settings" />
267 {success && <Alert variant="success">Settings updated.</Alert>}
268 <Form action={`/orgs/${orgName}/settings`} csrfToken={(c as any).get("csrfToken") || ""}>
269 <FormGroup label="Display name" htmlFor="displayName">
270 <Input name="displayName" value={org.displayName || ""} />
271 </FormGroup>
272 <FormGroup label="Description" htmlFor="description">
273 <TextArea name="description" rows={3} value={org.description || ""} />
274 </FormGroup>
275 <FormGroup label="Website" htmlFor="website">
276 <Input name="website" type="url" value={org.website || ""} />
277 </FormGroup>
278 <FormGroup label="Location" htmlFor="location">
279 <Input name="location" value={org.location || ""} />
280 </FormGroup>
281 <Button type="submit" variant="primary">Save changes</Button>
282 </Form>
59b6fb2Claude283
284 <div style="margin-top:40px;padding-top:24px;border-top:1px solid var(--red)">
285 <h3 style="color:var(--red);margin-bottom:12px">Danger Zone</h3>
3e8f8e8Claude286 <Form action={`/orgs/${orgName}/delete`} csrfToken={(c as any).get("csrfToken") || ""} class="confirm-action" >
287 <Button type="submit" variant="danger">Delete organization</Button>
288 </Form>
59b6fb2Claude289 </div>
3e8f8e8Claude290 </Container>
59b6fb2Claude291 </Layout>
292 );
293});
294
295orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
296 const orgName = c.req.param("org");
297 const user = c.get("user")!;
298 const body = await c.req.parseBody();
299
300 try {
301 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
302 if (!org) return c.redirect("/");
303
304 const [member] = await db.select().from(orgMembers)
305 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
306 .limit(1);
307 if (!member) return c.redirect(`/orgs/${orgName}`);
308
309 await db.update(organizations).set({
310 displayName: String(body.displayName || "").trim() || org.name,
311 description: String(body.description || "").trim() || null,
312 website: String(body.website || "").trim() || null,
313 location: String(body.location || "").trim() || null,
314 updatedAt: new Date(),
315 }).where(eq(organizations.id, org.id));
316 } catch { /* */ }
317
318 return c.redirect(`/orgs/${orgName}/settings?success=1`);
319});
320
321orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
322 const orgName = c.req.param("org");
323 const user = c.get("user")!;
324
325 try {
326 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
327 if (!org) return c.redirect("/");
328
329 const [member] = await db.select().from(orgMembers)
330 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
331 .limit(1);
332 if (!member) return c.redirect(`/orgs/${orgName}`);
333
334 await db.delete(organizations).where(eq(organizations.id, org.id));
335 } catch { /* */ }
336
337 return c.redirect("/");
338});
339
340// ─── Member Invite ──────────────────────────────────────────────────────────
341
342orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
343 const orgName = c.req.param("org");
344 const user = c.get("user")!;
345 const error = c.req.query("error");
346 const success = c.req.query("success");
347
348 return c.html(
349 <Layout title={`Invite Member — ${orgName}`} user={user}>
3e8f8e8Claude350 <Container maxWidth={500}>
351 <PageHeader title={`Invite a member to ${orgName}`} />
352 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
353 {success && <Alert variant="success">Member invited successfully.</Alert>}
354 <Form action={`/orgs/${orgName}/members/invite`} csrfToken={(c as any).get("csrfToken") || ""}>
355 <FormGroup label="Username" htmlFor="username">
356 <Input name="username" required placeholder="Enter username" autocomplete="off" />
357 </FormGroup>
358 <FormGroup label="Role" htmlFor="role">
359 <Select name="role">
59b6fb2Claude360 <option value="member">Member — can view</option>
361 <option value="admin">Admin — can manage teams</option>
362 <option value="owner">Owner — full control</option>
3e8f8e8Claude363 </Select>
364 </FormGroup>
365 <Button type="submit" variant="primary">Send invitation</Button>
366 </Form>
367 </Container>
59b6fb2Claude368 </Layout>
369 );
370});
371
372orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
373 const orgName = c.req.param("org");
374 const user = c.get("user")!;
375 const body = await c.req.parseBody();
376 const username = String(body.username || "").trim();
377 const role = String(body.role || "member");
378
379 try {
380 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
381 if (!org) return c.redirect("/");
382
383 // Check inviter is owner or admin
384 const [inviter] = await db.select().from(orgMembers)
385 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
386 .limit(1);
387 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
388 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
389 }
390
391 // Find user
392 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
393 if (!targetUser) {
394 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
395 }
396
397 // Check if already member
398 const [existing] = await db.select().from(orgMembers)
399 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
400 .limit(1);
401 if (existing) {
402 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
403 }
404
405 await db.insert(orgMembers).values({
406 orgId: org.id,
407 userId: targetUser.id,
408 role: ["owner", "admin", "member"].includes(role) ? role : "member",
409 });
410
411 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
412 } catch (err: any) {
413 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
414 }
415});
416
417// ─── Team Create ────────────────────────────────────────────────────────────
418
419orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
420 const orgName = c.req.param("org");
421 const user = c.get("user")!;
422 const error = c.req.query("error");
423
424 return c.html(
425 <Layout title={`New Team — ${orgName}`} user={user}>
3e8f8e8Claude426 <Container maxWidth={500}>
427 <PageHeader title="Create a new team" />
428 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
429 <Form action={`/orgs/${orgName}/teams/new`} csrfToken={(c as any).get("csrfToken") || ""}>
430 <FormGroup label="Team name" htmlFor="name">
431 <Input name="name" required placeholder="engineering" autocomplete="off" />
432 </FormGroup>
433 <FormGroup label="Description" htmlFor="description">
434 <TextArea name="description" rows={2} placeholder="What does this team do?" />
435 </FormGroup>
436 <FormGroup label="Default permission" htmlFor="permission">
437 <Select name="permission">
59b6fb2Claude438 <option value="read">Read — view repos</option>
439 <option value="write">Write — push to repos</option>
440 <option value="admin">Admin — manage repos</option>
3e8f8e8Claude441 </Select>
442 </FormGroup>
443 <Button type="submit" variant="primary">Create team</Button>
444 </Form>
445 </Container>
59b6fb2Claude446 </Layout>
447 );
448});
449
450orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
451 const orgName = c.req.param("org");
452 const user = c.get("user")!;
453 const body = await c.req.parseBody();
454 const name = String(body.name || "").trim();
455 const description = String(body.description || "").trim();
456 const permission = String(body.permission || "read");
457
458 try {
459 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
460 if (!org) return c.redirect("/");
461
462 const [member] = await db.select().from(orgMembers)
463 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
464 .limit(1);
465 if (!member || member.role === "member") {
466 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
467 }
468
469 if (!name) {
470 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
471 }
472
473 await db.insert(teams).values({
474 orgId: org.id,
475 name,
476 description: description || null,
477 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
478 });
479
480 return c.redirect(`/orgs/${orgName}`);
481 } catch (err: any) {
482 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
483 }
484});
485
486// ─── Team Detail ────────────────────────────────────────────────────────────
487
488orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
489 const orgName = c.req.param("org");
490 const teamName = c.req.param("team");
491 const user = c.get("user");
492
493 let org: any, team: any;
494 try {
495 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
496 if (!org) return c.notFound();
497 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
498 if (!team) return c.notFound();
499 } catch {
500 return c.notFound();
501 }
502
503 let members: any[] = [];
504 try {
505 members = await db
506 .select({ member: teamMembers, user: { username: users.username } })
507 .from(teamMembers)
508 .innerJoin(users, eq(teamMembers.userId, users.id))
509 .where(eq(teamMembers.teamId, team.id));
510 } catch { /* */ }
511
512 let repos: any[] = [];
513 try {
514 repos = await db
515 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
516 .from(teamRepos)
517 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
518 .where(eq(teamRepos.teamId, team.id));
519 } catch { /* */ }
520
521 return c.html(
522 <Layout title={`${teamName} — ${orgName}`} user={user}>
3e8f8e8Claude523 <Container maxWidth={800}>
524 <Section style="margin-bottom:24px">
525 <Text size={14} muted>
59b6fb2Claude526 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
3e8f8e8Claude527 </Text>
59b6fb2Claude528 <h2>{team.name}</h2>
3e8f8e8Claude529 {team.description && <p style="margin-top:4px"><Text muted>{team.description}</Text></p>}
530 <div style="margin-top:8px">
531 <Badge>{team.permission} access</Badge>
532 </div>
533 </Section>
59b6fb2Claude534
3e8f8e8Claude535 <Grid cols="1fr 1fr" gap={24}>
59b6fb2Claude536 <div>
3e8f8e8Claude537 <Section title={`Members (${members.length})`}>
538 {members.length === 0 ? (
539 <EmptyState><Text size={14} muted>No members yet.</Text></EmptyState>
540 ) : (
541 <List>
542 {members.map((m: any) => (
543 <ListItem>
544 <a href={`/${m.user.username}`}>{m.user.username}</a>
545 </ListItem>
546 ))}
547 </List>
548 )}
549 </Section>
59b6fb2Claude550 </div>
551 <div>
3e8f8e8Claude552 <Section title={`Repositories (${repos.length})`}>
553 {repos.length === 0 ? (
554 <EmptyState><Text size={14} muted>No repositories assigned.</Text></EmptyState>
555 ) : (
556 <List>
557 {repos.map((r: any) => (
558 <ListItem>
559 <span>{r.repo.name}</span>
560 <Badge style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</Badge>
561 </ListItem>
562 ))}
563 </List>
564 )}
565 </Section>
59b6fb2Claude566 </div>
3e8f8e8Claude567 </Grid>
568 </Container>
59b6fb2Claude569 </Layout>
570 );
571});
572
573export default orgRoutes;