Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit59b6fb2unknown_key

feat: add notifications, organizations, onboarding + wire CSRF/rate-limiting

feat: add notifications, organizations, onboarding + wire CSRF/rate-limiting

- Notification center with unread/all filter, mark-read, bell icon API
- Organization management with members, teams, and repo access
- Guided onboarding flow with step-by-step setup wizard
- CSRF protection wired globally (skips API and git routes)
- Rate limiting on git operations and search endpoints

https://claude.ai/code/session_01EQhj4UBSQDKmb7UwUgtWK7
Claude committed on April 18, 2026Parent: 45e31d0
5 files changed+979259b6fb27880f68fe9b0f68890af8b314f0b21bae
5 changed files+979−2
Modifiedsrc/app.tsx+25−1View fileUnifiedSplit
1818import exploreRoutes from "./routes/explore";
1919import tokenRoutes from "./routes/tokens";
2020import contributorRoutes from "./routes/contributors";
21import notificationRoutes from "./routes/notifications";
22import orgRoutes from "./routes/orgs";
23import onboardingRoutes from "./routes/onboarding";
2124import webRoutes from "./routes/web";
22import { authRateLimit } from "./middleware/rate-limit";
25import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
26import { csrfToken, csrfProtect } from "./middleware/csrf";
2327
2428const app = new Hono();
2529
2731app.use("*", logger());
2832app.use("/api/*", cors());
2933
34// CSRF protection — set token on all requests, validate on mutations
35app.use("*", csrfToken);
36app.use("*", csrfProtect);
37
3038// Rate limit auth routes
3139app.use("/login", authRateLimit);
3240app.use("/register", authRateLimit);
3341
42// Rate limit git operations
43app.use("/:owner/:repo.git/*", gitRateLimit);
44
45// Rate limit search
46app.use("/:owner/:repo/search", searchRateLimit);
47app.use("/explore", searchRateLimit);
48
3449// Git Smart HTTP protocol routes (must be before web routes)
3550app.route("/", gitRoutes);
3651
5267// API tokens
5368app.route("/", tokenRoutes);
5469
70// Notifications
71app.route("/", notificationRoutes);
72
73// Organizations
74app.route("/", orgRoutes);
75
5576// Repo settings (description, visibility, delete)
5677app.route("/", repoSettings);
5778
79100// Explore page
80101app.route("/", exploreRoutes);
81102
103// Onboarding
104app.route("/", onboardingRoutes);
105
82106// Web UI (catch-all, must be last)
83107app.route("/", webRoutes);
84108
Modifiedsrc/middleware/csrf.ts+6−1View fileUnifiedSplit
5454 return next();
5555 }
5656
57 // Skip CSRF for git protocol routes
57 // Skip CSRF for API routes (they use token auth, not cookies)
5858 const path = c.req.path;
59 if (path.startsWith("/api/")) {
60 return next();
61 }
62
63 // Skip CSRF for git protocol routes
5964 if (path.endsWith(".git/git-upload-pack") || path.endsWith(".git/git-receive-pack")) {
6065 return next();
6166 }
Addedsrc/routes/notifications.tsx+183−0View fileUnifiedSplit
1/**
2 * Notification routes — bell icon, list, mark read, clear.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, sql } from "drizzle-orm";
7import { db } from "../db";
8import { notifications } from "../db/schema-extensions";
9import { users, repositories } from "../db/schema";
10import { Layout } from "../views/layout";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13
14const notificationRoutes = new Hono<AuthEnv>();
15
16// Notification list page
17notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => {
18 const user = c.get("user")!;
19 const filter = c.req.query("filter") || "unread";
20
21 const query = db
22 .select()
23 .from(notifications)
24 .where(
25 filter === "all"
26 ? eq(notifications.userId, user.id)
27 : and(eq(notifications.userId, user.id), eq(notifications.isRead, false))
28 )
29 .orderBy(desc(notifications.createdAt))
30 .limit(50);
31
32 let items: any[] = [];
33 try {
34 items = await query;
35 } catch {
36 // Table may not exist yet
37 }
38
39 let unreadCount = 0;
40 try {
41 const [result] = await db
42 .select({ count: sql<number>`count(*)` })
43 .from(notifications)
44 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
45 unreadCount = result?.count ?? 0;
46 } catch {
47 // Table may not exist yet
48 }
49
50 return c.html(
51 <Layout title="Notifications" user={user}>
52 <div style="max-width:800px">
53 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
54 <h2>Notifications</h2>
55 <div style="display:flex;gap:8px">
56 {unreadCount > 0 && (
57 <form method="post" action="/notifications/read-all">
58 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
59 <button type="submit" class="btn btn-sm">Mark all read</button>
60 </form>
61 )}
62 </div>
63 </div>
64
65 <div class="issue-tabs" style="margin-bottom:16px">
66 <a href="/notifications?filter=unread" class={filter === "unread" ? "active" : ""}>
67 Unread {unreadCount > 0 && `(${unreadCount})`}
68 </a>
69 <a href="/notifications?filter=all" class={filter === "all" ? "active" : ""}>
70 All
71 </a>
72 </div>
73
74 {items.length === 0 ? (
75 <div class="empty-state">
76 <h2>All caught up</h2>
77 <p>No {filter === "unread" ? "unread " : ""}notifications.</p>
78 </div>
79 ) : (
80 <div class="issue-list">
81 {items.map((n: any) => (
82 <div class="issue-item" style={n.isRead ? "opacity:0.6" : ""}>
83 <div style="font-size:18px;padding-top:2px">
84 {n.type === "issue_comment" ? "\u{1F4AC}" :
85 n.type === "pr_review" ? "\u{1F50D}" :
86 n.type === "mention" ? "\u{1F4E3}" :
87 n.type === "star" ? "\u2B50" :
88 n.type === "ci_status" ? "\u2699\uFE0F" : "\u{1F514}"}
89 </div>
90 <div style="flex:1">
91 <div style="font-size:14px;font-weight:500">
92 {n.url ? <a href={n.url} style="color:var(--text)">{n.title}</a> : n.title}
93 </div>
94 {n.body && (
95 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
96 {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body}
97 </div>
98 )}
99 <div style="font-size:12px;color:var(--text-muted);margin-top:4px">
100 {formatRelative(n.createdAt)}
101 </div>
102 </div>
103 {!n.isRead && (
104 <form method="post" action={`/notifications/${n.id}/read`} style="flex-shrink:0">
105 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
106 <button type="submit" class="btn btn-sm btn-ghost" title="Mark as read">
107 \u2713
108 </button>
109 </form>
110 )}
111 </div>
112 ))}
113 </div>
114 )}
115 </div>
116 </Layout>
117 );
118});
119
120// Mark single notification as read
121notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async (c) => {
122 const user = c.get("user")!;
123 const id = c.req.param("id");
124
125 try {
126 await db
127 .update(notifications)
128 .set({ isRead: true })
129 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
130 } catch {
131 // Table may not exist
132 }
133
134 return c.redirect("/notifications");
135});
136
137// Mark all as read
138notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async (c) => {
139 const user = c.get("user")!;
140
141 try {
142 await db
143 .update(notifications)
144 .set({ isRead: true })
145 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
146 } catch {
147 // Table may not exist
148 }
149
150 return c.redirect("/notifications");
151});
152
153// API: Get unread count (for bell icon polling)
154notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
155 const user = c.get("user");
156 if (!user) return c.json({ count: 0 });
157
158 try {
159 const [result] = await db
160 .select({ count: sql<number>`count(*)` })
161 .from(notifications)
162 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
163 return c.json({ count: result?.count ?? 0 });
164 } catch {
165 return c.json({ count: 0 });
166 }
167});
168
169function formatRelative(date: Date | string): string {
170 const d = typeof date === "string" ? new Date(date) : date;
171 const now = new Date();
172 const diffMs = now.getTime() - d.getTime();
173 const diffMins = Math.floor(diffMs / 60000);
174 if (diffMins < 1) return "just now";
175 if (diffMins < 60) return `${diffMins}m ago`;
176 const diffHours = Math.floor(diffMins / 60);
177 if (diffHours < 24) return `${diffHours}h ago`;
178 const diffDays = Math.floor(diffHours / 24);
179 if (diffDays < 30) return `${diffDays}d ago`;
180 return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
181}
182
183export default notificationRoutes;
Addedsrc/routes/onboarding.tsx+200−0View fileUnifiedSplit
1/**
2 * Onboarding flow — guided setup for new users.
3 */
4
5import { Hono } from "hono";
6import { Layout } from "../views/layout";
7import { softAuth, requireAuth } from "../middleware/auth";
8import type { AuthEnv } from "../middleware/auth";
9import { eq, sql } from "drizzle-orm";
10import { db } from "../db";
11import { repositories, sshKeys, apiTokens, users } from "../db/schema";
12
13const onboardingRoutes = new Hono<AuthEnv>();
14
15onboardingRoutes.get("/getting-started", softAuth, requireAuth, async (c) => {
16 const user = c.get("user")!;
17
18 // Check what the user has done
19 let repoCount = 0;
20 let hasKeys = false;
21 let hasTokens = false;
22
23 try {
24 const [repos] = await db
25 .select({ count: sql<number>`count(*)` })
26 .from(repositories)
27 .where(eq(repositories.ownerId, user.id));
28 repoCount = repos?.count ?? 0;
29
30 const [keys] = await db
31 .select({ count: sql<number>`count(*)` })
32 .from(sshKeys)
33 .where(eq(sshKeys.userId, user.id));
34 hasKeys = (keys?.count ?? 0) > 0;
35
36 const [tokens] = await db
37 .select({ count: sql<number>`count(*)` })
38 .from(apiTokens)
39 .where(eq(apiTokens.userId, user.id));
40 hasTokens = (tokens?.count ?? 0) > 0;
41 } catch { /* DB may not be ready */ }
42
43 const steps = [
44 { label: "Create account", completed: true, active: false },
45 { label: "Create a repository", completed: repoCount > 0, active: repoCount === 0 },
46 { label: "Push your code", completed: false, active: repoCount > 0 },
47 { label: "Set up SSH key", completed: hasKeys, active: !hasKeys && repoCount > 0 },
48 { label: "Create API token", completed: hasTokens, active: !hasTokens && hasKeys },
49 ];
50
51 const activeStep = steps.findIndex((s) => s.active);
52
53 return c.html(
54 <Layout title="Getting Started" user={user}>
55 <div style="max-width:700px;margin:0 auto">
56 <div style="text-align:center;padding:40px 0 32px">
57 <h1 style="font-size:32px;margin-bottom:8px">Welcome to gluecron</h1>
58 <p style="font-size:16px;color:var(--text-muted)">Let's get you set up in a few steps.</p>
59 </div>
60
61 <div style="display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:40px">
62 {steps.map((step, i) => (
63 <>
64 {i > 0 && (
65 <div style={`flex:1;height:2px;background:${step.completed || steps[i - 1].completed ? "var(--green)" : "var(--border)"};min-width:30px`} />
66 )}
67 <div style="display:flex;flex-direction:column;align-items:center;gap:6px">
68 <div style={`width:36px;height:36px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:600;${step.completed ? "background:var(--green);border:2px solid var(--green);color:#fff" : step.active ? "border:2px solid var(--accent);color:var(--accent);background:transparent" : "border:2px solid var(--border);color:var(--text-muted);background:transparent"}`}>
69 {step.completed ? "\u2713" : i + 1}
70 </div>
71 <span style={`font-size:11px;white-space:nowrap;${step.active ? "color:var(--text);font-weight:500" : "color:var(--text-muted)"}`}>
72 {step.label}
73 </span>
74 </div>
75 </>
76 ))}
77 </div>
78
79 {/* Step 1: Create repository */}
80 {activeStep <= 1 && repoCount === 0 && (
81 <StepCard
82 number={1}
83 title="Create your first repository"
84 description="A repository contains all your project files, including the revision history."
85 active
86 >
87 <a href="/new" class="btn btn-primary" style="margin-top:12px">Create repository</a>
88 </StepCard>
89 )}
90
91 {/* Step 2: Push code */}
92 {repoCount > 0 && (
93 <StepCard
94 number={2}
95 title="Push your code"
96 description="Connect your local repository and push your first commit."
97 >
98 <pre style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;font-family:var(--font-mono);font-size:13px;margin-top:12px;line-height:1.8;overflow-x:auto">{`# Add the remote
99git remote add gluecron http://localhost:3000/${user.username}/your-repo.git
100
101# Push your code
102git push -u gluecron main`}</pre>
103 <button type="button" class="btn btn-sm" data-clipboard={`git remote add gluecron http://localhost:3000/${user.username}/your-repo.git\ngit push -u gluecron main`} style="margin-top:8px">
104 Copy commands
105 </button>
106 </StepCard>
107 )}
108
109 {/* Step 3: SSH key */}
110 <StepCard
111 number={3}
112 title={hasKeys ? "SSH key added \u2713" : "Add an SSH key"}
113 description={hasKeys ? "Your SSH key is configured." : "SSH keys let you push code securely without entering your password."}
114 completed={hasKeys}
115 >
116 {!hasKeys && (
117 <>
118 <pre style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);padding:16px;font-family:var(--font-mono);font-size:13px;margin-top:12px;line-height:1.8">{`# Generate a key (if you don't have one)
119ssh-keygen -t ed25519 -C "your@email.com"
120
121# Copy your public key
122cat ~/.ssh/id_ed25519.pub`}</pre>
123 <a href="/settings/keys" class="btn btn-primary btn-sm" style="margin-top:12px">Add SSH key</a>
124 </>
125 )}
126 </StepCard>
127
128 {/* Step 4: API token */}
129 <StepCard
130 number={4}
131 title={hasTokens ? "API token created \u2713" : "Create an API token"}
132 description={hasTokens ? "You have an API token configured." : "API tokens let you automate workflows and integrate with CI/CD."}
133 completed={hasTokens}
134 >
135 {!hasTokens && (
136 <>
137 <p style="font-size:14px;color:var(--text-muted);margin-top:12px">
138 Use tokens to authenticate with the gluecron API for scripting and automation.
139 </p>
140 <a href="/settings/tokens" class="btn btn-primary btn-sm" style="margin-top:12px">Create token</a>
141 </>
142 )}
143 </StepCard>
144
145 {/* All done */}
146 {repoCount > 0 && hasKeys && hasTokens && (
147 <div style="text-align:center;padding:40px 0;border:1px solid var(--green);border-radius:var(--radius);margin-top:24px;background:rgba(63,185,80,0.05)">
148 <div style="font-size:48px;margin-bottom:12px">&#127881;</div>
149 <h2>You're all set!</h2>
150 <p style="color:var(--text-muted);margin-top:8px">You've completed the setup. Start building something great.</p>
151 <div style="display:flex;gap:12px;justify-content:center;margin-top:20px">
152 <a href="/" class="btn btn-primary">Go to dashboard</a>
153 <a href="/api/docs" class="btn">Explore the API</a>
154 <a href="/explore" class="btn">Discover repos</a>
155 </div>
156 </div>
157 )}
158
159 <div style="text-align:center;padding:32px 0;color:var(--text-muted);font-size:13px">
160 <p>Need help? Check the <a href="/api/docs">API documentation</a> or press <kbd class="kbd">?</kbd> for keyboard shortcuts.</p>
161 </div>
162 </div>
163 </Layout>
164 );
165});
166
167const StepCard = ({
168 number,
169 title,
170 description,
171 active,
172 completed,
173 children,
174}: {
175 number: number;
176 title: string;
177 description: string;
178 active?: boolean;
179 completed?: boolean;
180 children?: any;
181}) => (
182 <div
183 style={`border:1px solid ${completed ? "var(--green)" : active ? "var(--accent)" : "var(--border)"};border-radius:var(--radius);padding:20px;margin-bottom:16px;${completed ? "opacity:0.7;" : ""}`}
184 >
185 <div style="display:flex;align-items:flex-start;gap:12px">
186 <div
187 style={`width:28px;height:28px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:600;flex-shrink:0;${completed ? "background:var(--green);color:#fff" : "background:var(--bg-tertiary);color:var(--text-muted)"}`}
188 >
189 {completed ? "\u2713" : number}
190 </div>
191 <div style="flex:1">
192 <h3 style="font-size:16px;margin-bottom:4px">{title}</h3>
193 <p style="font-size:14px;color:var(--text-muted)">{description}</p>
194 {children}
195 </div>
196 </div>
197 </div>
198);
199
200export default onboardingRoutes;
Addedsrc/routes/orgs.tsx+565−0View fileUnifiedSplit
1/**
2 * Organization and team routes — create orgs, manage members, teams, permissions.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
7import { db } from "../db";
8import { organizations, orgMembers, teams, teamMembers, teamRepos } from "../db/schema-extensions";
9import { users, repositories } from "../db/schema";
10import { Layout } from "../views/layout";
11import { softAuth, requireAuth } from "../middleware/auth";
12import type { AuthEnv } from "../middleware/auth";
13
14const orgRoutes = new Hono<AuthEnv>();
15
16// ─── Organization List / Create ─────────────────────────────────────────────
17
18orgRoutes.get("/orgs/new", softAuth, requireAuth, (c) => {
19 const user = c.get("user")!;
20 const error = c.req.query("error");
21
22 return c.html(
23 <Layout title="New Organization" user={user}>
24 <div style="max-width:500px">
25 <h2 style="margin-bottom:16px">Create a new organization</h2>
26 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
27 <form method="post" action="/orgs/new">
28 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
29 <div class="form-group">
30 <label for="name">Organization name</label>
31 <input type="text" id="name" name="name" required pattern="^[a-zA-Z0-9._-]+$" placeholder="my-org" autocomplete="off" />
32 <span style="font-size:12px;color:var(--text-muted)">Letters, numbers, hyphens, dots, underscores only</span>
33 </div>
34 <div class="form-group">
35 <label for="displayName">Display name</label>
36 <input type="text" id="displayName" name="displayName" placeholder="My Organization" />
37 </div>
38 <div class="form-group">
39 <label for="description">Description</label>
40 <textarea id="description" name="description" rows={3} placeholder="What does this organization do?" />
41 </div>
42 <div class="form-group">
43 <label for="website">Website</label>
44 <input type="url" id="website" name="website" placeholder="https://example.com" />
45 </div>
46 <button type="submit" class="btn btn-primary">Create organization</button>
47 </form>
48 </div>
49 </Layout>
50 );
51});
52
53orgRoutes.post("/orgs/new", softAuth, requireAuth, async (c) => {
54 const user = c.get("user")!;
55 const body = await c.req.parseBody();
56 const name = String(body.name || "").trim();
57 const displayName = String(body.displayName || "").trim();
58 const description = String(body.description || "").trim();
59 const website = String(body.website || "").trim();
60
61 if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) {
62 return c.redirect("/orgs/new?error=Invalid+organization+name");
63 }
64
65 try {
66 // Check if name is taken (by user or org)
67 const [existingUser] = await db.select().from(users).where(eq(users.username, name)).limit(1);
68 if (existingUser) {
69 return c.redirect("/orgs/new?error=Name+already+taken");
70 }
71
72 const [existingOrg] = await db.select().from(organizations).where(eq(organizations.name, name)).limit(1);
73 if (existingOrg) {
74 return c.redirect("/orgs/new?error=Organization+already+exists");
75 }
76
77 const [org] = await db
78 .insert(organizations)
79 .values({
80 name,
81 displayName: displayName || name,
82 description: description || null,
83 website: website || null,
84 })
85 .returning();
86
87 // Add creator as owner
88 await db.insert(orgMembers).values({
89 orgId: org.id,
90 userId: user.id,
91 role: "owner",
92 });
93
94 return c.redirect(`/orgs/${name}`);
95 } catch (err: any) {
96 return c.redirect(`/orgs/new?error=${encodeURIComponent(err.message || "Failed to create organization")}`);
97 }
98});
99
100// ─── Organization Profile ───────────────────────────────────────────────────
101
102orgRoutes.get("/orgs/:org", softAuth, async (c) => {
103 const orgName = c.req.param("org");
104 const user = c.get("user");
105
106 let org: any;
107 try {
108 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
109 org = found;
110 } catch {
111 return c.notFound();
112 }
113
114 if (!org) return c.notFound();
115
116 // Get members
117 let members: any[] = [];
118 try {
119 members = await db
120 .select({ member: orgMembers, user: { username: users.username, displayName: users.displayName } })
121 .from(orgMembers)
122 .innerJoin(users, eq(orgMembers.userId, users.id))
123 .where(eq(orgMembers.orgId, org.id))
124 .orderBy(asc(orgMembers.role));
125 } catch {
126 // Table may not exist
127 }
128
129 // Get teams
130 let teamList: any[] = [];
131 try {
132 teamList = await db.select().from(teams).where(eq(teams.orgId, org.id)).orderBy(asc(teams.name));
133 } catch {
134 // Table may not exist
135 }
136
137 const isMember = user && members.some((m: any) => m.member.userId === user.id);
138 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
139
140 return c.html(
141 <Layout title={org.displayName || org.name} user={user}>
142 <div style="max-width:900px">
143 <div style="display:flex;gap:24px;margin-bottom:32px">
144 <div class="user-avatar" style="width:80px;height:80px;font-size:32px">
145 {(org.displayName || org.name)[0].toUpperCase()}
146 </div>
147 <div>
148 <h2>{org.displayName || org.name}</h2>
149 <div style="font-size:14px;color:var(--text-muted)">@{org.name}</div>
150 {org.description && <p style="margin-top:8px;font-size:14px;color:var(--text-muted)">{org.description}</p>}
151 {org.website && (
152 <a href={org.website} style="font-size:13px" target="_blank" rel="noopener noreferrer">
153 {org.website}
154 </a>
155 )}
156 </div>
157 {isOwner && (
158 <div style="margin-left:auto">
159 <a href={`/orgs/${org.name}/settings`} class="btn btn-sm">Settings</a>
160 </div>
161 )}
162 </div>
163
164 <div style="display:grid;grid-template-columns:1fr 300px;gap:32px">
165 <div>
166 <h3 style="margin-bottom:16px">Teams</h3>
167 {teamList.length === 0 ? (
168 <div class="empty-state" style="padding:24px">
169 <p>No teams yet.</p>
170 {isOwner && <a href={`/orgs/${org.name}/teams/new`} class="btn btn-sm btn-primary" style="margin-top:8px">Create a team</a>}
171 </div>
172 ) : (
173 <div class="issue-list">
174 {teamList.map((team: any) => (
175 <div class="issue-item">
176 <div>
177 <div style="font-weight:500;font-size:15px">
178 <a href={`/orgs/${org.name}/teams/${team.name}`} style="color:var(--text)">{team.name}</a>
179 </div>
180 {team.description && <div style="font-size:13px;color:var(--text-muted)">{team.description}</div>}
181 </div>
182 <span class="badge">{team.permission}</span>
183 </div>
184 ))}
185 </div>
186 )}
187 {isOwner && teamList.length > 0 && (
188 <a href={`/orgs/${org.name}/teams/new`} class="btn btn-sm btn-primary" style="margin-top:12px">Create team</a>
189 )}
190 </div>
191
192 <div>
193 <h3 style="margin-bottom:16px">Members ({members.length})</h3>
194 <div style="display:flex;flex-direction:column;gap:8px">
195 {members.map((m: any) => (
196 <div style="display:flex;align-items:center;gap:8px;padding:8px;border:1px solid var(--border);border-radius:var(--radius)">
197 <div class="user-avatar" style="width:32px;height:32px;font-size:14px">
198 {(m.user.displayName || m.user.username)[0].toUpperCase()}
199 </div>
200 <div style="flex:1">
201 <a href={`/${m.user.username}`} style="font-size:14px;font-weight:500">{m.user.username}</a>
202 </div>
203 <span class="badge" style="font-size:11px">{m.member.role}</span>
204 </div>
205 ))}
206 </div>
207 {isOwner && (
208 <a href={`/orgs/${org.name}/members/invite`} class="btn btn-sm" style="margin-top:12px;width:100%;text-align:center">
209 Invite member
210 </a>
211 )}
212 </div>
213 </div>
214 </div>
215 </Layout>
216 );
217});
218
219// ─── Organization Settings ──────────────────────────────────────────────────
220
221orgRoutes.get("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
222 const orgName = c.req.param("org");
223 const user = c.get("user")!;
224
225 let org: any;
226 try {
227 const [found] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
228 org = found;
229 } catch {
230 return c.notFound();
231 }
232 if (!org) return c.notFound();
233
234 // Check owner
235 let isOwner = false;
236 try {
237 const [member] = await db.select().from(orgMembers)
238 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
239 .limit(1);
240 isOwner = !!member;
241 } catch { /* */ }
242 if (!isOwner) return c.redirect(`/orgs/${orgName}`);
243
244 const success = c.req.query("success");
245
246 return c.html(
247 <Layout title={`Settings — ${org.name}`} user={user}>
248 <div style="max-width:600px">
249 <h2 style="margin-bottom:20px">Organization Settings</h2>
250 {success && <div class="auth-success">Settings updated.</div>}
251 <form method="post" action={`/orgs/${orgName}/settings`}>
252 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
253 <div class="form-group">
254 <label for="displayName">Display name</label>
255 <input type="text" id="displayName" name="displayName" value={org.displayName || ""} />
256 </div>
257 <div class="form-group">
258 <label for="description">Description</label>
259 <textarea id="description" name="description" rows={3}>{org.description || ""}</textarea>
260 </div>
261 <div class="form-group">
262 <label for="website">Website</label>
263 <input type="url" id="website" name="website" value={org.website || ""} />
264 </div>
265 <div class="form-group">
266 <label for="location">Location</label>
267 <input type="text" id="location" name="location" value={org.location || ""} />
268 </div>
269 <button type="submit" class="btn btn-primary">Save changes</button>
270 </form>
271
272 <div style="margin-top:40px;padding-top:24px;border-top:1px solid var(--red)">
273 <h3 style="color:var(--red);margin-bottom:12px">Danger Zone</h3>
274 <form method="post" action={`/orgs/${orgName}/delete`} class="confirm-action" data-confirm="This will permanently delete the organization. Are you sure?">
275 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
276 <button type="submit" class="btn btn-danger">Delete organization</button>
277 </form>
278 </div>
279 </div>
280 </Layout>
281 );
282});
283
284orgRoutes.post("/orgs/:org/settings", softAuth, requireAuth, async (c) => {
285 const orgName = c.req.param("org");
286 const user = c.get("user")!;
287 const body = await c.req.parseBody();
288
289 try {
290 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
291 if (!org) return c.redirect("/");
292
293 const [member] = await db.select().from(orgMembers)
294 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
295 .limit(1);
296 if (!member) return c.redirect(`/orgs/${orgName}`);
297
298 await db.update(organizations).set({
299 displayName: String(body.displayName || "").trim() || org.name,
300 description: String(body.description || "").trim() || null,
301 website: String(body.website || "").trim() || null,
302 location: String(body.location || "").trim() || null,
303 updatedAt: new Date(),
304 }).where(eq(organizations.id, org.id));
305 } catch { /* */ }
306
307 return c.redirect(`/orgs/${orgName}/settings?success=1`);
308});
309
310orgRoutes.post("/orgs/:org/delete", softAuth, requireAuth, async (c) => {
311 const orgName = c.req.param("org");
312 const user = c.get("user")!;
313
314 try {
315 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
316 if (!org) return c.redirect("/");
317
318 const [member] = await db.select().from(orgMembers)
319 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id), eq(orgMembers.role, "owner")))
320 .limit(1);
321 if (!member) return c.redirect(`/orgs/${orgName}`);
322
323 await db.delete(organizations).where(eq(organizations.id, org.id));
324 } catch { /* */ }
325
326 return c.redirect("/");
327});
328
329// ─── Member Invite ──────────────────────────────────────────────────────────
330
331orgRoutes.get("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
332 const orgName = c.req.param("org");
333 const user = c.get("user")!;
334 const error = c.req.query("error");
335 const success = c.req.query("success");
336
337 return c.html(
338 <Layout title={`Invite Member — ${orgName}`} user={user}>
339 <div style="max-width:500px">
340 <h2 style="margin-bottom:16px">Invite a member to {orgName}</h2>
341 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
342 {success && <div class="auth-success">Member invited successfully.</div>}
343 <form method="post" action={`/orgs/${orgName}/members/invite`}>
344 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
345 <div class="form-group">
346 <label for="username">Username</label>
347 <input type="text" id="username" name="username" required placeholder="Enter username" autocomplete="off" />
348 </div>
349 <div class="form-group">
350 <label for="role">Role</label>
351 <select id="role" name="role">
352 <option value="member">Member — can view</option>
353 <option value="admin">Admin — can manage teams</option>
354 <option value="owner">Owner — full control</option>
355 </select>
356 </div>
357 <button type="submit" class="btn btn-primary">Send invitation</button>
358 </form>
359 </div>
360 </Layout>
361 );
362});
363
364orgRoutes.post("/orgs/:org/members/invite", softAuth, requireAuth, async (c) => {
365 const orgName = c.req.param("org");
366 const user = c.get("user")!;
367 const body = await c.req.parseBody();
368 const username = String(body.username || "").trim();
369 const role = String(body.role || "member");
370
371 try {
372 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
373 if (!org) return c.redirect("/");
374
375 // Check inviter is owner or admin
376 const [inviter] = await db.select().from(orgMembers)
377 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
378 .limit(1);
379 if (!inviter || (inviter.role !== "owner" && inviter.role !== "admin")) {
380 return c.redirect(`/orgs/${orgName}/members/invite?error=Permission+denied`);
381 }
382
383 // Find user
384 const [targetUser] = await db.select().from(users).where(eq(users.username, username)).limit(1);
385 if (!targetUser) {
386 return c.redirect(`/orgs/${orgName}/members/invite?error=User+not+found`);
387 }
388
389 // Check if already member
390 const [existing] = await db.select().from(orgMembers)
391 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, targetUser.id)))
392 .limit(1);
393 if (existing) {
394 return c.redirect(`/orgs/${orgName}/members/invite?error=User+is+already+a+member`);
395 }
396
397 await db.insert(orgMembers).values({
398 orgId: org.id,
399 userId: targetUser.id,
400 role: ["owner", "admin", "member"].includes(role) ? role : "member",
401 });
402
403 return c.redirect(`/orgs/${orgName}/members/invite?success=1`);
404 } catch (err: any) {
405 return c.redirect(`/orgs/${orgName}/members/invite?error=${encodeURIComponent(err.message || "Failed")}`);
406 }
407});
408
409// ─── Team Create ────────────────────────────────────────────────────────────
410
411orgRoutes.get("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
412 const orgName = c.req.param("org");
413 const user = c.get("user")!;
414 const error = c.req.query("error");
415
416 return c.html(
417 <Layout title={`New Team — ${orgName}`} user={user}>
418 <div style="max-width:500px">
419 <h2 style="margin-bottom:16px">Create a new team</h2>
420 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
421 <form method="post" action={`/orgs/${orgName}/teams/new`}>
422 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
423 <div class="form-group">
424 <label for="name">Team name</label>
425 <input type="text" id="name" name="name" required placeholder="engineering" autocomplete="off" />
426 </div>
427 <div class="form-group">
428 <label for="description">Description</label>
429 <textarea id="description" name="description" rows={2} placeholder="What does this team do?" />
430 </div>
431 <div class="form-group">
432 <label for="permission">Default permission</label>
433 <select id="permission" name="permission">
434 <option value="read">Read — view repos</option>
435 <option value="write">Write — push to repos</option>
436 <option value="admin">Admin — manage repos</option>
437 </select>
438 </div>
439 <button type="submit" class="btn btn-primary">Create team</button>
440 </form>
441 </div>
442 </Layout>
443 );
444});
445
446orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
447 const orgName = c.req.param("org");
448 const user = c.get("user")!;
449 const body = await c.req.parseBody();
450 const name = String(body.name || "").trim();
451 const description = String(body.description || "").trim();
452 const permission = String(body.permission || "read");
453
454 try {
455 const [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
456 if (!org) return c.redirect("/");
457
458 const [member] = await db.select().from(orgMembers)
459 .where(and(eq(orgMembers.orgId, org.id), eq(orgMembers.userId, user.id)))
460 .limit(1);
461 if (!member || member.role === "member") {
462 return c.redirect(`/orgs/${orgName}/teams/new?error=Permission+denied`);
463 }
464
465 if (!name) {
466 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
467 }
468
469 await db.insert(teams).values({
470 orgId: org.id,
471 name,
472 description: description || null,
473 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
474 });
475
476 return c.redirect(`/orgs/${orgName}`);
477 } catch (err: any) {
478 return c.redirect(`/orgs/${orgName}/teams/new?error=${encodeURIComponent(err.message || "Failed")}`);
479 }
480});
481
482// ─── Team Detail ────────────────────────────────────────────────────────────
483
484orgRoutes.get("/orgs/:org/teams/:team", softAuth, async (c) => {
485 const orgName = c.req.param("org");
486 const teamName = c.req.param("team");
487 const user = c.get("user");
488
489 let org: any, team: any;
490 try {
491 [org] = await db.select().from(organizations).where(eq(organizations.name, orgName)).limit(1);
492 if (!org) return c.notFound();
493 [team] = await db.select().from(teams).where(and(eq(teams.orgId, org.id), eq(teams.name, teamName))).limit(1);
494 if (!team) return c.notFound();
495 } catch {
496 return c.notFound();
497 }
498
499 let members: any[] = [];
500 try {
501 members = await db
502 .select({ member: teamMembers, user: { username: users.username } })
503 .from(teamMembers)
504 .innerJoin(users, eq(teamMembers.userId, users.id))
505 .where(eq(teamMembers.teamId, team.id));
506 } catch { /* */ }
507
508 let repos: any[] = [];
509 try {
510 repos = await db
511 .select({ teamRepo: teamRepos, repo: { name: repositories.name } })
512 .from(teamRepos)
513 .innerJoin(repositories, eq(teamRepos.repositoryId, repositories.id))
514 .where(eq(teamRepos.teamId, team.id));
515 } catch { /* */ }
516
517 return c.html(
518 <Layout title={`${teamName} — ${orgName}`} user={user}>
519 <div style="max-width:800px">
520 <div style="margin-bottom:24px">
521 <div style="font-size:14px;color:var(--text-muted);margin-bottom:4px">
522 <a href={`/orgs/${orgName}`}>{orgName}</a> / teams
523 </div>
524 <h2>{team.name}</h2>
525 {team.description && <p style="color:var(--text-muted);margin-top:4px">{team.description}</p>}
526 <span class="badge" style="margin-top:8px">{team.permission} access</span>
527 </div>
528
529 <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px">
530 <div>
531 <h3 style="margin-bottom:12px">Members ({members.length})</h3>
532 {members.length === 0 ? (
533 <p style="color:var(--text-muted);font-size:14px">No members yet.</p>
534 ) : (
535 <div style="display:flex;flex-direction:column;gap:8px">
536 {members.map((m: any) => (
537 <div style="padding:8px;border:1px solid var(--border);border-radius:var(--radius)">
538 <a href={`/${m.user.username}`}>{m.user.username}</a>
539 </div>
540 ))}
541 </div>
542 )}
543 </div>
544 <div>
545 <h3 style="margin-bottom:12px">Repositories ({repos.length})</h3>
546 {repos.length === 0 ? (
547 <p style="color:var(--text-muted);font-size:14px">No repositories assigned.</p>
548 ) : (
549 <div style="display:flex;flex-direction:column;gap:8px">
550 {repos.map((r: any) => (
551 <div style="padding:8px;border:1px solid var(--border);border-radius:var(--radius)">
552 {r.repo.name}
553 <span class="badge" style="margin-left:8px;font-size:11px">{r.teamRepo.permission}</span>
554 </div>
555 ))}
556 </div>
557 )}
558 </div>
559 </div>
560 </div>
561 </Layout>
562 );
563});
564
565export default orgRoutes;
0566