diff --git a/app/login/actions.ts b/app/login/actions.ts
new file mode 100644
index 0000000..80e8850
--- /dev/null
+++ b/app/login/actions.ts
@@ -0,0 +1,33 @@
+'use server';
+
+import { verifyCredentials } from '@/lib/auth';
+import { createSession, deleteSession } from '@/lib/session';
+import { redirect } from 'next/navigation';
+
+export async function login(prevState: any, formData: FormData) {
+ const username = formData.get('username') as string;
+ const password = formData.get('password') as string;
+
+ console.log('Login attempt:', { username, passwordLength: password?.length });
+ console.log('AUTH_USERS env:', process.env.AUTH_USERS);
+
+ if (!username || !password) {
+ return { error: 'Bitte Benutzername und Passwort eingeben' };
+ }
+
+ const isValid = verifyCredentials(username, password);
+
+ console.log('Credentials valid:', isValid);
+
+ if (!isValid) {
+ return { error: 'Ungültige Anmeldedaten' };
+ }
+
+ await createSession(username);
+ redirect('/');
+}
+
+export async function logout() {
+ await deleteSession();
+ redirect('/login');
+}
diff --git a/app/login/page.tsx b/app/login/page.tsx
new file mode 100644
index 0000000..efff5f8
--- /dev/null
+++ b/app/login/page.tsx
@@ -0,0 +1,79 @@
+'use client';
+
+import { useActionState } from 'react';
+import { login } from './actions';
+
+export default function LoginPage() {
+ const [state, loginAction, isPending] = useActionState(login, undefined);
+
+ return (
+
+
+
+
+ Anmeldung
+
+
+ Bitte melden Sie sich an, um fortzufahren
+
+
+
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index dbea858..59b271f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -5,6 +5,7 @@ import WerteForm from '@/components/WerteForm';
import WerteList from '@/components/WerteList';
import { WerteEntry } from '@/types/werte';
import packageJson from '@/package.json';
+import LogoutButton from '@/components/LogoutButton';
export default function Home() {
const [entries, setEntries] = useState([]);
diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx
new file mode 100644
index 0000000..1423a9e
--- /dev/null
+++ b/components/LogoutButton.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { logout } from '@/app/login/actions';
+
+interface LogoutButtonProps {
+ className?: string;
+ children?: React.ReactNode;
+}
+
+export default function LogoutButton({ className, children }: LogoutButtonProps) {
+ const handleLogout = async () => {
+ await logout();
+ };
+
+ return (
+
+ );
+}
diff --git a/lib/auth.ts b/lib/auth.ts
new file mode 100644
index 0000000..7b1fc5b
--- /dev/null
+++ b/lib/auth.ts
@@ -0,0 +1,50 @@
+/**
+ * Reusable authentication library
+ * Configure users via environment variables in .env:
+ * AUTH_USERS=user1:$2a$10$hash1,user2:$2a$10$hash2
+ *
+ * Use scripts/generate-password.js to generate password hashes
+ */
+
+export interface User {
+ username: string;
+ password: string;
+}
+
+/**
+ * Parse users from environment variable
+ * Format: username:password,username2:password2
+ */
+export function getUsers(): User[] {
+ const usersString = process.env.AUTH_USERS || '';
+ if (!usersString) {
+ console.warn('AUTH_USERS not configured in .env');
+ return [];
+ }
+ return usersString
+ .split(',')
+ .map((userPair) => {
+ const [username, password] = userPair.trim().split(':');
+ return { username: username?.trim(), password: password?.trim() };
+ })
+ .filter((user) => user.username && user.password);
+}
+
+/**
+ * Verify user credentials
+ */
+export function verifyCredentials(username: string, password: string): boolean {
+ const users = getUsers();
+ const user = users.find(u => u.username === username);
+ if (!user) {
+ return false;
+ }
+ return user.password === password;
+}
+
+/**
+ * Check if authentication is enabled
+ */
+export function isAuthEnabled(): boolean {
+ return !!process.env.AUTH_USERS;
+}
diff --git a/lib/session.ts b/lib/session.ts
new file mode 100644
index 0000000..d9bd8e0
--- /dev/null
+++ b/lib/session.ts
@@ -0,0 +1,102 @@
+import { cookies } from 'next/headers';
+import { SignJWT, jwtVerify } from 'jose';
+
+const SESSION_COOKIE_NAME = 'auth_session';
+const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
+
+const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
+const key = new TextEncoder().encode(secretKey);
+
+export interface SessionData {
+ username: string;
+ isAuthenticated: boolean;
+ expiresAt: number;
+}
+
+/**
+ * Encrypt session data to JWT
+ */
+async function encrypt(payload: SessionData): Promise {
+ return await new SignJWT(payload as any)
+ .setProtectedHeader({ alg: 'HS256' })
+ .setIssuedAt()
+ .setExpirationTime(new Date(payload.expiresAt))
+ .sign(key);
+}
+
+/**
+ * Decrypt JWT to session data
+ */
+async function decrypt(token: string): Promise {
+ try {
+ const { payload } = await jwtVerify(token, key, {
+ algorithms: ['HS256'],
+ });
+ return {
+ username: payload.username as string,
+ isAuthenticated: payload.isAuthenticated as boolean,
+ expiresAt: payload.expiresAt as number,
+ };
+ } catch (error) {
+ return null;
+ }
+}
+
+/**
+ * Create a new session
+ */
+export async function createSession(username: string): Promise {
+ const expiresAt = Date.now() + SESSION_DURATION;
+ const session: SessionData = {
+ username,
+ isAuthenticated: true,
+ expiresAt,
+ };
+
+ const encryptedSession = await encrypt(session);
+ const cookieStore = await cookies();
+
+ cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === 'production',
+ expires: expiresAt,
+ sameSite: 'lax',
+ path: '/',
+ });
+}
+
+/**
+ * Get current session
+ */
+export async function getSession(): Promise {
+ const cookieStore = await cookies();
+ const cookie = cookieStore.get(SESSION_COOKIE_NAME);
+
+ if (!cookie?.value) {
+ return null;
+ }
+
+ const session = await decrypt(cookie.value);
+
+ if (!session || session.expiresAt < Date.now()) {
+ return null;
+ }
+
+ return session;
+}
+
+/**
+ * Delete session (logout)
+ */
+export async function deleteSession(): Promise {
+ const cookieStore = await cookies();
+ cookieStore.delete(SESSION_COOKIE_NAME);
+}
+
+/**
+ * Verify if user is authenticated
+ */
+export async function isAuthenticated(): Promise {
+ const session = await getSession();
+ return session?.isAuthenticated ?? false;
+}
diff --git a/middleware.ts b/middleware.ts
new file mode 100644
index 0000000..06f2505
--- /dev/null
+++ b/middleware.ts
@@ -0,0 +1,74 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { cookies } from 'next/headers';
+import { jwtVerify } from 'jose';
+
+const SESSION_COOKIE_NAME = 'auth_session';
+
+/**
+ * Middleware to protect routes with authentication
+ * Reusable for other projects - just copy this file
+ */
+export async function middleware(request: NextRequest) {
+ const { pathname } = request.nextUrl;
+
+ // Check if authentication is enabled
+ const authEnabled = !!process.env.AUTH_USERS;
+
+ // If auth is not enabled, allow all requests
+ if (!authEnabled) {
+ return NextResponse.next();
+ }
+
+ // Public paths that don't require authentication
+ const publicPaths = ['/login'];
+ const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
+
+ if (isPublicPath) {
+ return NextResponse.next();
+ }
+
+ // Check for session cookie
+ const cookieStore = await cookies();
+ const sessionCookie = cookieStore.get(SESSION_COOKIE_NAME);
+
+ if (!sessionCookie) {
+ return NextResponse.redirect(new URL('/login', request.url));
+ }
+
+ // Verify session token
+ try {
+ const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
+ const key = new TextEncoder().encode(secretKey);
+
+ const { payload } = await jwtVerify(sessionCookie.value, key, {
+ algorithms: ['HS256'],
+ });
+
+ // Check if session is expired
+ if (payload.expiresAt && (payload.expiresAt as number) < Date.now()) {
+ const response = NextResponse.redirect(new URL('/login', request.url));
+ response.cookies.delete(SESSION_COOKIE_NAME);
+ return response;
+ }
+
+ return NextResponse.next();
+ } catch (error) {
+ // Invalid token - redirect to login
+ const response = NextResponse.redirect(new URL('/login', request.url));
+ response.cookies.delete(SESSION_COOKIE_NAME);
+ return response;
+ }
+}
+
+export const config = {
+ matcher: [
+ /*
+ * Match all request paths except for the ones starting with:
+ * - _next/static (static files)
+ * - _next/image (image optimization files)
+ * - favicon.ico (favicon file)
+ * - public folder
+ */
+ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
+ ],
+};
diff --git a/package-lock.json b/package-lock.json
index 114c2a9..668fde5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,14 @@
{
"name": "werte_next",
- "version": "0.1.1",
+ "version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "werte_next",
- "version": "0.1.1",
+ "version": "1.0.0",
"dependencies": {
+ "jose": "^6.1.3",
"mysql2": "^3.17.4",
"next": "16.1.6",
"react": "19.2.3",
@@ -4484,6 +4485,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "6.1.3",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+ "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
diff --git a/package.json b/package.json
index 85abfe6..795290b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "werte_next",
- "version": "1.0.0",
+ "version": "1.1.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
+ "jose": "^6.1.3",
"mysql2": "^3.17.4",
"next": "16.1.6",
"react": "19.2.3",