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.
| 59b6fb2 | 1 | /** |
| 2 | * Organization and team routes — create orgs, manage members, teams, permissions. | |
| 3 | */ | |
| 4 | ||
| 5 | import { Hono } from "hono"; | |
| 6 | import { eq, and, desc, asc, sql } from "drizzle-orm"; | |
| 7 | import { db } from "../db"; | |
| 8 | import { organizations, orgMembers, teams, teamMembers, teamRepos } from "../db/schema-extensions"; | |
| 9 | import { users, repositories } from "../db/schema"; | |
| 10 | import { Layout } from "../views/layout"; | |
| 11 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 12 | import type { AuthEnv } from "../middleware/auth"; | |
| 3e8f8e8 | 13 | import { |
| 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"; | |
| 59b6fb2 | 34 | |
| 35 | const orgRoutes = new Hono<AuthEnv>(); | |
| 36 | ||
| 37 | // ─── Organization List / Create ───────────────────────────────────────────── | |
| 38 | ||
| 39 | orgRoutes.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}> | |
| 3e8f8e8 | 45 | <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> | |
| 59b6fb2 | 64 | </Layout> |
| 65 | ); | |
| 66 | }); | |
| 67 | ||
| 68 | orgRoutes.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 | ||
| 117 | orgRoutes.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}> | |
| 3e8f8e8 | 157 | <Container maxWidth={900}> |
| 158 | <Flex gap={24} style="margin-bottom:32px"> | |
| 159 | <Avatar name={org.displayName || org.name} size={80} /> | |
| 59b6fb2 | 160 | <div> |
| 161 | <h2>{org.displayName || org.name}</h2> | |
| 3e8f8e8 | 162 | <Text size={14} muted>@{org.name}</Text> |
| 163 | {org.description && <p style="margin-top:8px"><Text size={14} muted>{org.description}</Text></p>} | |
| 59b6fb2 | 164 | {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"> | |
| 3e8f8e8 | 172 | <LinkButton href={`/orgs/${org.name}/settings`} size="sm">Settings</LinkButton> |
| 59b6fb2 | 173 | </div> |
| 174 | )} | |
| 3e8f8e8 | 175 | </Flex> |
| 59b6fb2 | 176 | |
| 3e8f8e8 | 177 | <Grid cols="1fr 300px" gap={32}> |
| 59b6fb2 | 178 | <div> |
| 3e8f8e8 | 179 | <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>} | |
| 59b6fb2 | 194 | </div> |
| 3e8f8e8 | 195 | <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> | |
| 59b6fb2 | 206 | </div> |
| 207 | ||
| 208 | <div> | |
| 3e8f8e8 | 209 | <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> | |
| 59b6fb2 | 226 | </div> |
| 3e8f8e8 | 227 | )} |
| 228 | </Section> | |
| 59b6fb2 | 229 | </div> |
| 3e8f8e8 | 230 | </Grid> |
| 231 | </Container> | |
| 59b6fb2 | 232 | </Layout> |
| 233 | ); | |
| 234 | }); | |
| 235 | ||
| 236 | // ─── Organization Settings ────────────────────────────────────────────────── | |
| 237 | ||
| 238 | orgRoutes.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}> | |
| 3e8f8e8 | 265 | <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> | |
| 59b6fb2 | 283 | |
| 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> | |
| 3e8f8e8 | 286 | <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> | |
| 59b6fb2 | 289 | </div> |
| 3e8f8e8 | 290 | </Container> |
| 59b6fb2 | 291 | </Layout> |
| 292 | ); | |
| 293 | }); | |
| 294 | ||
| 295 | orgRoutes.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 | ||
| 321 | orgRoutes.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 | ||
| 342 | orgRoutes.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}> | |
| 3e8f8e8 | 350 | <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"> | |
| 59b6fb2 | 360 | <option value="member">Member — can view</option> |
| 361 | <option value="admin">Admin — can manage teams</option> | |
| 362 | <option value="owner">Owner — full control</option> | |
| 3e8f8e8 | 363 | </Select> |
| 364 | </FormGroup> | |
| 365 | <Button type="submit" variant="primary">Send invitation</Button> | |
| 366 | </Form> | |
| 367 | </Container> | |
| 59b6fb2 | 368 | </Layout> |
| 369 | ); | |
| 370 | }); | |
| 371 | ||
| 372 | orgRoutes.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 | ||
| 419 | orgRoutes.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}> | |
| 3e8f8e8 | 426 | <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"> | |
| 59b6fb2 | 438 | <option value="read">Read — view repos</option> |
| 439 | <option value="write">Write — push to repos</option> | |
| 440 | <option value="admin">Admin — manage repos</option> | |
| 3e8f8e8 | 441 | </Select> |
| 442 | </FormGroup> | |
| 443 | <Button type="submit" variant="primary">Create team</Button> | |
| 444 | </Form> | |
| 445 | </Container> | |
| 59b6fb2 | 446 | </Layout> |
| 447 | ); | |
| 448 | }); | |
| 449 | ||
| 450 | orgRoutes.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 | ||
| 488 | orgRoutes.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}> | |
| 3e8f8e8 | 523 | <Container maxWidth={800}> |
| 524 | <Section style="margin-bottom:24px"> | |
| 525 | <Text size={14} muted> | |
| 59b6fb2 | 526 | <a href={`/orgs/${orgName}`}>{orgName}</a> / teams |
| 3e8f8e8 | 527 | </Text> |
| 59b6fb2 | 528 | <h2>{team.name}</h2> |
| 3e8f8e8 | 529 | {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> | |
| 59b6fb2 | 534 | |
| 3e8f8e8 | 535 | <Grid cols="1fr 1fr" gap={24}> |
| 59b6fb2 | 536 | <div> |
| 3e8f8e8 | 537 | <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> | |
| 59b6fb2 | 550 | </div> |
| 551 | <div> | |
| 3e8f8e8 | 552 | <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> | |
| 59b6fb2 | 566 | </div> |
| 3e8f8e8 | 567 | </Grid> |
| 568 | </Container> | |
| 59b6fb2 | 569 | </Layout> |
| 570 | ); | |
| 571 | }); | |
| 572 | ||
| 573 | export default orgRoutes; |