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.tsxBlame1559 lines · 3 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);
e589f77ccantynz-alt96 color: var(--accent);
6fd5915Claude97 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);
e589f77ccantynz-alt374 color: var(--accent);
6fd5915Claude375 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
02aa828ccantynz-alt525 .select({ count: sql<number>`count(*)::int` })
7581253Claude526 .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
02aa828ccantynz-alt541 .select({ count: sql<number>`count(*)::int` })
7581253Claude542 .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
45ac593ccantynz-alt742 // `slug` and `createdById` are NOT NULL with no default (migration 0003).
743 // Both were omitted here, so this insert has never succeeded — the two
744 // rival `organizations` definitions meant the typechecker could not see
745 // it. Now that schema-extensions re-exports the real table, tsc enforces
746 // the full column set.
747 //
748 // The route is /orgs/:org keyed on `name`, and `name` is already
749 // validated to /^[a-zA-Z0-9._-]+$/ and checked for collisions against
750 // both users and orgs above — so the slug is the lowercased name, which
751 // keeps the unique index consistent with the case-insensitive lookups.
6563f0aClaude752 const [org] = await db
753 .insert(organizations)
754 .values({
755 name,
45ac593ccantynz-alt756 slug: name.toLowerCase(),
757 createdById: user.id,
59b6fb2Claude758 displayName: displayName || name,
759 description: description || null,
760 website: website || null,
6563f0aClaude761 })
762 .returning();
763
59b6fb2Claude764 // Add creator as owner
6563f0aClaude765 await db.insert(orgMembers).values({
766 orgId: org.id,
767 userId: user.id,
768 role: "owner",
769 });
770
59b6fb2Claude771 return c.redirect(`/orgs/${name}`);
6563f0aClaude772 } catch (err: any) {
59b6fb2Claude773 return c.redirect(`/orgs/new?error=${encodeURIComponent(err.message || "Failed to create organization")}`);
6563f0aClaude774 }
775});
776
59b6fb2Claude777// ─── Organization Profile ───────────────────────────────────────────────────
778
699e5c7Claude779orgRoutes.get("/orgs/:org", softAuth, requireAuth, async (c) => {
59b6fb2Claude780 const orgName = c.req.param("org");
781 const user = c.get("user");
782
783 let org: any;
784 try {
785 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
786 org = found;
787 } catch {
788 return c.notFound();
789 }
6563f0aClaude790
791 if (!org) return c.notFound();
792
59b6fb2Claude793 // Get members
794 let members: any[] = [];
795 try {
796 members = await db
797 .select({ member: orgMembers, user: { username: users.username, displayName: users.displayName } })
798 .from(orgMembers)
799 .innerJoin(users, eq(orgMembers.userId, users.id))
800 .where(eq(orgMembers.orgId, org.id))
801 .orderBy(asc(orgMembers.role));
802 } catch {
803 // Table may not exist
804 }
805
806 // Get teams
807 let teamList: any[] = [];
808 try {
809 teamList = await db.select().from(teams).where(eq(teams.orgId, org.id)).orderBy(asc(teams.name));
810 } catch {
811 // Table may not exist
812 }
813
814 const isMember = user && members.some((m: any) => m.member.userId === user.id);
815 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
6563f0aClaude816
7581253Claude817 const initial = ((org.displayName || org.name || "?").charAt(0) || "?").toUpperCase();
818 return (
819 c.html(
820 <Layout title={org.displayName || org.name} user={user}>
821 <div class="orgs-wrap">
822 <section class="orgs-detail">
823 <div class="orgs-detail-orb" aria-hidden="true" />
824 <div class="orgs-detail-inner">
825 <div class="orgs-avatar-lg" aria-hidden="true">{initial}</div>
826 <div class="orgs-detail-text">
827 <h2 class="orgs-detail-name">{org.displayName || org.name}</h2>
828 <p class="orgs-detail-handle">@{org.name}</p>
829 {org.description && <p class="orgs-detail-desc">{org.description}</p>}
830 <div class="orgs-detail-meta">
831 <span class="orgs-pill">
832 <span class="dot" aria-hidden="true" />
833 {members.length} {members.length === 1 ? "member" : "members"}
834 </span>
835 <span class="orgs-pill is-role">
836 <span class="dot" aria-hidden="true" />
837 {teamList.length} {teamList.length === 1 ? "team" : "teams"}
838 </span>
839 {org.website && (
840 <a
841 href={org.website}
842 target="_blank"
843 rel="noopener noreferrer"
844 style="font-size:12.5px;color:var(--text-muted);text-decoration:none"
845 >
846 {org.website}
847 </a>
848 )}
6563f0aClaude849 </div>
7581253Claude850 </div>
3e8f8e8Claude851 {isOwner && (
7581253Claude852 <div class="orgs-detail-actions">
853 <a
854 href={`/orgs/${org.name}/members/invite`}
855 class="orgs-btn orgs-btn-ghost"
856 >
857 Invite member
858 </a>
859 <a
860 href={`/orgs/${org.name}/settings`}
861 class="orgs-btn orgs-btn-primary"
862 >
863 Settings
864 </a>
6563f0aClaude865 </div>
866 )}
7581253Claude867 </div>
868 </section>
869
870 <div class="orgs-grid-2">
871 <section class="orgs-section">
872 <header class="orgs-section-head">
873 <h3 class="orgs-section-title">
874 <span class="orgs-section-icon" aria-hidden="true">
875 <OrgsTeamIcon />
876 </span>
877 Teams ({teamList.length})
878 </h3>
879 {isOwner && teamList.length > 0 && (
880 <a
881 href={`/orgs/${org.name}/teams/new`}
882 class="orgs-btn orgs-btn-ghost"
883 style="padding:6px 12px;font-size:12.5px"
884 >
885 + New team
886 </a>
887 )}
888 </header>
889 <div class="orgs-section-body">
890 {teamList.length === 0 ? (
891 <div style="text-align:center;padding:24px 12px;color:var(--text-muted);font-size:13.5px;line-height:1.55">
892 No teams yet.
893 {isOwner && (
894 <div style="margin-top:12px">
895 <a
896 href={`/orgs/${org.name}/teams/new`}
897 class="orgs-btn orgs-btn-primary"
898 >
899 Create your first team
900 </a>
901 </div>
902 )}
903 </div>
904 ) : (
905 <ul class="orgs-row-list">
906 {teamList.map((team: any) => (
907 <li class="orgs-row">
908 <div class="orgs-row-avatar" aria-hidden="true">
909 {(team.name.charAt(0) || "?").toUpperCase()}
910 </div>
911 <a
912 href={`/orgs/${org.name}/teams/${team.name}`}
913 class="orgs-row-name"
914 style="font-weight:600"
915 >
916 {team.name}
917 {team.description && (
918 <span class="orgs-row-meta" style="margin-left:8px">
919 — {team.description}
920 </span>
921 )}
922 </a>
923 <span class="orgs-pill is-role">
924 <span class="dot" aria-hidden="true" />
925 {team.permission}
926 </span>
927 </li>
928 ))}
929 </ul>
930 )}
931 </div>
932 </section>
933
934 <section class="orgs-section">
935 <header class="orgs-section-head">
936 <h3 class="orgs-section-title">
937 <span class="orgs-section-icon" aria-hidden="true">
938 <OrgsTeamIcon />
939 </span>
940 Members ({members.length})
941 </h3>
942 </header>
943 <div class="orgs-section-body">
944 {members.length === 0 ? (
945 <div style="text-align:center;padding:18px 12px;color:var(--text-muted);font-size:13.5px">
946 No members.
947 </div>
948 ) : (
949 <ul class="orgs-row-list">
950 {members.map((m: any) => {
951 const init = (
952 (m.user.displayName || m.user.username || "?").charAt(0) || "?"
953 ).toUpperCase();
954 return (
955 <li class="orgs-row">
956 <div class="orgs-row-avatar" aria-hidden="true">{init}</div>
957 <a href={`/${m.user.username}`} class="orgs-row-name">
958 {m.user.displayName || m.user.username}
959 <span class="orgs-row-meta" style="margin-left:6px">
960 @{m.user.username}
961 </span>
962 </a>
963 <span class="orgs-pill is-role">
964 <span class="dot" aria-hidden="true" />
965 {m.member.role}
966 </span>
967 </li>
968 );
969 })}
970 </ul>
971 )}
972 </div>
973 </section>
6563f0aClaude974 </div>
7581253Claude975 </div>
976 <style dangerouslySetInnerHTML={{ __html: orgsStyles }} />
977 </Layout>
978 )
6563f0aClaude979 );
980});
981
982// --- PEOPLE -----------------------------------------------------------------
983
0316dbbClaude984orgRoutes.get("/orgs/:slug/people", async (c) => {
976d7f7Claude985 const user = c.get("user");
6563f0aClaude986 const slug = c.req.param("slug");
976d7f7Claude987 // Anonymous users get bounced to login; org membership is non-public.
988 // Previously dereferenced `user!.id` immediately and crashed for anon
989 // (smoke crawl: TypeError, null is not an object — orgs.tsx:338).
990 if (!user) return c.redirect(`/login?redirect=/orgs/${slug}/people`);
6563f0aClaude991 const { org, role } = await loadOrgForUser(slug, user.id);
992 if (!org) return c.notFound();
993 if (!role) return c.redirect(`/orgs/${slug}`);
994
995 const members = await listOrgMembers(org.id);
996 const error = c.req.query("error");
997 const success = c.req.query("success");
998 const canAdmin = orgRoleAtLeast(role, "admin");
999 const canOwner = orgRoleAtLeast(role, "owner");
1000
1001 return c.html(
1002 <Layout title={`${org.name} — people`} user={user}>
1003 <div style="max-width: 800px">
1004 <div class="breadcrumb">
1005 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
1006 <span>/</span>
1007 <span>people</span>
1008 </div>
1009 <h2>People ({members.length})</h2>
1010 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
1011 {success && (
1012 <div class="auth-success">{decodeURIComponent(success)}</div>
1013 )}
1014
1015 {canAdmin && (
1016 <form
e7e240eClaude1017 method="post"
6563f0aClaude1018 action={`/orgs/${org.slug}/people/add`}
1019 style="display: flex; gap: 8px; margin-bottom: 16px"
1020 >
1021 <input
1022 type="text"
1023 name="username"
1024 placeholder="username to add"
1025 required
1026 maxLength={64}
5db1b25copilot-swe-agent[bot]1027 aria-label="Username to add"
6563f0aClaude1028 style="flex: 1"
1029 />
1030 <select name="role">
1031 <option value="member">member</option>
1032 <option value="admin">admin</option>
1033 {canOwner && <option value="owner">owner</option>}
1034 </select>
1035 <button type="submit" class="btn btn-primary">
1036 Add
1037 </button>
1038 </form>
1039 )}
1040
1041 <div
1042 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
1043 >
1044 {members.map((m) => (
1045 <div
1046 style="padding: 10px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; background: var(--bg-secondary)"
1047 >
1048 <div>
1049 <a href={`/${m.username}`}>
1050 <strong>{m.username}</strong>
1051 </a>
1052 {m.displayName && (
1053 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
1054 {m.displayName}
1055 </span>
1056 )}
1057 </div>
1058 <div style="display: flex; gap: 8px; align-items: center">
1059 {canOwner && m.userId !== user.id ? (
1060 <form
e7e240eClaude1061 method="post"
6563f0aClaude1062 action={`/orgs/${org.slug}/people/${m.userId}/role`}
1063 style="display: flex; gap: 4px"
1064 >
1065 <select name="role">
1066 <option value="member" selected={m.role === "member"}>
1067 member
1068 </option>
1069 <option value="admin" selected={m.role === "admin"}>
1070 admin
1071 </option>
1072 <option value="owner" selected={m.role === "owner"}>
1073 owner
1074 </option>
1075 </select>
1076 <button type="submit" class="btn btn-sm">
1077 save
1078 </button>
1079 </form>
1080 ) : (
1081 <span
1082 class="gate-status"
1083 style="font-size: 11px; text-transform: uppercase"
1084 >
1085 {m.role}
1086 </span>
1087 )}
1088 {canAdmin && m.userId !== user.id && (
1089 <form
e7e240eClaude1090 method="post"
6563f0aClaude1091 action={`/orgs/${org.slug}/people/${m.userId}/remove`}
1092 style="display: inline"
1093 onsubmit="return confirm('Remove this member?')"
1094 >
1095 <button type="submit" class="btn btn-sm btn-danger">
1096 remove
1097 </button>
1098 </form>
1099 )}
1100 </div>
1101 </div>
1102 ))}
1103 </div>
1104 </div>
1105 </Layout>
1106 );
1107});
1108
59b6fb2Claude1109orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
1110 const orgName = c.req.param("org");
6563f0aClaude1111 const user = c.get("user")!;
1112
59b6fb2Claude1113 let org: any;
6563f0aClaude1114 try {
59b6fb2Claude1115 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1116 org = found;
1117 } catch {
1118 return c.notFound();
6563f0aClaude1119 }
1120 if (!org) return c.notFound();
1121
59b6fb2Claude1122 // Check owner
1123 let isOwner = false;
6563f0aClaude1124 try {
59b6fb2Claude1125 const [member] = await db.select().from(orgMembers)
1126 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude1127 .limit(1);
59b6fb2Claude1128 isOwner = !!member;
1129 } catch { /* */ }
1130 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
6563f0aClaude1131
1132 const success = c.req.query("success");
0316dbbClaude1133 const error = c.req.query("error");
1134 const canAdmin = isOwner;
1135 let orgTeams: any[] = [];
1136 try {
1137 orgTeams = await db.select().from(teams).where(eq(teams.orgId, org.id));
1138 } catch { /* */ }
6563f0aClaude1139
1140 return c.html(
1141 <Layout title={`${org.name} — teams`} user={user}>
1142 <div style="max-width: 800px">
1143 <div class="breadcrumb">
1144 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
1145 <span>/</span>
1146 <span>teams</span>
1147 </div>
1148 <h2>Teams ({orgTeams.length})</h2>
1149 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
1150 {success && (
1151 <div class="auth-success">{decodeURIComponent(success)}</div>
1152 )}
1153
1154 {canAdmin && (
1155 <form
e7e240eClaude1156 method="post"
6563f0aClaude1157 action={`/orgs/${org.slug}/teams/new`}
1158 style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; margin-bottom: 16px"
1159 >
1160 <input
1161 type="text"
1162 name="slug"
1163 placeholder="team-slug"
1164 required
1165 maxLength={39}
1166 pattern="[a-z0-9][a-z0-9-]{0,38}"
5db1b25copilot-swe-agent[bot]1167 aria-label="Team slug"
6563f0aClaude1168 />
1169 <input
1170 type="text"
1171 name="name"
1172 placeholder="Team name"
1173 required
1174 maxLength={80}
5db1b25copilot-swe-agent[bot]1175 aria-label="Team name"
6563f0aClaude1176 />
1177 <button type="submit" class="btn btn-primary">
1178 Create team
1179 </button>
1180 </form>
1181 )}
1182
1183 <div
1184 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
1185 >
1186 {orgTeams.length === 0 ? (
1187 <div
1188 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
1189 >
1190 No teams yet.
1191 </div>
1192 ) : (
1193 orgTeams.map((t) => (
1194 <a
1195 href={`/orgs/${org.slug}/teams/${t.slug}`}
1196 style="display: block; padding: 12px 16px; border-bottom: 1px solid var(--border); text-decoration: none; color: var(--text); background: var(--bg-secondary)"
1197 >
1198 <strong>{t.name}</strong>{" "}
1199 <span style="color: var(--text-muted); font-size: 12px">
1200 @{t.slug}
1201 </span>
1202 {t.description && (
1203 <div style="color: var(--text-muted); font-size: 12px">
1204 {t.description}
1205 </div>
1206 )}
1207 </a>
1208 ))
1209 )}
1210 </div>
0316dbbClaude1211 </div>
6563f0aClaude1212 </Layout>
1213 );
1214});
1215
59b6fb2Claude1216orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
1217 const orgName = c.req.param("org");
6563f0aClaude1218 const user = c.get("user")!;
1219 const body = await c.req.parseBody();
1220
1221 try {
59b6fb2Claude1222 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1223 if (!org) return c.redirect("/");
1224
1225 const [member] = await db.select().from(orgMembers)
1226 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
1227 .limit(1);
1228 if (!member) return c.redirect(`/orgs/${orgName}`);
1229
1230 await db.update(organizations).set({
1231 displayName: String(body.displayName || "").trim() || org.name,
1232 description: String(body.description || "").trim() || null,
1233 website: String(body.website || "").trim() || null,
1234 location: String(body.location || "").trim() || null,
1235 updatedAt: new Date(),
1236 }).where(eq(organizations.id, org.id));
1237 } catch { /* */ }
1238
1239 return c.redirect(`/orgs/${orgName}/settings?success=1`);
6563f0aClaude1240});
1241
59b6fb2Claude1242orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
1243 const orgName = c.req.param("org");
6563f0aClaude1244 const user = c.get("user")!;
1245
1246 try {
59b6fb2Claude1247 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1248 if (!org) return c.redirect("/");
1249
1250 const [member] = await db.select().from(orgMembers)
1251 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
6563f0aClaude1252 .limit(1);
59b6fb2Claude1253 if (!member) return c.redirect(`/orgs/${orgName}`);
1254
1255 await db.delete(organizations).where(eq(organizations.id, org.id));
1256 } catch { /* */ }
6563f0aClaude1257
59b6fb2Claude1258 return c.redirect("/");
1259});
1260
1261// ─── Member Invite ──────────────────────────────────────────────────────────
1262
1263orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
1264 const orgName = c.req.param("org");
1265 const user = c.get("user")!;
6563f0aClaude1266 const error = c.req.query("error");
1267 const success = c.req.query("success");
1268
1269 return c.html(
0316dbbClaude1270 <Layout title={`Invite — ${orgName}`} user={user}>
1271 <div style="max-width: 560px">
6563f0aClaude1272 <div class="breadcrumb">
0316dbbClaude1273 <a href={`/orgs/${orgName}`}>{orgName}</a>
6563f0aClaude1274 <span>/</span>
0316dbbClaude1275 <span>invite</span>
6563f0aClaude1276 </div>
0316dbbClaude1277 <h2>Invite a member</h2>
6563f0aClaude1278 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
1279 {success && (
1280 <div class="auth-success">{decodeURIComponent(success)}</div>
1281 )}
0316dbbClaude1282 <form
1283 method="post"
1284 action={`/orgs/${orgName}/members/invite`}
1285 style="display: flex; gap: 8px; margin-bottom: 16px"
6563f0aClaude1286 >
0316dbbClaude1287 <input
1288 type="text"
1289 name="username"
1290 placeholder="username"
1291 required
1292 maxLength={64}
5db1b25copilot-swe-agent[bot]1293 aria-label="Username to invite"
0316dbbClaude1294 style="flex: 1"
1295 />
1296 <select name="role">
1297 <option value="member">member</option>
1298 <option value="admin">admin</option>
1299 </select>
1300 <button type="submit" class="btn btn-primary">
1301 Invite
1302 </button>
1303 </form>
6563f0aClaude1304 </div>
1305 </Layout>
1306 );
1307});
1308
59b6fb2Claude1309orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
1310 const orgName = c.req.param("org");
6563f0aClaude1311 const user = c.get("user")!;
1312 const body = await c.req.parseBody();
59b6fb2Claude1313 const username = String(body.username || "").trim();
1314 const role = String(body.role || "member");
6563f0aClaude1315
1316 try {
59b6fb2Claude1317 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1318 if (!org) return c.redirect("/");
6563f0aClaude1319
59b6fb2Claude1320 // Check inviter is owner or admin
1321 const [inviter] = await db.select().from(orgMembers)
1322 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
6563f0aClaude1323 .limit(1);
59b6fb2Claude1324 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
1325 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
6563f0aClaude1326 }
1327
59b6fb2Claude1328 // Find user
1329 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
1330 if (!targetUser) {
1331 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
6563f0aClaude1332 }
1333
59b6fb2Claude1334 // Check if already member
1335 const [existing] = await db.select().from(orgMembers)
1336 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
6563f0aClaude1337 .limit(1);
59b6fb2Claude1338 if (existing) {
1339 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
1340 }
6563f0aClaude1341
59b6fb2Claude1342 await db.insert(orgMembers).values({
1343 orgId: org.id,
1344 userId: targetUser.id,
1345 role: ["owner", "admin", "member"].includes(role) ? role : "member",
6563f0aClaude1346 });
59b6fb2Claude1347
1348 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
1349 } catch (err: any) {
1350 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
6563f0aClaude1351 }
1352});
1353
59b6fb2Claude1354// ─── Team Create ────────────────────────────────────────────────────────────
7437605Claude1355
59b6fb2Claude1356orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
1357 const orgName = c.req.param("org");
7437605Claude1358 const user = c.get("user")!;
1359 const error = c.req.query("error");
1360
0316dbbClaude1361 let org: any;
1362 try {
1363 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1364 org = found;
1365 } catch {
1366 return c.notFound();
1367 }
1368 if (!org) return c.notFound();
1369
7437605Claude1370 return c.html(
0316dbbClaude1371 <Layout title={`New team — ${org.name}`} user={user}>
7437605Claude1372 <div class="settings-container" style="max-width: 560px">
1373 <div class="breadcrumb">
1374 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
1375 <span>/</span>
0316dbbClaude1376 <span>new team</span>
7437605Claude1377 </div>
0316dbbClaude1378 <h2>Create team in {org.name}</h2>
7437605Claude1379 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
0316dbbClaude1380 <form method="post" action={`/orgs/${org.slug}/teams/new`}>
7437605Claude1381 <div class="form-group">
0316dbbClaude1382 <label for="name">Team name</label>
7437605Claude1383 <input
1384 type="text"
1385 id="name"
1386 name="name"
1387 required
0316dbbClaude1388 maxLength={80}
1389 placeholder="Platform engineers"
7437605Claude1390 autocomplete="off"
1391 />
1392 </div>
1393 <div class="form-group">
1394 <label for="description">Description (optional)</label>
1395 <textarea
1396 id="description"
1397 name="description"
1398 rows={3}
1399 maxLength={500}
1400 />
1401 </div>
1402 <div class="form-group">
0316dbbClaude1403 <label for="permission">Default permission</label>
1404 <select id="permission" name="permission">
1405 <option value="read">read</option>
1406 <option value="write">write</option>
1407 <option value="admin">admin</option>
1408 </select>
7437605Claude1409 </div>
1410 <button type="submit" class="btn btn-primary">
0316dbbClaude1411 Create team
7437605Claude1412 </button>
1413 </form>
1414 </div>
1415 </Layout>
1416 );
1417});
1418
59b6fb2Claude1419orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
1420 const orgName = c.req.param("org");
7437605Claude1421 const user = c.get("user")!;
1422 const body = await c.req.parseBody();
1423 const name = String(body.name || "").trim();
59b6fb2Claude1424 const description = String(body.description || "").trim();
1425 const permission = String(body.permission || "read");
7437605Claude1426
1427 try {
59b6fb2Claude1428 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1429 if (!org) return c.redirect("/");
1430
1431 const [member] = await db.select().from(orgMembers)
1432 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
7437605Claude1433 .limit(1);
59b6fb2Claude1434 if (!member || member.role === "member") {
1435 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
7437605Claude1436 }
1437
59b6fb2Claude1438 if (!name) {
1439 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
7437605Claude1440 }
1441
45ac593ccantynz-alt1442 // `teams.slug` is NOT NULL and carries the unique (org_id, slug) index,
1443 // but this insert omitted it — the same latent bug as POST /orgs/new, and
1444 // invisible for the same reason: schema-extensions declared a rival
1445 // `teams` without the column, so tsc never checked it. Team names are
1446 // free text, so derive a URL-safe slug rather than reusing the name.
1447 const slug = name
1448 .toLowerCase()
1449 .normalize("NFKD")
1450 .replace(/[^a-z0-9\s-]/g, "")
1451 .replace(/\s+/g, "-")
1452 .replace(/-+/g, "-")
1453 .replace(/^-+|-+$/g, "");
1454 if (!slug) {
1455 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+must+contain+letters+or+numbers`);
1456 }
1457
59b6fb2Claude1458 await db.insert(teams).values({
1459 orgId: org.id,
1460 name,
45ac593ccantynz-alt1461 slug,
59b6fb2Claude1462 description: description || null,
1463 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
1464 });
1465
1466 return c.redirect(`/orgs/${orgName}`);
1467 } catch (err: any) {
1468 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
7437605Claude1469 }
1470});
1471
59b6fb2Claude1472// ─── Team Detail ────────────────────────────────────────────────────────────
7437605Claude1473
59b6fb2Claude1474orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
1475 const orgName = c.req.param("org");
1476 const teamName = c.req.param("team");
1477 const user = c.get("user");
1478
1479 let org: any, team: any;
7437605Claude1480 try {
59b6fb2Claude1481 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
1482 if (!org) return c.notFound();
1483 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
1484 if (!team) return c.notFound();
1485 } catch {
1486 return c.notFound();
7437605Claude1487 }
1488
59b6fb2Claude1489 let members: any[] = [];
1490 try {
1491 members = await db
1492 .select({ member: teamMembers, user: { username: users.username } })
1493 .from(teamMembers)
1494 .innerJoin(users, eq(teamMembers.userId, users.id))
1495 .where(eq(teamMembers.teamId, team.id));
1496 } catch { /* */ }
1497
1498 let repos: any[] = [];
1499 try {
1500 repos = await db
1501 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
1502 .from(teamRepos)
1503 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
1504 .where(eq(teamRepos.teamId, team.id));
1505 } catch { /* */ }
1506
7437605Claude1507 return c.html(
59b6fb2Claude1508 <Layout title={`${teamName} — ${orgName}`} user={user}>
3e8f8e8Claude1509 <Container maxWidth={800}>
1510 <Section style="margin-bottom:24px">
1511 <Text size={14} muted>
59b6fb2Claude1512 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
3e8f8e8Claude1513 </Text>
59b6fb2Claude1514 <h2>{team.name}</h2>
3e8f8e8Claude1515 {team.description && <p style="margin-top:4px"><Text muted>{team.description}</Text></p>}
1516 <div style="margin-top:8px">
1517 <Badge>{team.permission} access</Badge>
7437605Claude1518 </div>
3e8f8e8Claude1519 </Section>
59b6fb2Claude1520
3e8f8e8Claude1521 <Grid cols="1fr 1fr" gap={24}>
59b6fb2Claude1522 <div>
3e8f8e8Claude1523 <Section title={`Members (${members.length})`}>
1524 {members.length === 0 ? (
1525 <EmptyState><Text size={14} muted>No members yet.</Text></EmptyState>
1526 ) : (
1527 <List>
1528 {members.map((m: any) => (
1529 <ListItem>
1530 <a href={`/${m.user.username}`}>{m.user.username}</a>
1531 </ListItem>
1532 ))}
1533 </List>
1534 )}
1535 </Section>
7437605Claude1536 </div>
59b6fb2Claude1537 <div>
3e8f8e8Claude1538 <Section title={`Repositories (${repos.length})`}>
1539 {repos.length === 0 ? (
1540 <EmptyState><Text size={14} muted>No repositories assigned.</Text></EmptyState>
1541 ) : (
1542 <List>
1543 {repos.map((r: any) => (
1544 <ListItem>
1545 <span>{r.repo.name}</span>
1546 <Badge style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</Badge>
1547 </ListItem>
1548 ))}
1549 </List>
1550 )}
1551 </Section>
59b6fb2Claude1552 </div>
3e8f8e8Claude1553 </Grid>
1554 </Container>
7437605Claude1555 </Layout>
1556 );
1557});
1558
59b6fb2Claude1559export default orgRoutes;