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

auth.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.

auth.tsxBlame336 lines · 1 contributor
06d5ffeClaude1/**
2 * Auth routes — register, login, logout (web + API).
3 */
4
5import { Hono } from "hono";
6import { setCookie, deleteCookie } from "hono/cookie";
7import { eq } from "drizzle-orm";
8import { db } from "../db";
7437605Claude9import { users, sessions, organizations } from "../db/schema";
06d5ffeClaude10import {
11 hashPassword,
12 verifyPassword,
13 generateSessionToken,
14 sessionCookieOptions,
15 sessionExpiry,
16} from "../lib/auth";
17import { Layout } from "../views/layout";
18import type { AuthEnv } from "../middleware/auth";
19
20const auth = new Hono<AuthEnv>();
21
22// --- Web UI ---
23
24auth.get("/register", (c) => {
25 const error = c.req.query("error");
26 return c.html(
27 <Layout title="Register">
28 <div class="auth-container">
29 <h2>Create account</h2>
30 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
31 <form method="POST" action="/register">
32 <div class="form-group">
33 <label for="username">Username</label>
34 <input
35 type="text"
36 id="username"
37 name="username"
38 required
39 pattern="^[a-zA-Z0-9_-]+$"
40 minLength={2}
41 maxLength={39}
42 placeholder="your-username"
43 autocomplete="username"
44 />
45 </div>
46 <div class="form-group">
47 <label for="email">Email</label>
48 <input
49 type="email"
50 id="email"
51 name="email"
52 required
53 placeholder="you@example.com"
54 autocomplete="email"
55 />
56 </div>
57 <div class="form-group">
58 <label for="password">Password</label>
59 <input
60 type="password"
61 id="password"
62 name="password"
63 required
64 minLength={8}
65 placeholder="Min 8 characters"
66 autocomplete="new-password"
67 />
68 </div>
69 <button type="submit" class="btn btn-primary">
70 Create account
71 </button>
72 </form>
73 <p class="auth-switch">
74 Already have an account? <a href="/login">Sign in</a>
75 </p>
76 </div>
77 </Layout>
78 );
79});
80
81auth.post("/register", async (c) => {
82 const body = await c.req.parseBody();
83 const username = String(body.username || "").trim();
84 const email = String(body.email || "").trim();
85 const password = String(body.password || "");
86
87 if (!username || !email || !password) {
88 return c.redirect("/register?error=All+fields+are+required");
89 }
90
91 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
92 return c.redirect(
93 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
94 );
95 }
96
97 if (password.length < 8) {
98 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
99 }
100
101 // Check existing
102 const [existingUser] = await db
103 .select()
104 .from(users)
105 .where(eq(users.username, username))
106 .limit(1);
107 if (existingUser) {
108 return c.redirect("/register?error=Username+already+taken");
109 }
110
7437605Claude111 // B2: usernames share the URL namespace with org slugs; refuse collisions.
112 const [existingOrg] = await db
113 .select({ id: organizations.id })
114 .from(organizations)
115 .where(eq(organizations.slug, username.toLowerCase()))
116 .limit(1);
117 if (existingOrg) {
118 return c.redirect("/register?error=Username+already+taken");
119 }
120
06d5ffeClaude121 const [existingEmail] = await db
122 .select()
123 .from(users)
124 .where(eq(users.email, email))
125 .limit(1);
126 if (existingEmail) {
127 return c.redirect("/register?error=Email+already+registered");
128 }
129
130 const passwordHash = await hashPassword(password);
131
132 const [user] = await db
133 .insert(users)
134 .values({ username, email, passwordHash })
135 .returning();
136
137 // Create session
138 const token = generateSessionToken();
139 await db.insert(sessions).values({
140 userId: user.id,
141 token,
142 expiresAt: sessionExpiry(),
143 });
144
145 setCookie(c, "session", token, sessionCookieOptions());
146
147 const redirect = c.req.query("redirect") || "/";
148 return c.redirect(redirect);
149});
150
151auth.get("/login", (c) => {
152 const error = c.req.query("error");
153 const redirect = c.req.query("redirect") || "";
154 return c.html(
155 <Layout title="Sign in">
156 <div class="auth-container">
157 <h2>Sign in</h2>
158 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
159 <form
160 method="POST"
161 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
162 >
163 <div class="form-group">
164 <label for="username">Username or email</label>
165 <input
166 type="text"
167 id="username"
168 name="username"
169 required
170 placeholder="username or email"
171 autocomplete="username"
172 />
173 </div>
174 <div class="form-group">
175 <label for="password">Password</label>
176 <input
177 type="password"
178 id="password"
179 name="password"
180 required
181 placeholder="Password"
182 autocomplete="current-password"
183 />
184 </div>
185 <button type="submit" class="btn btn-primary">
186 Sign in
187 </button>
188 </form>
189 <p class="auth-switch">
190 New to gluecron? <a href="/register">Create an account</a>
191 </p>
192 </div>
193 </Layout>
194 );
195});
196
197auth.post("/login", async (c) => {
198 const body = await c.req.parseBody();
199 const identifier = String(body.username || "").trim();
200 const password = String(body.password || "");
201 const redirect = c.req.query("redirect") || "/";
202
203 if (!identifier || !password) {
204 return c.redirect("/login?error=All+fields+are+required");
205 }
206
207 // Find user by username or email
208 const isEmail = identifier.includes("@");
209 const [user] = await db
210 .select()
211 .from(users)
212 .where(
213 isEmail
214 ? eq(users.email, identifier)
215 : eq(users.username, identifier)
216 )
217 .limit(1);
218
219 if (!user) {
220 return c.redirect("/login?error=Invalid+credentials");
221 }
222
223 const valid = await verifyPassword(password, user.passwordHash);
224 if (!valid) {
225 return c.redirect("/login?error=Invalid+credentials");
226 }
227
228 const token = generateSessionToken();
229 await db.insert(sessions).values({
230 userId: user.id,
231 token,
232 expiresAt: sessionExpiry(),
233 });
234
235 setCookie(c, "session", token, sessionCookieOptions());
236 return c.redirect(redirect);
237});
238
239auth.get("/logout", async (c) => {
240 deleteCookie(c, "session", { path: "/" });
241 return c.redirect("/");
242});
243
244// --- API ---
245
246auth.post("/api/auth/register", async (c) => {
247 const body = await c.req.json<{
248 username: string;
249 email: string;
250 password: string;
251 }>();
252
253 if (!body.username || !body.email || !body.password) {
254 return c.json({ error: "username, email, and password are required" }, 400);
255 }
256
257 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
258 return c.json({ error: "Invalid username" }, 400);
259 }
260
261 if (body.password.length < 8) {
262 return c.json({ error: "Password must be at least 8 characters" }, 400);
263 }
264
265 const [existing] = await db
266 .select()
267 .from(users)
268 .where(eq(users.username, body.username))
269 .limit(1);
270 if (existing) {
271 return c.json({ error: "Username already taken" }, 409);
272 }
273
274 const passwordHash = await hashPassword(body.password);
275 const [user] = await db
276 .insert(users)
277 .values({
278 username: body.username,
279 email: body.email,
280 passwordHash,
281 })
282 .returning();
283
284 const token = generateSessionToken();
285 await db.insert(sessions).values({
286 userId: user.id,
287 token,
288 expiresAt: sessionExpiry(),
289 });
290
291 return c.json(
292 {
293 user: { id: user.id, username: user.username, email: user.email },
294 token,
295 },
296 201
297 );
298});
299
300auth.post("/api/auth/login", async (c) => {
301 const body = await c.req.json<{ username: string; password: string }>();
302
303 if (!body.username || !body.password) {
304 return c.json({ error: "username and password are required" }, 400);
305 }
306
307 const isEmail = body.username.includes("@");
308 const [user] = await db
309 .select()
310 .from(users)
311 .where(
312 isEmail
313 ? eq(users.email, body.username)
314 : eq(users.username, body.username)
315 )
316 .limit(1);
317
318 if (!user) return c.json({ error: "Invalid credentials" }, 401);
319
320 const valid = await verifyPassword(body.password, user.passwordHash);
321 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
322
323 const token = generateSessionToken();
324 await db.insert(sessions).values({
325 userId: user.id,
326 token,
327 expiresAt: sessionExpiry(),
328 });
329
330 return c.json({
331 user: { id: user.id, username: user.username, email: user.email },
332 token,
333 });
334});
335
336export default auth;