From 09e47d880012585ae92c53bb20fe7187d1fc428e Mon Sep 17 00:00:00 2001 From: Robin Choice Date: Sun, 12 Apr 2026 19:06:06 +0200 Subject: [PATCH] Password auth, artist folders, timezone fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/routes/auth.ts | 72 +- .../lib/components/workspace/Sidebar.svelte | 151 ++- apps/web/src/lib/stores/auth.ts | 12 + .../[projectId]/settings/+page.svelte | 6 +- .../routes/(app)/projects/new/+page.svelte | 3 + apps/web/src/routes/+layout.svelte | 5 +- apps/web/src/routes/+page.svelte | 6 +- apps/web/src/routes/login/+page.svelte | 131 ++- apps/web/src/routes/register/+page.svelte | 140 +++ packages/db/src/index.ts | 4 +- .../db/src/migrations/0004_smart_karma.sql | 1 + .../db/src/migrations/0005_rare_triathlon.sql | 1 + .../db/src/migrations/meta/0004_snapshot.json | 921 +++++++++++++++++ .../db/src/migrations/meta/0005_snapshot.json | 927 ++++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 14 + packages/db/src/schema/projects.ts | 1 + packages/db/src/schema/users.ts | 1 + packages/shared/src/validation/auth.ts | 13 + packages/shared/src/validation/project.ts | 2 + 19 files changed, 2335 insertions(+), 76 deletions(-) create mode 100644 apps/web/src/routes/register/+page.svelte create mode 100644 packages/db/src/migrations/0004_smart_karma.sql create mode 100644 packages/db/src/migrations/0005_rare_triathlon.sql create mode 100644 packages/db/src/migrations/meta/0004_snapshot.json create mode 100644 packages/db/src/migrations/meta/0005_snapshot.json diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index a27e9be..9525229 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -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() + // 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() .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 } }); }) diff --git a/apps/web/src/lib/components/workspace/Sidebar.svelte b/apps/web/src/lib/components/workspace/Sidebar.svelte index 47f5da2..adf9599 100644 --- a/apps/web/src/lib/components/workspace/Sidebar.svelte +++ b/apps/web/src/lib/components/workspace/Sidebar.svelte @@ -8,8 +8,9 @@ import Icon from '$lib/components/ui/Icon.svelte'; import CoverImage from '$lib/components/ui/CoverImage.svelte'; - type Project = { id: string; name: string; coverUrl: string | null }; + type Project = { id: string; name: string; artist: string | null; coverUrl: string | null }; type ProjectMembership = { project: Project; role: string; trackCount: number }; + type ArtistGroup = { artist: string; memberships: ProjectMembership[] }; type TrackStatus = 'sketch' | 'in_progress' | 'final' | 'released'; type Track = { id: string; name: string; coverUrl: string | null; status: TrackStatus }; @@ -37,17 +38,53 @@ const activeProjectId = $derived(($page.params as Record).projectId ?? null); const activeTrackId = $derived(($page.params as Record).trackId ?? null); - // Filtered projects: a project matches if its name matches OR any of its loaded tracks match + // Filtered projects: a project matches if its name matches, artist matches, OR any of its loaded tracks match const filtered = $derived.by(() => { const q = query.trim().toLowerCase(); if (!q) return projects; return projects.filter(({ project }) => { if (project.name.toLowerCase().includes(q)) return true; + if (project.artist?.toLowerCase().includes(q)) return true; const tracks = tracksByProject[project.id]; return tracks?.some((t) => t.name.toLowerCase().includes(q)); }); }); + // Group filtered projects by artist + const artistGroups = $derived.by(() => { + const groups = new Map(); + for (const m of filtered) { + const key = m.project.artist?.trim() || ''; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(m); + } + const sorted: ArtistGroup[] = []; + for (const [artist, memberships] of groups) { + if (artist) sorted.push({ artist, memberships }); + } + sorted.sort((a, b) => a.artist.localeCompare(b.artist)); + const ungrouped = groups.get(''); + if (ungrouped) sorted.push({ artist: '', memberships: ungrouped }); + return sorted; + }); + + // Artist group collapsed state + let collapsedArtists = $state>(new Set()); + function toggleArtist(artist: string) { + const next = new Set(collapsedArtists); + if (next.has(artist)) next.delete(artist); + else next.add(artist); + collapsedArtists = next; + } + function isArtistExpanded(artist: string) { + if (collapsedArtists.has(artist)) return false; + // Auto-expand if active project is in this group or search is active + if (query.trim()) return true; + return artistGroups.some( + (g) => g.artist === artist && g.memberships.some((m) => m.project.id === activeProjectId), + ); + } + function trackMatches(track: Track) { const q = query.trim().toLowerCase(); return !q || track.name.toLowerCase().includes(q); @@ -133,44 +170,65 @@ -
    - {#each filtered as { project, trackCount } (project.id)} -
  • - - - {project.name} - {trackCount} - - {#if shouldExpand(project.id) && tracksByProject[project.id]} -
      - {#each tracksByProject[project.id].filter(trackMatches) as track (track.id)} +
      + {#each artistGroups as group (group.artist)} + {@const expanded = group.artist === '' || isArtistExpanded(group.artist) || !collapsedArtists.has(group.artist)} +
      + {#if group.artist} + + {:else} +
      + Ohne Zuordnung +
      + {/if} + + {#if expanded} + {/if} - +
      {/each} {#if filtered.length === 0 && query} -
    • Nichts gefunden für "{query}"
    • +

      Nichts gefunden für "{query}"

      {:else if projects.length === 0} -
    • Noch keine Projekte
    • +

      Noch keine Projekte

      {/if} -
    +
    @@ -314,6 +372,45 @@ color: var(--color-accent); } + .artist-groups { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + .artist-head { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-1) var(--space-3); + background: none; + border: none; + color: var(--color-text-tertiary); + font-size: var(--text-xs); + font-weight: 600; + font-family: inherit; + cursor: pointer; + text-align: left; + border-radius: var(--radius-sm); + transition: all var(--transition-fast); + } + .artist-head:hover { + color: var(--color-text-secondary); + } + .artist-head.ungrouped { + cursor: default; + padding-top: var(--space-2); + border-top: 1px solid var(--color-border); + margin-top: var(--space-1); + border-radius: 0; + } + .artist-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .projects { list-style: none; padding: 0; diff --git a/apps/web/src/lib/stores/auth.ts b/apps/web/src/lib/stores/auth.ts index d498b3b..7ec1bf0 100644 --- a/apps/web/src/lib/stores/auth.ts +++ b/apps/web/src/lib/stores/auth.ts @@ -22,6 +22,18 @@ export async function checkAuth() { } } +export async function register(name: string, email: string, password: string) { + const res = await api.post<{ user: User }>('/auth/register', { name, email, password }); + user.set(res.user); + return res.user; +} + +export async function login(email: string, password: string) { + const res = await api.post<{ user: User }>('/auth/login', { email, password }); + user.set(res.user); + return res.user; +} + export async function sendMagicLink(email: string) { return api.post('/auth/magic-link', { email }); } diff --git a/apps/web/src/routes/(app)/projects/[projectId]/settings/+page.svelte b/apps/web/src/routes/(app)/projects/[projectId]/settings/+page.svelte index 4a561b6..b8bb6bb 100644 --- a/apps/web/src/routes/(app)/projects/[projectId]/settings/+page.svelte +++ b/apps/web/src/routes/(app)/projects/[projectId]/settings/+page.svelte @@ -19,7 +19,7 @@ user: { id: string; email: string; name: string; avatarUrl: string | null }; }; - type Project = { id: string; name: string; description: string | null; coverUrl: string | null; coverImageUrl: string | null }; + type Project = { id: string; name: string; description: string | null; artist: string | null; coverUrl: string | null; coverImageUrl: string | null }; const projectId = $page.params.projectId!; @@ -30,6 +30,7 @@ // Edit project let editName = $state(''); + let editArtist = $state(''); let editDesc = $state(''); let saving = $state(false); @@ -52,6 +53,7 @@ project = projectRes.project; role = projectRes.role; editName = project.name; + editArtist = project.artist || ''; editDesc = project.description || ''; members = membersRes.members; } finally { @@ -70,6 +72,7 @@ try { await api.patch(`/projects/${projectId}`, { name: editName, + artist: editArtist.trim() || null, description: editDesc || undefined, }); toastSuccess('Projekt gespeichert'); @@ -135,6 +138,7 @@
    { e.preventDefault(); saveProject(); }}> +
    diff --git a/apps/web/src/routes/(app)/projects/new/+page.svelte b/apps/web/src/routes/(app)/projects/new/+page.svelte index 1d6ab04..74032f2 100644 --- a/apps/web/src/routes/(app)/projects/new/+page.svelte +++ b/apps/web/src/routes/(app)/projects/new/+page.svelte @@ -7,6 +7,7 @@ import TopBar from '$lib/components/workspace/TopBar.svelte'; let name = $state(''); + let artist = $state(''); let description = $state(''); let loading = $state(false); @@ -16,6 +17,7 @@ try { const res = await api.post<{ project: { id: string } }>('/projects', { name, + artist: artist.trim() || null, description: description || undefined, }); toastSuccess('Projekt erstellt'); @@ -38,6 +40,7 @@

    Neues Projekt

    +
    diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index ddc8e72..f15f57d 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import { checkAuth, authLoading } from '$lib/stores/auth.js'; import ToastContainer from '$lib/components/ui/ToastContainer.svelte'; + // @ts-ignore — no types shipped for fontsource import '@fontsource-variable/inter'; let { children } = $props(); @@ -11,7 +12,9 @@ const isPublic = $derived( $page.url.pathname === '/' || $page.url.pathname === '/login' || - $page.url.pathname.startsWith('/listen/'), + $page.url.pathname === '/register' || + $page.url.pathname.startsWith('/listen/') || + $page.url.pathname.startsWith('/auth/'), ); onMount(() => { diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index ba8dc26..ec98033 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -24,7 +24,7 @@ {:else} Einloggen - + {/if}
    @@ -43,7 +43,7 @@ ohne Account, ohne Anmeldung, ohne Stress.

    - + Live-Demo ansehen @@ -275,7 +275,7 @@ präg die Roadmap mit. Gratis, ohne Verpflichtung.

    - + Auf Git anschauen diff --git a/apps/web/src/routes/login/+page.svelte b/apps/web/src/routes/login/+page.svelte index a0de6d9..fb11973 100644 --- a/apps/web/src/routes/login/+page.svelte +++ b/apps/web/src/routes/login/+page.svelte @@ -1,52 +1,99 @@