feat: ST-001–ST-005 foundation — scaffolding, Prisma, schema, seed, env
- ST-001: Next.js 16 + React 19 + TypeScript strict + Tailwind + shadcn/ui + all deps - ST-002: Prisma v7 setup with better-sqlite3 adapter (local) and pg adapter (cloud) - ST-003: Full schema migration (users, pbis, stories, sprints, tasks, todos, api_tokens) - ST-004: Seed with 9 PBIs, ~40 stories, demo user (demo/demo1234), lars user - ST-005: Zod-validated env vars, .env.example, lib/session, lib/auth, lib/api-auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4cf5833c1d
commit
7f94bb6359
32 changed files with 8653 additions and 183 deletions
23
lib/api-auth.ts
Normal file
23
lib/api-auth.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { createHash } from 'crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function authenticateApiRequest(request: Request) {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7)
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { token_hash: tokenHash },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!apiToken || apiToken.revoked_at) {
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
}
|
||||
|
||||
return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
|
||||
}
|
||||
34
lib/auth.ts
Normal file
34
lib/auth.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import bcrypt from 'bcryptjs'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function registerUser(username: string, password: string) {
|
||||
const existing = await prisma.user.findUnique({ where: { username } })
|
||||
if (existing) {
|
||||
return { error: 'Gebruikersnaam is al in gebruik' }
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return { error: 'Wachtwoord moet minimaal 8 tekens bevatten' }
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 12)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
password_hash,
|
||||
roles: { create: [{ role: 'DEVELOPER' }] },
|
||||
},
|
||||
})
|
||||
|
||||
return { user }
|
||||
}
|
||||
|
||||
export async function verifyUser(username: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { username } })
|
||||
if (!user) return null
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash)
|
||||
if (!valid) return null
|
||||
|
||||
return user
|
||||
}
|
||||
18
lib/env.ts
Normal file
18
lib/env.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
const envSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
|
||||
DIRECT_URL: z.string().optional(),
|
||||
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
})
|
||||
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Invalid environment variables:')
|
||||
console.error(parsed.error.flatten().fieldErrors)
|
||||
throw new Error('Invalid environment variables. Check .env.local.')
|
||||
}
|
||||
|
||||
export const env = parsed.data
|
||||
32
lib/prisma.ts
Normal file
32
lib/prisma.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
function createPrismaClient() {
|
||||
const url = process.env.DATABASE_URL
|
||||
if (!url) throw new Error('DATABASE_URL is not set')
|
||||
|
||||
if (url.startsWith('file:')) {
|
||||
// SQLite (local development) — use better-sqlite3 adapter
|
||||
const { PrismaBetterSqlite3 } = require('@prisma/adapter-better-sqlite3')
|
||||
const adapter = new PrismaBetterSqlite3({ url })
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
})
|
||||
}
|
||||
|
||||
// PostgreSQL (production) — use pg adapter
|
||||
const { Pool } = require('pg')
|
||||
const { PrismaPg } = require('@prisma/adapter-pg')
|
||||
const pool = new Pool({ connectionString: url })
|
||||
const adapter = new PrismaPg(pool)
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
|
||||
})
|
||||
}
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
16
lib/session.ts
Normal file
16
lib/session.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { SessionOptions } from 'iron-session'
|
||||
|
||||
export interface SessionData {
|
||||
userId: string
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
export const sessionOptions: SessionOptions = {
|
||||
password: process.env.SESSION_SECRET!,
|
||||
cookieName: 'scrum4me-session',
|
||||
cookieOptions: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
},
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue