diff --git a/src/pages/api/auth/[...nextauth].ts b/src/pages/api/auth/[...nextauth].ts index 0e6ea72..c98f258 100644 --- a/src/pages/api/auth/[...nextauth].ts +++ b/src/pages/api/auth/[...nextauth].ts @@ -1,20 +1,57 @@ // Datei: pages/api/auth/[...nextauth].ts -import NextAuth, { AuthOptions } from "next-auth" -import CredentialsProvider from "next-auth/providers/credentials" -import { PrismaAdapter } from "@next-auth/prisma-adapter" -import prisma from "../../../lib/prisma" // Pfad zu deinem Prisma Client Singleton anpassen -import bcrypt from "bcryptjs" +import NextAuth, {AuthOptions} from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import KeycloakProvider, { KeycloakProfile } from "next-auth/providers/keycloak"; +// Falls 'next-auth/jwt' nicht funktioniert, versuche 'next-auth/core/types' +import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import prisma from "../../../lib/prisma"; +import bcrypt from "bcryptjs"; + +// User-Typ aus deinen globalen NextAuth-Typen (next-auth.d.ts) +// wird für den Rückgabetyp des profile-Callbacks benötigt. +import type { User } from "next-auth"; -// authOptions explizit als AuthOptions typisieren export const authOptions: AuthOptions = { - adapter: PrismaAdapter(prisma), // Der Adapter wird weiterhin für User/Account Management genutzt + adapter: PrismaAdapter(prisma), session: { - strategy: "jwt", // Beibehaltung der JWT-Strategie + strategy: "jwt", }, providers: [ + KeycloakProvider({ + clientId: process.env.KEYCLOAK_CLIENT_ID as string, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET as string, + issuer: process.env.KEYCLOAK_ISSUER as string, + profile(profile: KeycloakProfile): User | Promise { + // profile: Das von Keycloak zurückgegebene Benutzerprofil. + // tokens: Enthält access_token, id_token etc. und entspricht dem TokenSet-Typ. + + let userRole = 'user'; // Standardrolle + // Beispiel für Rollenextraktion (passe dies an deine Keycloak-Konfiguration an): + // Oft sind Rollen in 'realm_access.roles' oder 'resource_access.[client-id].roles' + // im dekodierten Access Token oder ID Token. + // Oder Keycloak Mappers können Rollen direkt ins Profil-Objekt legen. + if (profile.realm_access?.roles && Array.isArray(profile.realm_access.roles) && profile.realm_access.roles.includes('admin')) { + userRole = 'admin'; + } else if (profile.roles && Array.isArray(profile.roles) && profile.roles.includes('admin')) { // Falls 'roles' direkt im Profil ist + userRole = 'admin'; + } + + return { + id: profile.sub, + name: profile.name || profile.preferred_username, + email: profile.email, + image: profile.picture, + role: userRole, + isApproved: true, + // emailVerified wird vom PrismaAdapter erwartet (kann null sein). + // Mappe es, wenn Keycloak 'email_verified: true' sendet. + ...(profile.email_verified && { emailVerified: new Date() }), + }; + }, + }), CredentialsProvider({ - name: "Credentials", + name: "Lokale Konten", credentials: { email: { label: "E-Mail", type: "email", placeholder: "user@example.com" }, password: { label: "Passwort", type: "password" } @@ -23,80 +60,53 @@ export const authOptions: AuthOptions = { if (!credentials?.email || !credentials.password) { throw new Error("E-Mail und Passwort sind erforderlich."); } - const user = await prisma.user.findUnique({ - where: { email: credentials.email.toLowerCase() } // E-Mail normalisieren für den Abgleich + where: { email: credentials.email.toLowerCase() } }); - if (!user || !user.password) { - // Benutzer nicht gefunden oder hat kein Passwort gesetzt (z.B. nur OAuth) throw new Error("Benutzer nicht gefunden oder Passwort nicht gesetzt."); } - - const isPasswordValid = await bcrypt.compare( - credentials.password, - user.password - ); - + const isPasswordValid = await bcrypt.compare(credentials.password, user.password); if (!isPasswordValid) { throw new Error("E-Mail oder Passwort ist falsch."); } - - // NEU: Überprüfung des Freigabestatus if (!user.isApproved) { throw new Error("Dein Konto wurde noch nicht von einem Administrator freigegeben."); } - - // Stelle sicher, dass das zurückgegebene User-Objekt mit deiner - // erweiterten User-Definition in next-auth.d.ts übereinstimmt. return { id: user.id, name: user.name, email: user.email, role: user.role, image: user.image, - isApproved: user.isApproved, // isApproved zum User-Objekt hinzufügen, das an jwt Callback geht + isApproved: user.isApproved, }; } }), - // Füge hier weitere Provider hinzu, z.B. Google: - // GoogleProvider({ - // clientId: process.env.GOOGLE_CLIENT_ID, - // clientSecret: process.env.GOOGLE_CLIENT_SECRET, - // }), ], callbacks: { - async jwt({ token, user }) { - // Das 'user'-Objekt ist hier das, was vom 'authorize'-Callback zurückgegeben wird. - // Es ist nur beim ersten Aufruf nach der Anmeldung (oder Token-Erstellung) vorhanden. - if (user) { + async jwt({ token, user, account }) { + if (account && user) { token.id = user.id; token.role = user.role; - token.isApproved = user.isApproved; // isApproved zum JWT hinzufügen - // Standard-Claims wie name, email, picture werden oft automatisch von NextAuth hinzugefügt, - // wenn sie im User-Objekt vorhanden sind. + token.isApproved = user.isApproved; } return token; }, async session({ session, token }) { - // Das 'token'-Argument enthält die entschlüsselten Daten aus dem JWT. if (token && session.user) { session.user.id = token.id as string; - session.user.role = token.role as string | undefined; // oder string | null, je nach Definition - session.user.isApproved = token.isApproved as boolean | undefined; // isApproved zur Session hinzufügen - // session.user.name = token.name; // Wird oft schon durch DefaultSession abgedeckt - // session.user.email = token.email; // Wird oft schon durch DefaultSession abgedeckt - // session.user.image = token.picture; // Wird oft schon durch DefaultSession abgedeckt (picture ist der JWT Claim für image) + session.user.role = token.role as string | undefined; + session.user.isApproved = token.isApproved as boolean | undefined; } return session; }, }, pages: { signIn: '/auth/signin', - error: '/auth/error', // Deine benutzerdefinierte Fehlerseite + error: '/auth/error', }, - secret: process.env.NEXTAUTH_SECRET, // Sollte in .env.local definiert sein - // debug: process.env.NODE_ENV === 'development', + secret: process.env.NEXTAUTH_SECRET, }; export default NextAuth(authOptions); diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx index 8bc4b29..843611b 100644 --- a/src/pages/auth/signin.tsx +++ b/src/pages/auth/signin.tsx @@ -1,17 +1,17 @@ // Datei: pages/auth/signin.tsx -import type { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next'; // GetServerSidePropsContext hinzugefügt -import { getProviders, signIn, useSession, getCsrfToken, getSession } from 'next-auth/react'; // getSession hinzugefügt +import type { NextPage, GetServerSideProps, GetServerSidePropsContext } from 'next'; +import { getProviders, signIn, useSession, getCsrfToken, getSession } from 'next-auth/react'; import { useRouter } from 'next/router'; import { useEffect, useState } from 'react'; import type { ClientSafeProvider, LiteralUnion } from 'next-auth/react'; -import type { ParsedUrlQuery } from 'querystring'; // Für GetServerSidePropsContext +import type { ParsedUrlQuery } from 'querystring'; import type { PreviewData } from 'next/dist/types'; import {BuiltInProviderType} from "next-auth/providers/index"; -import Link from "next/link"; // Für GetServerSidePropsContext +import Link from "next/link"; interface SignInProps { providers: Record, ClientSafeProvider> | null; - csrfToken: string | undefined; + csrfToken: string | undefined; // csrfToken ist für Credentials-Formular-POSTs relevant, weniger für OAuth/OIDC Klicks } const SignInPage: NextPage = ({ providers}) => { @@ -22,10 +22,10 @@ const SignInPage: NextPage = ({ providers}) => { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); - // Fehler aus der URL-Query extrahieren (von NextAuth gesetzt) useEffect(() => { - if (router.query.error) { - switch (router.query.error) { + if (router.query.error && !error) { // Nur setzen, wenn noch kein Fehler manuell gesetzt wurde + const errorKey = router.query.error as string; + switch (errorKey) { case 'CredentialsSignin': setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine E-Mail und dein Passwort.'); break; @@ -34,24 +34,24 @@ const SignInPage: NextPage = ({ providers}) => { case 'OAuthCreateAccount': case 'EmailCreateAccount': case 'Callback': - setError('Fehler bei der OAuth-Anmeldung. Bitte versuche es erneut.'); + case 'AccountNotLinked': // Häufiger Fehler bei OIDC, wenn E-Mail bereits existiert + setError(`Fehler bei der externen Anmeldung (${errorKey}). Existiert bereits ein Konto mit dieser E-Mail?`); break; case 'EmailSignin': setError('Fehler beim Senden der Anmelde-E-Mail.'); break; default: - setError(`Ein unbekannter Anmeldefehler ist aufgetreten: ${router.query.error}`); + setError(`Ein Anmeldefehler ist aufgetreten: ${errorKey}`); break; } - // Entferne den Fehler aus der URL, um ihn nicht erneut anzuzeigen, wenn der Benutzer interagiert + // Entferne den Fehler aus der URL, um ihn nicht erneut anzuzeigen const newPath = router.pathname; const queryWithoutError = { ...router.query }; delete queryWithoutError.error; router.replace({ pathname: newPath, query: queryWithoutError }, undefined, { shallow: true }); } - }, [router.query.error, router]); + }, [router.query, error, router]); // error zur Dependency-Liste hinzugefügt - // Weiterleitung, wenn bereits authentifiziert useEffect(() => { if (status === 'authenticated') { const callbackUrl = router.query.callbackUrl as string || '/dashboard'; @@ -59,124 +59,63 @@ const SignInPage: NextPage = ({ providers}) => { } }, [status, router]); - const handleSubmit = async (e: React.FormEvent) => { + const handleCredentialsSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); - setError(null); // Reset error before new attempt + setError(null); const result = await signIn('credentials', { - redirect: false, // Wir behandeln die Weiterleitung manuell oder warten auf den useEffect + redirect: false, email: email, password: password, - // callbackUrl: router.query.callbackUrl as string || '/dashboard' // Optional: hier schon setzen }); setIsLoading(false); if (result?.error) { - // Der Fehler wird auch über router.query.error gesetzt, aber wir können ihn hier direkt setzen - switch (result.error) { - case 'CredentialsSignin': - setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine E-Mail und dein Passwort.'); - break; - default: - setError(result.error || 'Ein unbekannter Fehler ist aufgetreten.'); + // Fehlerbehandlung wie im useEffect, aber spezifischer für Credentials + if (result.error === "CredentialsSignin" || result.error.includes("Konto wurde noch nicht") || result.error.includes("E-Mail oder Passwort ist falsch")) { + setError(result.error); // Direkte Fehlermeldung von authorize + } else { + setError('Anmeldung fehlgeschlagen. Bitte überprüfe deine Eingaben.'); } - } else if (result?.ok && !result.error) { - // Erfolgreich, Weiterleitung wird durch useEffect oben oder durch callbackUrl in signIn gehandhabt - // router.push(router.query.callbackUrl as string || '/dashboard'); + } else if (result?.ok) { + // Erfolg, Weiterleitung wird durch useEffect oben gehandhabt } }; if (status === 'loading' || status === 'authenticated') { - // Zeige Ladezustand, während auf Session-Status oder Weiterleitung gewartet wird return ( -
-

