diff --git a/docker-compose.yml b/docker-compose.yml index 0ba5b60..5fe37f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: container_name: slaapkampioen restart: unless-stopped environment: + TZ: Europe/Brussels DATABASE_URL: postgresql://sleep:${POSTGRES_PASSWORD}@db:5432/sleep NEXTAUTH_URL: ${NEXTAUTH_URL} NEXTAUTH_SECRET: ${NEXTAUTH_SECRET} diff --git a/package-lock.json b/package-lock.json index a8e1669..38ae491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,14 @@ "dependencies": { "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^5.16.0", + "bcryptjs": "^2.4.3", "next": "^14.2.5", "next-auth": "^4.24.7", "react": "^18.3.0", "react-dom": "^18.3.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^20.14.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", @@ -378,6 +380,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", @@ -494,6 +503,12 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/package.json b/package.json index 5a10ec2..89b5b33 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "next": "^14.2.5", "next-auth": "^4.24.7", "react": "^18.3.0", - "react-dom": "^18.3.0" + "react-dom": "^18.3.0", + "bcryptjs": "^2.4.3" }, "devDependencies": { "@types/node": "^20.14.0", @@ -27,6 +28,7 @@ "postcss": "^8.4.38", "prisma": "^5.16.0", "tailwindcss": "^3.4.4", - "typescript": "^5.4.5" + "typescript": "^5.4.5", + "@types/bcryptjs": "^2.4.6" } -} +} \ No newline at end of file diff --git a/prisma/migrations/20240515000001_add_credentials/migration.sql b/prisma/migrations/20240515000001_add_credentials/migration.sql new file mode 100644 index 0000000..d8c7d29 --- /dev/null +++ b/prisma/migrations/20240515000001_add_credentials/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "username" TEXT; +ALTER TABLE "User" ADD COLUMN "password" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); \ No newline at end of file diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..62426ba --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "@/lib/prisma"; + +export async function POST(req: NextRequest) { + const { username, password } = await req.json(); + + if (!username || !password) { + return NextResponse.json({ error: "Gebruikersnaam en wachtwoord zijn verplicht." }, { status: 400 }); + } + if (username.length < 3) { + return NextResponse.json({ error: "Gebruikersnaam moet minstens 3 tekens zijn." }, { status: 400 }); + } + if (password.length < 6) { + return NextResponse.json({ error: "Wachtwoord moet minstens 6 tekens zijn." }, { status: 400 }); + } + + const existing = await prisma.user.findUnique({ where: { username } }); + if (existing) { + return NextResponse.json({ error: "Deze gebruikersnaam is al in gebruik." }, { status: 409 }); + } + + const hashed = await bcrypt.hash(password, 12); + + await prisma.user.create({ + data: { + username, + name: username, + password: hashed, + }, + }); + + return NextResponse.json({ success: true }); +} \ No newline at end of file diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c86e49f..9a513db 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,27 +1,112 @@ "use client"; -import { signIn } from "next-auth/react"; +import { signIn, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useState, useEffect } from "react"; +import Link from "next/link"; export default function LoginPage() { + const { data: session, status } = useSession(); + const router = useRouter(); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + // Al ingelogd → meteen door naar klassement + useEffect(() => { + if (status === "authenticated") router.replace("/"); + }, [status, router]); + + if (status === "loading" || status === "authenticated") { + return ( +
+
Laden…
+
+ ); + } + + async function handleCredentials() { + setError(""); + setLoading(true); + const res = await signIn("credentials", { + username, + password, + redirect: false, + }); + setLoading(false); + if (res?.error) { + setError("Gebruikersnaam of wachtwoord klopt niet."); + } else { + router.replace("/"); + } + } + return ( -
-
💤
-

SlaapKampioen

-

- Log elke nacht je slaap en zie wie het meeste slaapt in jouw vriendengroep. -

- -

- Alleen toegankelijk voor uitgenodigde vrienden. -

+
+
+ + {/* Logo */} +
+
💤
+

SlaapKampioen

+

Log je slaap, win het klassement.

+
+ + {/* Discord */} + + + {/* Divider */} +
+
+ of +
+
+ + {/* Credentials */} +
+ setUsername(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCredentials()} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + setPassword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleCredentials()} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + {error &&

⚠️ {error}

} + +
+ +

+ Nog geen account?{" "} + + Registreer hier + +

+
); -} +} \ No newline at end of file diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..f560063 --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; + +export default function RegisterPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [password2, setPassword2] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleRegister() { + setError(""); + if (password !== password2) { + setError("Wachtwoorden komen niet overeen."); + return; + } + setLoading(true); + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error ?? "Registratie mislukt."); + setLoading(false); + return; + } + // Direct inloggen na registratie + await signIn("credentials", { username, password, redirect: false }); + router.replace("/"); + } + + return ( +
+
+ +
+
🛌
+

Account aanmaken

+

Kies een gebruikersnaam en wachtwoord.

+
+ +
+ setUsername(e.target.value)} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + setPassword(e.target.value)} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + setPassword2(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + className="w-full bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> + {error &&

⚠️ {error}

} + +
+ +

+ Al een account?{" "} + + Inloggen + +

+
+
+ ); +} \ No newline at end of file diff --git a/src/components/SleepForm.tsx b/src/components/SleepForm.tsx index 5285bf1..b1ce7ed 100644 --- a/src/components/SleepForm.tsx +++ b/src/components/SleepForm.tsx @@ -4,8 +4,8 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { calculateTotalFromTimes, formatDuration } from "@/lib/utils"; -const YESTERDAY = new Date(Date.now() - 86400000).toISOString().slice(0, 10); -const TODAY = new Date().toISOString().slice(0, 10); +const YESTERDAY = new Date(Date.now() - 86400000).toISOString().slice(0, 10); +const DAY_BEFORE = new Date(Date.now() - 172800000).toISOString().slice(0, 10); function fromMinutes(total: number) { return { h: String(Math.floor(total / 60)), m: String(total % 60) }; @@ -149,6 +149,16 @@ export function SleepForm() {

📅 Datum van de nacht

+ -
); -} +} \ No newline at end of file diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 68ccf32..2f611ca 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,33 +1,47 @@ import { NextAuthOptions } from "next-auth"; import DiscordProvider from "next-auth/providers/discord"; +import CredentialsProvider from "next-auth/providers/credentials"; import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import bcrypt from "bcryptjs"; import { prisma } from "./prisma"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), - // JWT strategy zodat de middleware de sessie kan verifieren - // zonder een database call te doen bij elk request - session: { - strategy: "jwt", - }, + session: { strategy: "jwt" }, providers: [ DiscordProvider({ clientId: process.env.DISCORD_CLIENT_ID!, clientSecret: process.env.DISCORD_CLIENT_SECRET!, }), + CredentialsProvider({ + name: "credentials", + credentials: { + username: { label: "Gebruikersnaam", type: "text" }, + password: { label: "Wachtwoord", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.username || !credentials?.password) return null; + + const user = await prisma.user.findUnique({ + where: { username: credentials.username }, + }); + + if (!user?.password) return null; + + const valid = await bcrypt.compare(credentials.password, user.password); + if (!valid) return null; + + return { id: user.id, name: user.name, email: user.email, image: user.image }; + }, + }), ], callbacks: { jwt({ token, user }) { - // user is alleen aanwezig bij eerste login - if (user) { - token.id = user.id; - } + if (user) token.id = user.id; return token; }, session({ session, token }) { - if (session.user) { - session.user.id = token.id as string; - } + if (session.user) session.user.id = token.id as string; return session; }, }, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e77cb0c..fd42380 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,19 +4,15 @@ export function formatDuration(minutes: number): string { return `${h}u ${m.toString().padStart(2, "0")}min`; } -export function minutesToHoursDecimal(minutes: number): number { - return Math.round((minutes / 60) * 10) / 10; -} - export function getSleepQuality(minutes: number): { label: string; color: string; bg: string; } { - if (minutes < 300) return { label: "Heel kort", color: "text-red-400", bg: "bg-red-500/20 text-red-400" }; - if (minutes < 360) return { label: "Kort", color: "text-orange-400", bg: "bg-orange-500/20 text-orange-400" }; - if (minutes < 420) return { label: "Matig", color: "text-yellow-400", bg: "bg-yellow-500/20 text-yellow-400" }; - if (minutes <= 540) return { label: "Goed", color: "text-green-400", bg: "bg-green-500/20 text-green-400" }; + if (minutes < 300) return { label: "Heel kort", color: "text-red-400", bg: "bg-red-500/20 text-red-400" }; + if (minutes < 360) return { label: "Kort", color: "text-orange-400", bg: "bg-orange-500/20 text-orange-400" }; + if (minutes < 420) return { label: "Matig", color: "text-yellow-400", bg: "bg-yellow-500/20 text-yellow-400" }; + if (minutes <= 540) return { label: "Goed", color: "text-green-400", bg: "bg-green-500/20 text-green-400" }; return { label: "Lang", color: "text-blue-400", bg: "bg-blue-500/20 text-blue-400" }; } @@ -26,30 +22,32 @@ export function getPhaseQuality( type: "deep" | "light" | "rem" | "awake" ): { label: string; className: string } { const pct = (phaseMinutes / totalMinutes) * 100; - if (type === "deep") { if (pct >= 13 && pct <= 25) return { label: "Normaal", className: "bg-green-500/20 text-green-400" }; - if (pct > 25) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" }; - return { label: "Kort", className: "bg-red-500/20 text-red-400" }; + if (pct > 25) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" }; + return { label: "Kort", className: "bg-red-500/20 text-red-400" }; } if (type === "light") { if (pct >= 45 && pct <= 65) return { label: "Normaal", className: "bg-green-500/20 text-green-400" }; - if (pct > 65) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" }; - return { label: "Kort", className: "bg-red-500/20 text-red-400" }; + if (pct > 65) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" }; + return { label: "Kort", className: "bg-red-500/20 text-red-400" }; } if (type === "rem") { if (pct >= 15 && pct <= 25) return { label: "Normaal", className: "bg-green-500/20 text-green-400" }; - if (pct > 25) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" }; - return { label: "Kort", className: "bg-red-500/20 text-red-400" }; + if (pct > 25) return { label: "Lang", className: "bg-orange-500/20 text-orange-400" }; + return { label: "Kort", className: "bg-red-500/20 text-red-400" }; } - // awake - if (pct <= 5) return { label: "Normaal", className: "bg-green-500/20 text-green-400" }; - if (pct <= 10) return { label: "Matig", className: "bg-yellow-500/20 text-yellow-400" }; - return { label: "Veel", className: "bg-red-500/20 text-red-400" }; + if (pct <= 5) return { label: "Normaal", className: "bg-green-500/20 text-green-400" }; + if (pct <= 10) return { label: "Matig", className: "bg-yellow-500/20 text-yellow-400" }; + return { label: "Veel", className: "bg-red-500/20 text-red-400" }; } +// Prisma @db.Date geeft UTC middernacht terug → trek datum uit ISO string +// om tijdzone-offset te vermijden export function formatDate(date: string | Date): string { - return new Date(date).toLocaleDateString("nl-BE", { + const iso = typeof date === "string" ? date : date.toISOString(); + const [year, month, day] = iso.slice(0, 10).split("-").map(Number); + return new Date(year, month - 1, day).toLocaleDateString("nl-BE", { weekday: "short", day: "numeric", month: "short", @@ -62,6 +60,6 @@ export function calculateTotalFromTimes(bedtime: string, wakeTime: string): numb const [bh, bm] = bedtime.split(":").map(Number); const [wh, wm] = wakeTime.split(":").map(Number); let total = wh * 60 + wm - (bh * 60 + bm); - if (total <= 0) total += 24 * 60; // crosses midnight + if (total <= 0) total += 24 * 60; // kruist middernacht return total; -} +} \ No newline at end of file