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.tsxBlame1530 lines · 2 contributors
6563f0aClaude1/**
59b6fb2Claude2 * Organization and team routes — create orgs, manage members, teams, permissions.
7581253Claude3 *
4 * 2026 polish: `/orgs` list + `/orgs/:org` detail wrapped in scoped `.orgs-*`
5 * classes (gradient hairline hero, avatar + member-count cards, empty state
6 * with orb). Other admin/team subroutes keep their existing UI shells. Every
7 * POST handler, validation rule, and ownership check is preserved verbatim.
6563f0aClaude8 */
9
10import { Hono } from "hono";
7581253Claude11import { eq, and, asc, sql } from "drizzle-orm";
6563f0aClaude12import { db } from "../db";
59b6fb2Claude13import { organizations, orgMembers, teams, teamMembers, teamRepos } from "../db/schema-extensions";
14import { users, repositories } from "../db/schema";
6563f0aClaude15import { Layout } from "../views/layout";
59b6fb2Claude16import { softAuth, requireAuth } from "../middleware/auth";
17import type { AuthEnv } from "../middleware/auth";
0316dbbClaude18import { loadOrgForUser, listOrgMembers, orgRoleAtLeast } from "../lib/orgs";
6563f0aClaude19import {
3e8f8e8Claude20 Container,
21 EmptyState,
22 Grid,
23 Text,
24 Badge,
25 Section,
26 List,
27 ListItem,
28} from "../views/ui";
59b6fb2Claude29
30const orgRoutes = new Hono<AuthEnv>();
31
7581253Claude32// ─── Scoped CSS (.orgs-*) ───────────────────────────────────────────────────
33// Every selector prefixed `.orgs-*` so the surface can't bleed into the
34// repo header / nav / page chrome. Mirrors the gradient-hairline hero +
35// card patterns from settings-2fa.tsx + admin-integrations.tsx.
36const orgsStyles = `
eed4684Claude37 .orgs-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
7581253Claude38
39 .orgs-hero {
40 position: relative;
41 margin-bottom: var(--space-5);
42 padding: var(--space-5) var(--space-6);
43 background: var(--bg-elevated);
44 border: 1px solid var(--border);
45 border-radius: 16px;
46 overflow: hidden;
47 }
48 .orgs-hero::before {
49 content: '';
50 position: absolute;
51 top: 0; left: 0; right: 0;
52 height: 2px;
6fd5915Claude53 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude54 opacity: 0.7;
55 pointer-events: none;
56 }
57 .orgs-hero-orb {
58 position: absolute;
59 inset: -20% -10% auto auto;
60 width: 380px; height: 380px;
6fd5915Claude61 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
7581253Claude62 filter: blur(80px);
63 opacity: 0.7;
64 pointer-events: none;
65 z-index: 0;
66 }
67 .orgs-hero-inner {
68 position: relative;
69 z-index: 1;
70 display: flex;
71 align-items: flex-end;
72 justify-content: space-between;
73 gap: var(--space-4);
74 flex-wrap: wrap;
75 }
76 .orgs-hero-text { flex: 1; min-width: 280px; max-width: 660px; }
77 .orgs-eyebrow {
78 font-size: 12px;
79 color: var(--text-muted);
80 margin-bottom: var(--space-2);
81 letter-spacing: 0.02em;
82 display: inline-flex;
83 align-items: center;
84 gap: 8px;
85 text-transform: uppercase;
86 font-family: var(--font-mono);
87 font-weight: 600;
88 }
89 .orgs-eyebrow-pill {
90 display: inline-flex;
91 align-items: center;
92 justify-content: center;
93 width: 18px; height: 18px;
94 border-radius: 6px;
6fd5915Claude95 background: rgba(91,110,232,0.14);
96 color: #5b6ee8;
97 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
7581253Claude98 }
99 .orgs-crumb { color: var(--text-muted); text-decoration: none; }
100 .orgs-crumb:hover { color: var(--text); }
101 .orgs-title {
102 font-size: clamp(28px, 4vw, 40px);
103 font-family: var(--font-display);
104 font-weight: 800;
105 letter-spacing: -0.028em;
106 line-height: 1.05;
107 margin: 0 0 var(--space-2);
108 color: var(--text-strong);
109 }
110 .orgs-title-grad {
6fd5915Claude111 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
7581253Claude112 -webkit-background-clip: text;
113 background-clip: text;
114 -webkit-text-fill-color: transparent;
115 color: transparent;
116 }
117 .orgs-sub {
118 font-size: 15px;
119 color: var(--text-muted);
120 margin: 0;
121 line-height: 1.55;
122 max-width: 620px;
123 }
124
125 /* ─── Buttons ─── */
126 .orgs-btn {
127 display: inline-flex;
128 align-items: center;
129 justify-content: center;
130 gap: 6px;
131 padding: 10px 18px;
132 border-radius: 10px;
133 font-size: 13.5px;
134 font-weight: 600;
135 text-decoration: none;
136 border: 1px solid transparent;
137 cursor: pointer;
138 font-family: inherit;
139 line-height: 1;
140 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
141 }
142 .orgs-btn-primary {
6fd5915Claude143 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
7581253Claude144 color: #fff;
6fd5915Claude145 box-shadow: 0 6px 18px -4px rgba(91,110,232,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
7581253Claude146 }
147 .orgs-btn-primary:hover {
148 transform: translateY(-1px);
6fd5915Claude149 box-shadow: 0 10px 24px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
7581253Claude150 color: #fff;
151 text-decoration: none;
152 }
153 .orgs-btn-ghost {
154 background: rgba(255,255,255,0.025);
155 color: var(--text);
156 border-color: var(--border-strong);
157 }
158 .orgs-btn-ghost:hover {
6fd5915Claude159 background: rgba(91,110,232,0.06);
160 border-color: rgba(91,110,232,0.45);
7581253Claude161 color: var(--text-strong);
162 text-decoration: none;
163 }
164
165 /* ─── Org grid ─── */
166 .orgs-grid {
167 list-style: none;
168 padding: 0;
169 margin: 0;
170 display: grid;
171 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
172 gap: var(--space-3);
173 }
174 .orgs-card {
175 display: flex;
176 gap: var(--space-3);
177 padding: var(--space-4);
178 background: var(--bg-elevated);
179 border: 1px solid var(--border);
180 border-radius: 14px;
181 text-decoration: none;
182 color: inherit;
183 transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
184 }
185 .orgs-card:hover {
6fd5915Claude186 border-color: rgba(91,110,232,0.32);
7581253Claude187 box-shadow: 0 8px 24px -10px rgba(0,0,0,0.32);
188 transform: translateY(-1px);
189 color: inherit;
190 text-decoration: none;
191 }
192 .orgs-avatar {
193 flex-shrink: 0;
194 width: 48px;
195 height: 48px;
196 border-radius: 12px;
6fd5915Claude197 background: linear-gradient(135deg, rgba(91,110,232,0.22), rgba(95,143,160,0.16));
198 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
7581253Claude199 display: flex;
200 align-items: center;
201 justify-content: center;
202 font-family: var(--font-display);
203 font-size: 20px;
204 font-weight: 800;
205 color: #e9d5ff;
206 text-transform: uppercase;
207 }
208 .orgs-card-body { flex: 1; min-width: 0; }
209 .orgs-card-name {
210 margin: 0;
211 font-family: var(--font-display);
212 font-size: 15px;
213 font-weight: 700;
214 letter-spacing: -0.012em;
215 color: var(--text-strong);
216 }
217 .orgs-card-handle {
218 margin: 2px 0 0;
219 font-size: 12.5px;
220 color: var(--text-muted);
221 font-family: var(--font-mono);
222 }
223 .orgs-card-meta {
224 margin-top: 10px;
225 display: flex;
226 gap: 6px;
227 flex-wrap: wrap;
228 align-items: center;
229 }
230 .orgs-pill {
231 display: inline-flex;
232 align-items: center;
233 gap: 6px;
234 padding: 2px 9px;
235 border-radius: 9999px;
236 font-size: 10.5px;
237 font-weight: 700;
238 letter-spacing: 0.05em;
239 text-transform: uppercase;
6fd5915Claude240 background: rgba(91,110,232,0.14);
7581253Claude241 color: #c4b5fd;
6fd5915Claude242 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
7581253Claude243 }
6fd5915Claude244 .orgs-pill.is-role { background: rgba(95,143,160,0.14); color: #67e8f9; box-shadow: inset 0 0 0 1px rgba(95,143,160,0.32); }
7581253Claude245 .orgs-pill .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
246 .orgs-stat {
247 display: inline-flex;
248 align-items: center;
249 gap: 5px;
250 font-size: 11.5px;
251 color: var(--text-muted);
252 font-family: var(--font-mono);
253 }
254
255 /* ─── Detail hero ─── */
256 .orgs-detail {
257 position: relative;
258 margin-bottom: var(--space-5);
259 padding: var(--space-5) var(--space-6);
260 background: var(--bg-elevated);
261 border: 1px solid var(--border);
262 border-radius: 16px;
263 overflow: hidden;
264 }
265 .orgs-detail::before {
266 content: '';
267 position: absolute;
268 top: 0; left: 0; right: 0;
269 height: 2px;
6fd5915Claude270 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude271 opacity: 0.7;
272 pointer-events: none;
273 }
274 .orgs-detail-orb {
275 position: absolute;
276 inset: -20% -10% auto auto;
277 width: 380px; height: 380px;
6fd5915Claude278 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
7581253Claude279 filter: blur(80px);
280 opacity: 0.7;
281 pointer-events: none;
282 z-index: 0;
283 }
284 .orgs-detail-inner {
285 position: relative;
286 z-index: 1;
287 display: flex;
288 gap: var(--space-4);
289 align-items: flex-start;
290 flex-wrap: wrap;
291 }
292 .orgs-avatar-lg {
293 flex-shrink: 0;
294 width: 86px;
295 height: 86px;
296 border-radius: 18px;
6fd5915Claude297 background: linear-gradient(135deg, rgba(91,110,232,0.22), rgba(95,143,160,0.16));
298 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
7581253Claude299 display: flex;
300 align-items: center;
301 justify-content: center;
302 font-family: var(--font-display);
303 font-size: 36px;
304 font-weight: 800;
305 color: #e9d5ff;
306 text-transform: uppercase;
307 }
308 .orgs-detail-text { flex: 1; min-width: 220px; }
309 .orgs-detail-name {
310 margin: 0;
311 font-family: var(--font-display);
312 font-size: clamp(22px, 3vw, 30px);
313 font-weight: 800;
314 letter-spacing: -0.022em;
315 color: var(--text-strong);
316 }
317 .orgs-detail-handle {
318 margin: 4px 0 0;
319 font-family: var(--font-mono);
320 font-size: 13px;
321 color: var(--text-muted);
322 }
323 .orgs-detail-desc {
324 margin: 12px 0 0;
325 font-size: 14px;
326 color: var(--text-muted);
327 line-height: 1.55;
328 max-width: 620px;
329 }
330 .orgs-detail-meta {
331 margin-top: 12px;
332 display: flex;
333 gap: 8px;
334 flex-wrap: wrap;
335 align-items: center;
336 }
337 .orgs-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; align-self: center; }
338
339 /* ─── Section card ─── */
340 .orgs-section {
341 margin-bottom: var(--space-5);
342 background: var(--bg-elevated);
343 border: 1px solid var(--border);
344 border-radius: 14px;
345 overflow: hidden;
346 }
347 .orgs-section-head {
348 padding: var(--space-4) var(--space-5);
349 border-bottom: 1px solid var(--border);
350 display: flex;
351 justify-content: space-between;
352 align-items: center;
353 gap: var(--space-3);
354 flex-wrap: wrap;
355 }
356 .orgs-section-title {
357 margin: 0;
358 font-family: var(--font-display);
359 font-size: 16px;
360 font-weight: 700;
361 letter-spacing: -0.012em;
362 color: var(--text-strong);
363 display: flex;
364 align-items: center;
365 gap: 10px;
366 }
367 .orgs-section-icon {
368 display: inline-flex;
369 align-items: center;
370 justify-content: center;
371 width: 26px; height: 26px;
372 border-radius: 8px;
6fd5915Claude373 background: rgba(91,110,232,0.12);
374 color: #5b6ee8;
375 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.28);
7581253Claude376 flex-shrink: 0;
377 }
378 .orgs-section-body { padding: var(--space-4) var(--space-5); }
379
380 .orgs-row-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
381 .orgs-row {
382 display: flex;
383 align-items: center;
384 gap: 10px;
385 padding: 10px 12px;
386 background: var(--bg);
387 border: 1px solid var(--border-strong);
388 border-radius: 10px;
389 }
390 .orgs-row-avatar {
391 flex-shrink: 0;
392 width: 30px; height: 30px;
393 border-radius: 8px;
6fd5915Claude394 background: linear-gradient(135deg, rgba(91,110,232,0.22), rgba(95,143,160,0.16));
395 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
7581253Claude396 display: flex;
397 align-items: center;
398 justify-content: center;
399 font-family: var(--font-display);
400 font-size: 13px;
401 font-weight: 800;
402 color: #e9d5ff;
403 text-transform: uppercase;
404 }
405 .orgs-row-name {
406 flex: 1;
407 min-width: 0;
408 font-size: 13.5px;
409 color: var(--text);
410 text-decoration: none;
411 }
412 .orgs-row-name:hover { color: var(--accent); }
413 .orgs-row-meta { color: var(--text-muted); font-size: 12px; }
414
415 /* ─── Empty state ─── */
416 .orgs-empty {
417 position: relative;
418 padding: 56px 32px;
419 background: var(--bg-elevated);
420 border: 1px solid var(--border);
421 border-radius: 16px;
422 text-align: center;
423 overflow: hidden;
424 }
425 .orgs-empty::before {
426 content: '';
427 position: absolute;
428 top: 0; left: 0; right: 0;
429 height: 2px;
6fd5915Claude430 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
7581253Claude431 opacity: 0.55;
432 pointer-events: none;
433 }
434 .orgs-empty-orb {
435 width: 96px; height: 96px;
436 margin: 0 auto 18px;
437 border-radius: 9999px;
438 background:
6fd5915Claude439 radial-gradient(circle at 35% 35%, rgba(91,110,232,0.55), rgba(95,143,160,0.25) 55%, transparent 75%);
7581253Claude440 box-shadow:
6fd5915Claude441 0 0 32px rgba(91,110,232,0.35),
442 inset 0 0 0 1px rgba(91,110,232,0.35);
7581253Claude443 display: flex;
444 align-items: center;
445 justify-content: center;
446 color: #fff;
447 }
448 .orgs-empty-title {
449 font-family: var(--font-display);
450 font-size: 22px;
451 font-weight: 700;
452 letter-spacing: -0.018em;
453 color: var(--text-strong);
454 margin: 0 0 8px;
455 }
456 .orgs-empty-sub {
457 font-size: 14.5px;
458 color: var(--text-muted);
459 line-height: 1.55;
460 margin: 0 auto 18px;
461 max-width: 460px;
462 }
463
464 .orgs-grid-2 {
465 display: grid;
466 grid-template-columns: 1fr 300px;
467 gap: var(--space-4);
468 }
469 @media (max-width: 800px) {
470 .orgs-grid-2 { grid-template-columns: 1fr; }
471 }
472`;
473
474const OrgsIcon = () => (
475 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
476 <path d="M3 21h18" />
477 <path d="M5 21V7l8-4v18" />
478 <path d="M19 21V11l-6-4" />
479 <line x1="9" y1="9" x2="9" y2="9.01" />
480 <line x1="9" y1="12" x2="9" y2="12.01" />
481 <line x1="9" y1="15" x2="9" y2="15.01" />
482 <line x1="9" y1="18" x2="9" y2="18.01" />
483 </svg>
484);
485const OrgsTeamIcon = () => (
486 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
487 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
488 <circle cx="9" cy="7" r="4" />
489 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
490 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
491 </svg>
492);
493const OrgsEmptyIcon = () => (
494 <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
495 <path d="M3 21h18" />
496 <path d="M5 21V7l8-4v18" />
497 <path d="M19 21V11l-6-4" />
498 </svg>
499);
500
699e5c7Claude501// ─── Organization List (index) ──────────────────────────────────────────────
502// GET /orgs — auth-required directory of the viewer's organizations.
503orgRoutes.get("/orgs", softAuth, requireAuth, async (c) => {
504 const user = c.get("user")!;
7581253Claude505 type OrgRow = {
506 org: typeof organizations.$inferSelect;
507 role: string;
508 memberCount: number;
509 repoCount: number;
510 };
511 let rows: OrgRow[] = [];
699e5c7Claude512 try {
7581253Claude513 const base = await db
699e5c7Claude514 .select({ org: organizations, role: orgMembers.role })
515 .from(orgMembers)
516 .innerJoin(organizations, eq(orgMembers.orgId, organizations.id))
517 .where(eq(orgMembers.userId, user.id))
518 .orderBy(asc(organizations.name));
7581253Claude519 rows = await Promise.all(
520 base.map(async (r) => {
521 let memberCount = 0;
522 let repoCount = 0;
523 try {
524 const [m] = await db
525 .select({ count: sql<number>`count(*)` })
526 .from(orgMembers)
527 .where(eq(orgMembers.orgId, r.org.id));
528 memberCount = Number(m?.count ?? 0);
529 } catch { /* ignore */ }
530 try {
531 // Org-owned repos are scoped by username convention (organizations.name).
532 // The repositories table keys ownership via users.id, so we look up the
533 // synthetic owner-user row whose username matches the org name.
534 const [u] = await db
535 .select({ id: users.id })
536 .from(users)
537 .where(eq(users.username, r.org.name))
538 .limit(1);
539 if (u) {
540 const [rc] = await db
541 .select({ count: sql<number>`count(*)` })
542 .from(repositories)
543 .where(eq(repositories.ownerId, u.id));
544 repoCount = Number(rc?.count ?? 0);
545 }
546 } catch { /* ignore */ }
547 return { org: r.org, role: r.role, memberCount, repoCount };
548 })
549 );
699e5c7Claude550 } catch {
551 rows = [];
552 }
7581253Claude553
699e5c7Claude554 return c.html(
555 <Layout title="Your organizations" user={user}>
7581253Claude556 <div class="orgs-wrap">
557 <section class="orgs-hero">
558 <div class="orgs-hero-orb" aria-hidden="true" />
559 <div class="orgs-hero-inner">
560 <div class="orgs-hero-text">
561 <div class="orgs-eyebrow">
562 <span class="orgs-eyebrow-pill" aria-hidden="true">
563 <OrgsIcon />
564 </span>
565 <span>Organizations</span>
566 <span>·</span>
567 <span>@{user.username}</span>
568 </div>
569 <h2 class="orgs-title">
570 <span class="orgs-title-grad">Your organizations.</span>
571 </h2>
572 <p class="orgs-sub">
573 Multi-user namespaces for sharing repos and managing teams.
574 Create one to invite teammates with role-based access.
575 </p>
576 </div>
577 <a href="/orgs/new" class="orgs-btn orgs-btn-primary">
578 + New organization
579 </a>
580 </div>
581 </section>
582
699e5c7Claude583 {rows.length === 0 ? (
7581253Claude584 <div class="orgs-empty">
585 <div class="orgs-empty-orb" aria-hidden="true">
586 <OrgsEmptyIcon />
699e5c7Claude587 </div>
7581253Claude588 <h2 class="orgs-empty-title">No organizations yet</h2>
589 <p class="orgs-empty-sub">
590 Organizations let you collaborate with teammates under a shared
591 namespace, with role-based access and team-scoped repos.
592 </p>
593 <a href="/orgs/new" class="orgs-btn orgs-btn-primary">
594 Create your first organization
595 </a>
596 </div>
699e5c7Claude597 ) : (
7581253Claude598 <ul class="orgs-grid">
599 {rows.map((r) => {
600 const displayName =
601 (r.org as any).displayName || (r.org as any).name || "?";
602 const slug = (r.org as any).slug || (r.org as any).name || "";
603 const initial = (displayName.charAt(0) || "?").toUpperCase();
604 return (
605 <li>
606 <a href={`/orgs/${slug}`} class="orgs-card">
607 <div class="orgs-avatar" aria-hidden="true">{initial}</div>
608 <div class="orgs-card-body">
609 <h3 class="orgs-card-name">{displayName}</h3>
610 <p class="orgs-card-handle">@{slug}</p>
611 <div class="orgs-card-meta">
612 <span class="orgs-pill is-role">
613 <span class="dot" aria-hidden="true" />
614 {r.role}
615 </span>
616 <span class="orgs-stat">
617 {r.memberCount} {r.memberCount === 1 ? "member" : "members"}
618 </span>
619 <span class="orgs-stat">·</span>
620 <span class="orgs-stat">
621 {r.repoCount} {r.repoCount === 1 ? "repo" : "repos"}
622 </span>
623 </div>
624 </div>
625 </a>
626 </li>
627 );
628 })}
629 </ul>
699e5c7Claude630 )}
7581253Claude631 </div>
632 <style dangerouslySetInnerHTML={{ __html: orgsStyles }} />
699e5c7Claude633 </Layout>
634 );
635});
636
637// ─── Org-scoped repos stubs — require auth, delegate to existing flows ──────
638orgRoutes.get("/orgs/:org/repos", softAuth, requireAuth, async (c) => {
639 return c.redirect(`/orgs/${c.req.param("org")}`);
640});
641orgRoutes.get("/orgs/:org/repos/new", softAuth, requireAuth, (c) => {
642 const org = c.req.param("org");
643 return c.redirect(`/new?org=${encodeURIComponent(org)}`);
644});
645orgRoutes.post("/orgs/:org/repos/new", softAuth, requireAuth, (c) => {
646 const org = c.req.param("org");
647 return c.redirect(`/new?org=${encodeURIComponent(org)}`);
648});
649
650// ─── Org people mutation stub — require auth ────────────────────────────────
651orgRoutes.post("/orgs/:org/people/add", softAuth, requireAuth, (c) => {
652 return c.redirect(`/orgs/${c.req.param("org")}/people`);
653});
654
59b6fb2Claude655// ─── Organization List / Create ─────────────────────────────────────────────
656
657orgRoutes.get("/orgs/new", softAuth, requireAuth, (c) => {
6563f0aClaude658 const user = c.get("user")!;
659 const error = c.req.query("error");
660
661 return c.html(
662 <Layout title="New organization" user={user}>
663 <div class="settings-container" style="max-width: 560px">
664 <h2>Create organization</h2>
665 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 16px">
666 Organizations are multi-user namespaces. You'll be the owner and can
667 invite teammates after creation.
668 </p>
669 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
e7e240eClaude670 <form method="post" action="/orgs/new">
6563f0aClaude671 <div class="form-group">
672 <label for="slug">Slug</label>
673 <input
674 type="text"
675 id="slug"
676 name="slug"
677 required
678 maxLength={39}
679 pattern="[a-z0-9][a-z0-9-]{0,38}"
680 placeholder="acme-corp"
681 autocomplete="off"
682 />
683 <div style="color: var(--text-muted); font-size: 12px; margin-top: 4px">
684 2–39 chars, lowercase letters, numbers, hyphens. Cannot start or
685 end with a hyphen.
686 </div>
687 </div>
688 <div class="form-group">
689 <label for="name">Display name</label>
690 <input
691 type="text"
692 id="name"
693 name="name"
694 required
695 maxLength={120}
696 placeholder="Acme Corp"
697 />
698 </div>
699 <div class="form-group">
700 <label for="description">Description (optional)</label>
701 <textarea
702 id="description"
703 name="description"
704 rows={3}
705 maxLength={500}
706 placeholder="What does this org do?"
707 />
708 </div>
709 <button type="submit" class="btn btn-primary">
710 Create organization
711 </button>
712 </form>
713 </div>
714 </Layout>
715 );
716});
717
59b6fb2Claude718orgRoutes.post("/orgs/new", softAuth, requireAuth, async (c) => {
6563f0aClaude719 const user = c.get("user")!;
720 const body = await c.req.parseBody();
721 const name = String(body.name || "").trim();
59b6fb2Claude722 const displayName = String(body.displayName || "").trim();
723 const description = String(body.description || "").trim();
724 const website = String(body.website || "").trim();
725
726 if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
727 return c.redirect("/orgs/new?error=Invalid+organization+name");
6563f0aClaude728 }
729
730 try {
59b6fb2Claude731 // Check if name is taken (by user or org)
732 const [existingUser] = await db.select().from(users).where(eq(users.username, name)).limit(1);
6563f0aClaude733 if (existingUser) {
59b6fb2Claude734 return c.redirect("/orgs/new?error=Name+already+taken");
735 }
736
737 const [existingOrg] = await db.select().from(organizations).where(eq(organizations.name, name)).limit(1);
738 if (existingOrg) {
739 return c.redirect("/orgs/new?error=Organization+already+exists");
6563f0aClaude740 }
741
742 const [org] = await db
743 .insert(organizations)
744 .values({
745 name,
59b6fb2Claude746 displayName: displayName || name,
747 description: description || null,
748 website: website || null,
6563f0aClaude749 })
750 .returning();
751
59b6fb2Claude752 // Add creator as owner
6563f0aClaude753 await db.insert(orgMembers).values({
754 orgId: org.id,
755 userId: user.id,
756 role: "owner",
757 });
758
59b6fb2Claude759 return c.redirect(`/orgs/${name}`);
6563f0aClaude760 } catch (err: any) {
59b6fb2Claude761 return c.redirect(`/orgs/new?error=${encodeURIComponent(err.message || "Failed to create organization")}`);
6563f0aClaude762 }
763});
764
59b6fb2Claude765// ─── Organization Profile ───────────────────────────────────────────────────
766
699e5c7Claude767orgRoutes.get("/orgs/:org", softAuth, requireAuth, async (c) => {
59b6fb2Claude768 const orgName = c.req.param("org");
769 const user = c.get("user");
770
771 let org: any;
772 try {
773 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
774 org = found;
775 } catch {
776 return c.notFound();
777 }
6563f0aClaude778
779 if (!org) return c.notFound();
780
59b6fb2Claude781 // Get members
782 let members: any[] = [];
783 try {
784 members = await db
785 .select({ member: orgMembers, user: { username: users.username, displayName: users.displayName } })
786 .from(orgMembers)
787 .innerJoin(users, eq(orgMembers.userId, users.id))
788 .where(eq(orgMembers.orgId, org.id))
789 .orderBy(asc(orgMembers.role));
790 } catch {
791 // Table may not exist
792 }
793
794 // Get teams
795 let teamList: any[] = [];
796 try {
797 teamList = await db.select().from(teams).where(eq(teams.orgId, org.id)).orderBy(asc(teams.name));
798 } catch {
799 // Table may not exist
800 }
801
802 const isMember = user && members.some((m: any) => m.member.userId === user.id);
803 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
6563f0aClaude804
7581253Claude805 const initial = ((org.displayName || org.name || "?").charAt(0) || "?").toUpperCase();
806 return (
807 c.html(
808 <Layout title={org.displayName || org.name} user={user}>
809 <div class="orgs-wrap">
810 <section class="orgs-detail">
811 <div class="orgs-detail-orb" aria-hidden="true" />
812 <div class="orgs-detail-inner">
813 <div class="orgs-avatar-lg" aria-hidden="true">{initial}</div>
814 <div class="orgs-detail-text">
815 <h2 class="orgs-detail-name">{org.displayName || org.name}</h2>
816 <p class="orgs-detail-handle">@{org.name}</p>
817 {org.description && <p class="orgs-detail-desc">{org.description}</p>}
818 <div class="orgs-detail-meta">
819 <span class="orgs-pill">
820 <span class="dot" aria-hidden="true" />
821 {members.length} {members.length === 1 ? "member" : "members"}
822 </span>
823 <span class="orgs-pill is-role">
824 <span class="dot" aria-hidden="true" />
825 {teamList.length} {teamList.length === 1 ? "team" : "teams"}
826 </span>
827 {org.website && (
828 <a
829 href={org.website}
830 target="_blank"
831 rel="noopener noreferrer"
832 style="font-size:12.5px;color:var(--text-muted);text-decoration:none"
833 >
834 {org.website}
835 </a>
836 )}
6563f0aClaude837 </div>
7581253Claude838 </div>
3e8f8e8Claude839 {isOwner && (
7581253Claude840 <div class="orgs-detail-actions">
841 <a
842 href={`/orgs/${org.name}/members/invite`}
843 class="orgs-btn orgs-btn-ghost"
844 >
845 Invite member
846 </a>
847 <a
848 href={`/orgs/${org.name}/settings`}
849 class="orgs-btn orgs-btn-primary"
850 >
851 Settings
852 </a>
6563f0aClaude853 </div>
854 )}
7581253Claude855 </div>
856 </section>
857
858 <div class="orgs-grid-2">
859 <section class="orgs-section">
860 <header class="orgs-section-head">
861 <h3 class="orgs-section-title">
862 <span class="orgs-section-icon" aria-hidden="true">
863 <OrgsTeamIcon />
864 </span>
865 Teams ({teamList.length})
866 </h3>
867 {isOwner && teamList.length > 0 && (
868 <a
869 href={`/orgs/${org.name}/teams/new`}
870 class="orgs-btn orgs-btn-ghost"
871 style="padding:6px 12px;font-size:12.5px"
872 >
873 + New team
874 </a>
875 )}
876 </header>
877 <div class="orgs-section-body">
878 {teamList.length === 0 ? (
879 <div style="text-align:center;padding:24px 12px;color:var(--text-muted);font-size:13.5px;line-height:1.55">
880 No teams yet.
881 {isOwner && (
882 <div style="margin-top:12px">
883 <a
884 href={`/orgs/${org.name}/teams/new`}
885 class="orgs-btn orgs-btn-primary"
886 >
887 Create your first team
888 </a>
889 </div>
890 )}
891 </div>
892 ) : (
893 <ul class="orgs-row-list">
894 {teamList.map((team: any) => (
895 <li class="orgs-row">
896 <div class="orgs-row-avatar" aria-hidden="true">
897 {(team.name.charAt(0) || "?").toUpperCase()}
898 </div>
899 <a
900 href={`/orgs/${org.name}/teams/${team.name}`}
901 class="orgs-row-name"
902 style="font-weight:600"
903 >
904 {team.name}
905 {team.description && (
906 <span class="orgs-row-meta" style="margin-left:8px">
907 — {team.description}
908 </span>
909 )}
910 </a>
911 <span class="orgs-pill is-role">
912 <span class="dot" aria-hidden="true" />
913 {team.permission}
914 </span>
915 </li>
916 ))}
917 </ul>
918 )}
919 </div>
920 </section>
921
922 <section class="orgs-section">
923 <header class="orgs-section-head">
924 <h3 class="orgs-section-title">
925 <span class="orgs-section-icon" aria-hidden="true">
926 <OrgsTeamIcon />
927 </span>
928 Members ({members.length})
929 </h3>
930 </header>
931 <div class="orgs-section-body">
932 {members.length === 0 ? (
933 <div style="text-align:center;padding:18px 12px;color:var(--text-muted);font-size:13.5px">
934 No members.
935 </div>
936 ) : (
937 <ul class="orgs-row-list">
938 {members.map((m: any) => {
939 const init = (
940 (m.user.displayName || m.user.username || "?").charAt(0) || "?"
941 ).toUpperCase();
942 return (
943 <li class="orgs-row">
944 <div class="orgs-row-avatar" aria-hidden="true">{init}</div>
945 <a href={`/${m.user.username}`} class="orgs-row-name">
946 {m.user.displayName || m.user.username}
947 <span class="orgs-row-meta" style="margin-left:6px">
948 @{m.user.username}
949 </span>
950 </a>
951 <span class="orgs-pill is-role">
952 <span class="dot" aria-hidden="true" />
953 {m.member.role}
954 </span>
955 </li>
956 );
957 })}
958 </ul>
959 )}
960 </div>
961 </section>
6563f0aClaude962 </div>
7581253Claude963 </div>
964 <style dangerouslySetInnerHTML={{ __html: orgsStyles }} />
965 </Layout>
966 )
6563f0aClaude967 );
968});
969
970// --- PEOPLE -----------------------------------------------------------------
971
0316dbbClaude972orgRoutes.get("/orgs/:slug/people", async (c) => {
976d7f7Claude973 const user = c.get("user");
6563f0aClaude974 const slug = c.req.param("slug");
976d7f7Claude975 // Anonymous users get bounced to login; org membership is non-public.
976 // Previously dereferenced `user!.id` immediately and crashed for anon
977 // (smoke crawl: TypeError, null is not an object — orgs.tsx:338).
978 if (!user) return c.redirect(`/login?redirect=/orgs/${slug}/people`);
6563f0aClaude979 const { org, role } = await loadOrgForUser(slug, user.id);
980 if (!org) return c.notFound();
981 if (!role) return c.redirect(`/orgs/${slug}`);
982
983 const members = await listOrgMembers(org.id);
984 const error = c.req.query("error");
985 const success = c.req.query("success");
986 const canAdmin = orgRoleAtLeast(role, "admin");
987 const canOwner = orgRoleAtLeast(role, "owner");
988
989 return c.html(
990 <Layout title={`${org.name} — people`} user={user}>
991 <div style="max-width: 800px">
992 <div class="breadcrumb">
993 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
994 <span>/</span>
995 <span>people</span>
996 </div>
997 <h2>People ({members.length})</h2>
998 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
999 {success && (
1000 <div class="auth-success">{decodeURIComponent(success)}</div>
1001 )}
1002
1003 {canAdmin && (
1004 <form
e7e240eClaude1005 method="post"
6563f0aClaude1006 action={`/orgs/${org.slug}/people/add`}
1007 style="display: flex; gap: 8px; margin-bottom: 16px"
1008 >
1009 <input
1010 type="text"
1011 name="username"
1012 placeholder="username to add"
1013 required
1014 maxLength={64}
5db1b25copilot-swe-agent[bot]1015 aria-label="Username to add"
6563f0aClaude1016 style="flex: 1"
1017 />
1018 <select name="role">
1019 <option value="member">member</option>
1020 <option value="admin">admin</option>
1021 {canOwner && <option value="owner">owner</option>}
1022 </select>
1023 <button type="submit" class="btn btn-primary">
1024 Add
1025 </button>
1026 </form>
1027 )}
1028
1029 <div
1030 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
1031 >
1032 {members.map((m) => (
1033 <div
1034 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
1035 >
1036 <div>
1037 <a href={`/${m.username}`}>
1038 <strong>{m.username}</strong>
1039 </a>
1040 {m.displayName && (
1041 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
1042 {m.displayName}
1043 </span>
1044 )}
1045 </div>
1046 <div style="display: flex; gap: 8px; align-items: center">
1047 {canOwner && m.userId !== user.id ? (
1048 <form
e7e240eClaude1049 method="post"
6563f0aClaude1050 action={`/orgs/${org.slug}/people/${m.userId}/role`}
1051 style="display: flex; gap: 4px"
1052 >
1053 <select name="role">
1054 <option value="member" selected={m.role === "member"}>
1055 member
1056 </option>
1057 <option value="admin" selected={m.role === "admin"}>
1058 admin
1059 </option>
1060 <option value="owner" selected={m.role === "owner"}>
1061 owner
1062 </option>
1063 </select>
1064 <button type="submit" class="btn btn-sm">
1065 save
1066 </button>
1067 </form>
1068 ) : (
1069 <span
1070 class="gate-status"
1071 style="font-size: 11px; text-transform: uppercase"
1072 >
1073 {m.role}
1074 </span>
1075 )}
1076 {canAdmin && m.userId !== user.id && (
1077 <form
e7e240eClaude1078 method="post"
6563f0aClaude1079 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
1080 style="display: inline"
1081 onsubmit="return confirm('Remove this member?')"
1082 >
1083 <button type="submit" class="btn btn-sm btn-danger">
1084 remove
1085 </button>
1086 </form>
1087 )}
1088 </div>
1089 </div>
1090 ))}
1091 </div>
1092 </div>
1093 </Layout>
1094 );
1095});
1096
59b6fb2Claude1097orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
1098 const orgName = c.req.param("org");
6563f0aClaude1099 const user = c.get("user")!;
1100
59b6fb2Claude1101 let org: any;
6563f0aClaude1102 try {
59b6fb2Claude1103 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1104 org = found;
1105 } catch {
1106 return c.notFound();
6563f0aClaude1107 }
1108 if (!org) return c.notFound();
1109
59b6fb2Claude1110 // Check owner
1111 let isOwner = false;
6563f0aClaude1112 try {
59b6fb2Claude1113 const [member] = await db.select().from(orgMembers)
1114 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude1115 .limit(1);
59b6fb2Claude1116 isOwner = !!member;
1117 } catch { /* */ }
1118 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
6563f0aClaude1119
1120 const success = c.req.query("success");
0316dbbClaude1121 const error = c.req.query("error");
1122 const canAdmin = isOwner;
1123 let orgTeams: any[] = [];
1124 try {
1125 orgTeams = await db.select().from(teams).where(eq(teams.orgId, org.id));
1126 } catch { /* */ }
6563f0aClaude1127
1128 return c.html(
1129 <Layout title={`${org.name} — teams`} user={user}>
1130 <div style="max-width: 800px">
1131 <div class="breadcrumb">
1132 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
1133 <span>/</span>
1134 <span>teams</span>
1135 </div>
1136 <h2>Teams ({orgTeams.length})</h2>
1137 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
1138 {success && (
1139 <div class="auth-success">{decodeURIComponent(success)}</div>
1140 )}
1141
1142 {canAdmin && (
1143 <form
e7e240eClaude1144 method="post"
6563f0aClaude1145 action={`/orgs/${org.slug}/teams/new`}
1146 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
1147 >
1148 <input
1149 type="text"
1150 name="slug"
1151 placeholder="team-slug"
1152 required
1153 maxLength={39}
1154 pattern="[a-z0-9][a-z0-9-]{0,38}"
5db1b25copilot-swe-agent[bot]1155 aria-label="Team slug"
6563f0aClaude1156 />
1157 <input
1158 type="text"
1159 name="name"
1160 placeholder="Team name"
1161 required
1162 maxLength={80}
5db1b25copilot-swe-agent[bot]1163 aria-label="Team name"
6563f0aClaude1164 />
1165 <button type="submit" class="btn btn-primary">
1166 Create team
1167 </button>
1168 </form>
1169 )}
1170
1171 <div
1172 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
1173 >
1174 {orgTeams.length === 0 ? (
1175 <div
1176 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
1177 >
1178 No teams yet.
1179 </div>
1180 ) : (
1181 orgTeams.map((t) => (
1182 <a
1183 href={`/orgs/${org.slug}/teams/${t.slug}`}
1184 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
1185 >
1186 <strong>{t.name}</strong>{" "}
1187 <span style="color: var(--text-muted); font-size: 12px">
1188 @{t.slug}
1189 </span>
1190 {t.description && (
1191 <div style="color: var(--text-muted); font-size: 12px">
1192 {t.description}
1193 </div>
1194 )}
1195 </a>
1196 ))
1197 )}
1198 </div>
0316dbbClaude1199 </div>
6563f0aClaude1200 </Layout>
1201 );
1202});
1203
59b6fb2Claude1204orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
1205 const orgName = c.req.param("org");
6563f0aClaude1206 const user = c.get("user")!;
1207 const body = await c.req.parseBody();
1208
1209 try {
59b6fb2Claude1210 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1211 if (!org) return c.redirect("/");
1212
1213 const [member] = await db.select().from(orgMembers)
1214 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
1215 .limit(1);
1216 if (!member) return c.redirect(`/orgs/${orgName}`);
1217
1218 await db.update(organizations).set({
1219 displayName: String(body.displayName || "").trim() || org.name,
1220 description: String(body.description || "").trim() || null,
1221 website: String(body.website || "").trim() || null,
1222 location: String(body.location || "").trim() || null,
1223 updatedAt: new Date(),
1224 }).where(eq(organizations.id, org.id));
1225 } catch { /* */ }
1226
1227 return c.redirect(`/orgs/${orgName}/settings?success=1`);
6563f0aClaude1228});
1229
59b6fb2Claude1230orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
1231 const orgName = c.req.param("org");
6563f0aClaude1232 const user = c.get("user")!;
1233
1234 try {
59b6fb2Claude1235 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1236 if (!org) return c.redirect("/");
1237
1238 const [member] = await db.select().from(orgMembers)
1239 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude1240 .limit(1);
59b6fb2Claude1241 if (!member) return c.redirect(`/orgs/${orgName}`);
1242
1243 await db.delete(organizations).where(eq(organizations.id, org.id));
1244 } catch { /* */ }
6563f0aClaude1245
59b6fb2Claude1246 return c.redirect("/");
1247});
1248
1249// ─── Member Invite ──────────────────────────────────────────────────────────
1250
1251orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
1252 const orgName = c.req.param("org");
1253 const user = c.get("user")!;
6563f0aClaude1254 const error = c.req.query("error");
1255 const success = c.req.query("success");
1256
1257 return c.html(
0316dbbClaude1258 <Layout title={`Invite — ${orgName}`} user={user}>
1259 <div style="max-width: 560px">
6563f0aClaude1260 <div class="breadcrumb">
0316dbbClaude1261 <a href={`/orgs/${orgName}`}>{orgName}</a>
6563f0aClaude1262 <span>/</span>
0316dbbClaude1263 <span>invite</span>
6563f0aClaude1264 </div>
0316dbbClaude1265 <h2>Invite a member</h2>
6563f0aClaude1266 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
1267 {success && (
1268 <div class="auth-success">{decodeURIComponent(success)}</div>
1269 )}
0316dbbClaude1270 <form
1271 method="post"
1272 action={`/orgs/${orgName}/members/invite`}
1273 style="display: flex; gap: 8px; margin-bottom: 16px"
6563f0aClaude1274 >
0316dbbClaude1275 <input
1276 type="text"
1277 name="username"
1278 placeholder="username"
1279 required
1280 maxLength={64}
5db1b25copilot-swe-agent[bot]1281 aria-label="Username to invite"
0316dbbClaude1282 style="flex: 1"
1283 />
1284 <select name="role">
1285 <option value="member">member</option>
1286 <option value="admin">admin</option>
1287 </select>
1288 <button type="submit" class="btn btn-primary">
1289 Invite
1290 </button>
1291 </form>
6563f0aClaude1292 </div>
1293 </Layout>
1294 );
1295});
1296
59b6fb2Claude1297orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
1298 const orgName = c.req.param("org");
6563f0aClaude1299 const user = c.get("user")!;
1300 const body = await c.req.parseBody();
59b6fb2Claude1301 const username = String(body.username || "").trim();
1302 const role = String(body.role || "member");
6563f0aClaude1303
1304 try {
59b6fb2Claude1305 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1306 if (!org) return c.redirect("/");
6563f0aClaude1307
59b6fb2Claude1308 // Check inviter is owner or admin
1309 const [inviter] = await db.select().from(orgMembers)
1310 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
6563f0aClaude1311 .limit(1);
59b6fb2Claude1312 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
1313 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
6563f0aClaude1314 }
1315
59b6fb2Claude1316 // Find user
1317 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
1318 if (!targetUser) {
1319 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
6563f0aClaude1320 }
1321
59b6fb2Claude1322 // Check if already member
1323 const [existing] = await db.select().from(orgMembers)
1324 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
6563f0aClaude1325 .limit(1);
59b6fb2Claude1326 if (existing) {
1327 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
1328 }
6563f0aClaude1329
59b6fb2Claude1330 await db.insert(orgMembers).values({
1331 orgId: org.id,
1332 userId: targetUser.id,
1333 role: ["owner", "admin", "member"].includes(role) ? role : "member",
6563f0aClaude1334 });
59b6fb2Claude1335
1336 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
1337 } catch (err: any) {
1338 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
6563f0aClaude1339 }
1340});
1341
59b6fb2Claude1342// ─── Team Create ────────────────────────────────────────────────────────────
7437605Claude1343
59b6fb2Claude1344orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
1345 const orgName = c.req.param("org");
7437605Claude1346 const user = c.get("user")!;
1347 const error = c.req.query("error");
1348
0316dbbClaude1349 let org: any;
1350 try {
1351 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1352 org = found;
1353 } catch {
1354 return c.notFound();
1355 }
1356 if (!org) return c.notFound();
1357
7437605Claude1358 return c.html(
0316dbbClaude1359 <Layout title={`New team — ${org.name}`} user={user}>
7437605Claude1360 <div class="settings-container" style="max-width: 560px">
1361 <div class="breadcrumb">
1362 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
1363 <span>/</span>
0316dbbClaude1364 <span>new team</span>
7437605Claude1365 </div>
0316dbbClaude1366 <h2>Create team in {org.name}</h2>
7437605Claude1367 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude1368 <form method="post" action={`/orgs/${org.slug}/teams/new`}>
7437605Claude1369 <div class="form-group">
0316dbbClaude1370 <label for="name">Team name</label>
7437605Claude1371 <input
1372 type="text"
1373 id="name"
1374 name="name"
1375 required
0316dbbClaude1376 maxLength={80}
1377 placeholder="Platform engineers"
7437605Claude1378 autocomplete="off"
1379 />
1380 </div>
1381 <div class="form-group">
1382 <label for="description">Description (optional)</label>
1383 <textarea
1384 id="description"
1385 name="description"
1386 rows={3}
1387 maxLength={500}
1388 />
1389 </div>
1390 <div class="form-group">
0316dbbClaude1391 <label for="permission">Default permission</label>
1392 <select id="permission" name="permission">
1393 <option value="read">read</option>
1394 <option value="write">write</option>
1395 <option value="admin">admin</option>
1396 </select>
7437605Claude1397 </div>
1398 <button type="submit" class="btn btn-primary">
0316dbbClaude1399 Create team
7437605Claude1400 </button>
1401 </form>
1402 </div>
1403 </Layout>
1404 );
1405});
1406
59b6fb2Claude1407orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
1408 const orgName = c.req.param("org");
7437605Claude1409 const user = c.get("user")!;
1410 const body = await c.req.parseBody();
1411 const name = String(body.name || "").trim();
59b6fb2Claude1412 const description = String(body.description || "").trim();
1413 const permission = String(body.permission || "read");
7437605Claude1414
1415 try {
59b6fb2Claude1416 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1417 if (!org) return c.redirect("/");
1418
1419 const [member] = await db.select().from(orgMembers)
1420 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
7437605Claude1421 .limit(1);
59b6fb2Claude1422 if (!member || member.role === "member") {
1423 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
7437605Claude1424 }
1425
59b6fb2Claude1426 if (!name) {
1427 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
7437605Claude1428 }
1429
59b6fb2Claude1430 await db.insert(teams).values({
1431 orgId: org.id,
1432 name,
1433 description: description || null,
1434 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
1435 });
1436
1437 return c.redirect(`/orgs/${orgName}`);
1438 } catch (err: any) {
1439 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
7437605Claude1440 }
1441});
1442
59b6fb2Claude1443// ─── Team Detail ────────────────────────────────────────────────────────────
7437605Claude1444
59b6fb2Claude1445orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
1446 const orgName = c.req.param("org");
1447 const teamName = c.req.param("team");
1448 const user = c.get("user");
1449
1450 let org: any, team: any;
7437605Claude1451 try {
59b6fb2Claude1452 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1453 if (!org) return c.notFound();
1454 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
1455 if (!team) return c.notFound();
1456 } catch {
1457 return c.notFound();
7437605Claude1458 }
1459
59b6fb2Claude1460 let members: any[] = [];
1461 try {
1462 members = await db
1463 .select({ member: teamMembers, user: { username: users.username } })
1464 .from(teamMembers)
1465 .innerJoin(users, eq(teamMembers.userId, users.id))
1466 .where(eq(teamMembers.teamId, team.id));
1467 } catch { /* */ }
1468
1469 let repos: any[] = [];
1470 try {
1471 repos = await db
1472 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
1473 .from(teamRepos)
1474 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
1475 .where(eq(teamRepos.teamId, team.id));
1476 } catch { /* */ }
1477
7437605Claude1478 return c.html(
59b6fb2Claude1479 <Layout title={`${teamName} — ${orgName}`} user={user}>
3e8f8e8Claude1480 <Container maxWidth={800}>
1481 <Section style="margin-bottom:24px">
1482 <Text size={14} muted>
59b6fb2Claude1483 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
3e8f8e8Claude1484 </Text>
59b6fb2Claude1485 <h2>{team.name}</h2>
3e8f8e8Claude1486 {team.description && <p style="margin-top:4px"><Text muted>{team.description}</Text></p>}
1487 <div style="margin-top:8px">
1488 <Badge>{team.permission} access</Badge>
7437605Claude1489 </div>
3e8f8e8Claude1490 </Section>
59b6fb2Claude1491
3e8f8e8Claude1492 <Grid cols="1fr 1fr" gap={24}>
59b6fb2Claude1493 <div>
3e8f8e8Claude1494 <Section title={`Members (${members.length})`}>
1495 {members.length === 0 ? (
1496 <EmptyState><Text size={14} muted>No members yet.</Text></EmptyState>
1497 ) : (
1498 <List>
1499 {members.map((m: any) => (
1500 <ListItem>
1501 <a href={`/${m.user.username}`}>{m.user.username}</a>
1502 </ListItem>
1503 ))}
1504 </List>
1505 )}
1506 </Section>
7437605Claude1507 </div>
59b6fb2Claude1508 <div>
3e8f8e8Claude1509 <Section title={`Repositories (${repos.length})`}>
1510 {repos.length === 0 ? (
1511 <EmptyState><Text size={14} muted>No repositories assigned.</Text></EmptyState>
1512 ) : (
1513 <List>
1514 {repos.map((r: any) => (
1515 <ListItem>
1516 <span>{r.repo.name}</span>
1517 <Badge style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</Badge>
1518 </ListItem>
1519 ))}
1520 </List>
1521 )}
1522 </Section>
59b6fb2Claude1523 </div>
3e8f8e8Claude1524 </Grid>
1525 </Container>
7437605Claude1526 </Layout>
1527 );
1528});
1529
59b6fb2Claude1530export default orgRoutes;