Laden...

+
+

Laden...

); } return ( -
+
- {/* Optional: Logo hier einfügen */} - {/* Logo */} -

- Anmelden bei deiner Strichliste +

+ Anmelden

-
+
{error && ( -
+
{error}
)} -
- {/* CSRF Token ist nicht explizit für das manuelle signIn mit Credentials nötig, - aber NextAuth fügt es hinzu, wenn man die Standard-Submit-Action verwendet. - Da wir signIn('credentials', ...) verwenden, ist es implizit gehandhabt. - Wenn du action="/api/auth/callback/credentials" verwenden würdest, wäre es nötig: - - */} -
- -
- setEmail(e.target.value)} - className="appearance-none text-black block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" - placeholder="deine@email.de" - /> -
-
-
- -
- setPassword(e.target.value)} - className="appearance-none text-black block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" - /> -
-
- -
- -
-
- - {/* Buttons für andere Provider (z.B. Google, GitHub), falls konfiguriert */} + {/* Keycloak Provider Button (und andere OAuth/OIDC Provider) */} {providers && Object.values(providers).map((provider) => { - if (provider.id === 'credentials') return null; // Credentials Provider ist das Formular oben + if (provider.id === 'credentials') return null; // Credentials Provider wird unten als Formular behandelt return ( -
+
); })} + + {/* Trennlinie, wenn beide Arten von Providern vorhanden sind */} + {providers && Object.values(providers).some(p => p.id !== 'credentials') && Object.values(providers).some(p => p.id === 'credentials') && ( +
+
+
+
+
+
+ + Oder mit lokalen Konten + +
+
+
+ )} + + {/* Credentials Provider Formular */} + {providers?.credentials && ( +
+
+ +
+ setEmail(e.target.value)} + className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100" + placeholder="deine@email.de" + /> +
+
+ +
+ +
+ setPassword(e.target.value)} + className="appearance-none block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100" + /> +
+
+ +
+ +
+
+ )} +
-

- Noch kein Konto?{' '} - +

+ Noch kein lokales Konto?{' '} + Registrieren

@@ -201,9 +209,7 @@ const SignInPage: NextPage = ({ providers}) => { export const getServerSideProps: GetServerSideProps = async ( context: GetServerSidePropsContext ) => { - // Wenn der Benutzer bereits angemeldet ist, leite ihn direkt zum Dashboard weiter. - // Dies verhindert ein kurzes Aufblitzen der Anmeldeseite. - const session = await getSession(context); // getSession importiert + const session = await getSession(context); if (session) { return { redirect: { @@ -217,10 +223,11 @@ export const getServerSideProps: GetServerSideProps