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.tsxBlame323 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";
9import { users, sessions } from "../db/schema";
10import {
11 hashPassword,
12 verifyPassword,
13 generateSessionToken,
14 sessionCookieOptions,
15 sessionExpiry,
16} from "../lib/auth";
17import { Layout } from "../views/layout";
bb0f894Claude18import {
19 Form,
20 FormGroup,
21 Input,
22 Button,
23 Alert,
24 Text,
25} from "../views/ui";
06d5ffeClaude26import type { AuthEnv } from "../middleware/auth";
27
28const auth = new Hono<AuthEnv>();
29
30// --- Web UI ---
31
32auth.get("/register", (c) => {
33 const error = c.req.query("error");
34 return c.html(
35 <Layout title="Register">
36 <div class="auth-container">
37 <h2>Create account</h2>
bb0f894Claude38 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
39 <Form action="/register">
40 <FormGroup label="Username" htmlFor="username">
41 <Input
06d5ffeClaude42 type="text"
43 name="username"
44 required
45 pattern="^[a-zA-Z0-9_-]+$"
46 minLength={2}
47 maxLength={39}
48 placeholder="your-username"
49 autocomplete="username"
50 />
bb0f894Claude51 </FormGroup>
52 <FormGroup label="Email" htmlFor="email">
53 <Input
06d5ffeClaude54 type="email"
55 name="email"
56 required
57 placeholder="you@example.com"
58 autocomplete="email"
59 />
bb0f894Claude60 </FormGroup>
61 <FormGroup label="Password" htmlFor="password">
62 <Input
06d5ffeClaude63 type="password"
64 name="password"
65 required
66 minLength={8}
67 placeholder="Min 8 characters"
68 autocomplete="new-password"
69 />
bb0f894Claude70 </FormGroup>
71 <Button type="submit" variant="primary">
06d5ffeClaude72 Create account
bb0f894Claude73 </Button>
74 </Form>
06d5ffeClaude75 <p class="auth-switch">
bb0f894Claude76 <Text>Already have an account? <a href="/login">Sign in</a></Text>
06d5ffeClaude77 </p>
78 </div>
79 </Layout>
80 );
81});
82
83auth.post("/register", async (c) => {
84 const body = await c.req.parseBody();
85 const username = String(body.username || "").trim();
86 const email = String(body.email || "").trim();
87 const password = String(body.password || "");
88
89 if (!username || !email || !password) {
90 return c.redirect("/register?error=All+fields+are+required");
91 }
92
93 if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
94 return c.redirect(
95 "/register?error=Username+may+only+contain+letters%2C+numbers%2C+hyphens+and+underscores"
96 );
97 }
98
99 if (password.length < 8) {
100 return c.redirect("/register?error=Password+must+be+at+least+8+characters");
101 }
102
103 // Check existing
104 const [existingUser] = await db
105 .select()
106 .from(users)
107 .where(eq(users.username, username))
108 .limit(1);
109 if (existingUser) {
110 return c.redirect("/register?error=Username+already+taken");
111 }
112
113 const [existingEmail] = await db
114 .select()
115 .from(users)
116 .where(eq(users.email, email))
117 .limit(1);
118 if (existingEmail) {
119 return c.redirect("/register?error=Email+already+registered");
120 }
121
122 const passwordHash = await hashPassword(password);
123
124 const [user] = await db
125 .insert(users)
126 .values({ username, email, passwordHash })
127 .returning();
128
129 // Create session
130 const token = generateSessionToken();
131 await db.insert(sessions).values({
132 userId: user.id,
133 token,
134 expiresAt: sessionExpiry(),
135 });
136
137 setCookie(c, "session", token, sessionCookieOptions());
138
139 const redirect = c.req.query("redirect") || "/";
140 return c.redirect(redirect);
141});
142
143auth.get("/login", (c) => {
144 const error = c.req.query("error");
145 const redirect = c.req.query("redirect") || "";
146 return c.html(
147 <Layout title="Sign in">
148 <div class="auth-container">
149 <h2>Sign in</h2>
bb0f894Claude150 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
151 <Form
06d5ffeClaude152 action={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ""}`}
153 >
bb0f894Claude154 <FormGroup label="Username or email" htmlFor="username">
155 <Input
06d5ffeClaude156 type="text"
157 name="username"
158 required
159 placeholder="username or email"
160 autocomplete="username"
161 />
bb0f894Claude162 </FormGroup>
163 <FormGroup label="Password" htmlFor="password">
164 <Input
06d5ffeClaude165 type="password"
166 name="password"
167 required
168 placeholder="Password"
169 autocomplete="current-password"
170 />
bb0f894Claude171 </FormGroup>
172 <Button type="submit" variant="primary">
06d5ffeClaude173 Sign in
bb0f894Claude174 </Button>
175 </Form>
06d5ffeClaude176 <p class="auth-switch">
bb0f894Claude177 <Text>New to gluecron? <a href="/register">Create an account</a></Text>
06d5ffeClaude178 </p>
179 </div>
180 </Layout>
181 );
182});
183
184auth.post("/login", async (c) => {
185 const body = await c.req.parseBody();
186 const identifier = String(body.username || "").trim();
187 const password = String(body.password || "");
188 const redirect = c.req.query("redirect") || "/";
189
190 if (!identifier || !password) {
191 return c.redirect("/login?error=All+fields+are+required");
192 }
193
194 // Find user by username or email
195 const isEmail = identifier.includes("@");
196 const [user] = await db
197 .select()
198 .from(users)
199 .where(
200 isEmail
201 ? eq(users.email, identifier)
202 : eq(users.username, identifier)
203 )
204 .limit(1);
205
206 if (!user) {
207 return c.redirect("/login?error=Invalid+credentials");
208 }
209
210 const valid = await verifyPassword(password, user.passwordHash);
211 if (!valid) {
212 return c.redirect("/login?error=Invalid+credentials");
213 }
214
215 const token = generateSessionToken();
216 await db.insert(sessions).values({
217 userId: user.id,
218 token,
219 expiresAt: sessionExpiry(),
220 });
221
222 setCookie(c, "session", token, sessionCookieOptions());
223 return c.redirect(redirect);
224});
225
226auth.get("/logout", async (c) => {
227 deleteCookie(c, "session", { path: "/" });
228 return c.redirect("/");
229});
230
231// --- API ---
232
233auth.post("/api/auth/register", async (c) => {
234 const body = await c.req.json<{
235 username: string;
236 email: string;
237 password: string;
238 }>();
239
240 if (!body.username || !body.email || !body.password) {
241 return c.json({ error: "username, email, and password are required" }, 400);
242 }
243
244 if (!/^[a-zA-Z0-9_-]+$/.test(body.username)) {
245 return c.json({ error: "Invalid username" }, 400);
246 }
247
248 if (body.password.length < 8) {
249 return c.json({ error: "Password must be at least 8 characters" }, 400);
250 }
251
252 const [existing] = await db
253 .select()
254 .from(users)
255 .where(eq(users.username, body.username))
256 .limit(1);
257 if (existing) {
258 return c.json({ error: "Username already taken" }, 409);
259 }
260
261 const passwordHash = await hashPassword(body.password);
262 const [user] = await db
263 .insert(users)
264 .values({
265 username: body.username,
266 email: body.email,
267 passwordHash,
268 })
269 .returning();
270
271 const token = generateSessionToken();
272 await db.insert(sessions).values({
273 userId: user.id,
274 token,
275 expiresAt: sessionExpiry(),
276 });
277
278 return c.json(
279 {
280 user: { id: user.id, username: user.username, email: user.email },
281 token,
282 },
283 201
284 );
285});
286
287auth.post("/api/auth/login", async (c) => {
288 const body = await c.req.json<{ username: string; password: string }>();
289
290 if (!body.username || !body.password) {
291 return c.json({ error: "username and password are required" }, 400);
292 }
293
294 const isEmail = body.username.includes("@");
295 const [user] = await db
296 .select()
297 .from(users)
298 .where(
299 isEmail
300 ? eq(users.email, body.username)
301 : eq(users.username, body.username)
302 )
303 .limit(1);
304
305 if (!user) return c.json({ error: "Invalid credentials" }, 401);
306
307 const valid = await verifyPassword(body.password, user.passwordHash);
308 if (!valid) return c.json({ error: "Invalid credentials" }, 401);
309
310 const token = generateSessionToken();
311 await db.insert(sessions).values({
312 userId: user.id,
313 token,
314 expiresAt: sessionExpiry(),
315 });
316
317 return c.json({
318 user: { id: user.id, username: user.username, email: user.email },
319 token,
320 });
321});
322
323export default auth;