Password auth, artist folders, timezone fix

Add password-based registration + login alongside existing magic links.
New /register and updated /login with tabs (password default, magic link
as alternative). Bun.password.hash/verify for bcrypt. Auto-login on
register. Landing page CTAs point to /register.

Add projects.artist field for grouping projects by artist in sidebar.
Sidebar shows collapsible artist sections (▸ Anna Berger) with project
counts, "Ohne Zuordnung" for ungrouped projects. Search filters across
artist names. New/edit project forms include artist field.

Fix timezone bug: set postgres connection timezone to UTC so magic link
expiry works correctly in CEST and other non-UTC timezones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Robin Choice
2026-04-12 19:06:06 +02:00
parent 8bf72c2482
commit 09e47d8800
19 changed files with 2335 additions and 76 deletions

View File

@@ -2,13 +2,63 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
import { eq } from 'drizzle-orm';
import { magicLinkSchema, verifyTokenSchema } from '@music-hub/shared';
import { magicLinkSchema, verifyTokenSchema, registerSchema, loginSchema } from '@music-hub/shared';
import { users, magicLinks, sessions } from '@music-hub/db';
import { hashToken } from '../middleware/auth.js';
import { sendMagicLinkEmail } from '../services/email.js';
import type { AppEnv } from '../types.js';
async function createSession(c: any, db: any, userId: string) {
const sessionToken = generateToken();
const tokenHash = await hashToken(sessionToken);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await db.insert(sessions).values({ userId, tokenHash, expiresAt });
setCookie(c, 'session', sessionToken, {
httpOnly: true,
sameSite: 'lax',
path: '/',
maxAge: 30 * 24 * 60 * 60,
});
}
export const authRoutes = new Hono<AppEnv>()
// Register with password
.post('/register', zValidator('json', registerSchema), async (c) => {
const { name, email, password } = c.req.valid('json');
const db = c.get('db');
const [existing] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (existing) return c.json({ error: 'E-Mail bereits vergeben' }, 409);
const passwordHash = await Bun.password.hash(password);
const [user] = await db
.insert(users)
.values({ email, name, passwordHash })
.returning({ id: users.id, email: users.email, name: users.name, avatarUrl: users.avatarUrl });
await createSession(c, db, user.id);
return c.json({ user }, 201);
})
// Login with password
.post('/login', zValidator('json', loginSchema), async (c) => {
const { email, password } = c.req.valid('json');
const db = c.get('db');
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
if (!user || !user.passwordHash) {
return c.json({ error: 'E-Mail oder Passwort falsch' }, 401);
}
const valid = await Bun.password.verify(password, user.passwordHash);
if (!valid) return c.json({ error: 'E-Mail oder Passwort falsch' }, 401);
await createSession(c, db, user.id);
return c.json({
user: { id: user.id, email: user.email, name: user.name, avatarUrl: user.avatarUrl },
});
})
.post('/magic-link', zValidator('json', magicLinkSchema), async (c) => {
const { email } = c.req.valid('json');
const db = c.get('db');
@@ -61,25 +111,7 @@ export const authRoutes = new Hono<AppEnv>()
.returning();
}
// Create session
const sessionToken = generateToken();
const tokenHash = await hashToken(sessionToken);
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await db.insert(sessions).values({
userId: user.id,
tokenHash,
expiresAt,
});
setCookie(c, 'session', sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 30 * 24 * 60 * 60,
});
await createSession(c, db, user.id);
return c.json({ user: { id: user.id, email: user.email, name: user.name } });
})