diff --git a/.gitignore b/.gitignore index db40652..e0ae4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules .next electron/dist +posts/admin.json +posts/admin.json.tmp diff --git a/package-lock.json b/package-lock.json index cdb26be..92e6de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tailwindcss/typography": "^0.5.16", "autoprefixer": "^10.4.17", + "bcrypt": "^6.0.0", "chokidar": "^4.0.3", "date-fns": "^3.3.1", "gray-matter": "^4.0.3", @@ -22,6 +23,7 @@ "tailwindcss": "^3.4.1" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/chokidar": "^1.7.5", "@types/node": "^20.11.19", "@types/react": "^18.2.57", @@ -1100,6 +1102,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", @@ -2382,6 +2394,29 @@ ], "license": "MIT" }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bcrypt/node_modules/node-addon-api": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.4.0.tgz", + "integrity": "sha512-D9DI/gXHvVmjHS08SVch0Em8G5S1P+QWtU31appcKT/8wFSPRcdHadIFSAntdMMVM5zz+/DL+bL/gz3UDppqtg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -7378,6 +7413,17 @@ "license": "MIT", "optional": true }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", diff --git a/package.json b/package.json index 25d9be1..eb69b2f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dependencies": { "@tailwindcss/typography": "^0.5.16", "autoprefixer": "^10.4.17", + "bcrypt": "^6.0.0", "chokidar": "^4.0.3", "date-fns": "^3.3.1", "gray-matter": "^4.0.3", @@ -25,6 +26,7 @@ "tailwindcss": "^3.4.1" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/chokidar": "^1.7.5", "@types/node": "^20.11.19", "@types/react": "^18.2.57", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index dded0e4..fa5c369 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; @@ -62,7 +62,14 @@ export default function AdminPage() { return []; }); const [pinFeedback, setPinFeedback] = useState(null); + const [showChangePassword, setShowChangePassword] = useState(false); + const [changePwOld, setChangePwOld] = useState(''); + const [changePwNew, setChangePwNew] = useState(''); + const [changePwConfirm, setChangePwConfirm] = useState(''); + const [changePwFeedback, setChangePwFeedback] = useState(null); const router = useRouter(); + const usernameRef = useRef(null); + const passwordRef = useRef(null); useEffect(() => { // Check if already authenticated @@ -89,14 +96,29 @@ export default function AdminPage() { } }; - const handleLogin = (e: React.FormEvent) => { + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); - if (username === 'admin' && password === 'admin') { + const form = e.target as HTMLFormElement; + const formData = new FormData(form); + const user = (formData.get('username') as string) || usernameRef.current?.value || ''; + const pass = (formData.get('password') as string) || passwordRef.current?.value || ''; + if (user !== 'admin') { + alert('Ungültiger Benutzername'); + return; + } + // Check password via API + const res = await fetch('/api/admin/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: pass, mode: 'login' }), + }); + const data = await res.json(); + if (res.ok && data.success) { setIsAuthenticated(true); localStorage.setItem('adminAuth', 'true'); loadContent(); } else { - alert('Invalid credentials'); + alert(data.error || 'Ungültiges Passwort'); } }; @@ -336,6 +358,47 @@ export default function AdminPage() { }); }; + // Password change handler + const handleChangePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setChangePwFeedback(null); + if (!changePwOld || !changePwNew || !changePwConfirm) { + setChangePwFeedback('Bitte alle Felder ausfüllen.'); + return; + } + if (changePwNew !== changePwConfirm) { + setChangePwFeedback('Die neuen Passwörter stimmen nicht überein.'); + return; + } + // Check old password + const res = await fetch('/api/admin/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: changePwOld, mode: 'login' }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + setChangePwFeedback('Altes Passwort ist falsch.'); + return; + } + // Set new password + const res2 = await fetch('/api/admin/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password: changePwNew }), + }); + const data2 = await res2.json(); + if (res2.ok && data2.success) { + setChangePwFeedback('Passwort erfolgreich geändert!'); + setChangePwOld(''); + setChangePwNew(''); + setChangePwConfirm(''); + setTimeout(() => setShowChangePassword(false), 1500); + } else { + setChangePwFeedback(data2.error || 'Fehler beim Ändern des Passworts.'); + } + }; + return (
{pinFeedback && ( @@ -346,31 +409,37 @@ export default function AdminPage() { {!isAuthenticated ? (

Admin Login

-
+
setUsername(e.target.value)} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" required + autoComplete="username" />
setPassword(e.target.value)} className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" required + autoComplete="current-password" />
+
+ + +
+ {/* Password Change Modal */} + {showChangePassword && ( +
+
+ +

Passwort ändern

+ +
+ + setChangePwOld(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" + required + autoComplete="current-password" + /> +
+
+ + setChangePwNew(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" + required + autoComplete="new-password" + /> +
+
+ + setChangePwConfirm(e.target.value)} + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2" + required + autoComplete="new-password" + /> +
+ {changePwFeedback && ( +
{changePwFeedback}
+ )} + + +
+
+ )} + {/* Breadcrumbs with back button */}
{currentPath.length > 0 && ( diff --git a/src/app/api/admin/password/route.ts b/src/app/api/admin/password/route.ts new file mode 100644 index 0000000..dae235b --- /dev/null +++ b/src/app/api/admin/password/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import bcrypt from 'bcrypt'; + +const adminPath = path.join(process.cwd(), 'posts', 'admin.json'); +const tempPath = path.join(process.cwd(), 'posts', 'admin.json.tmp'); +const BCRYPT_SALT_ROUNDS = 12; // Stronger than minimum recommended + +// Generate a bcrypt hash for 'admin' at runtime if needed +async function getDefaultHash() { + return await bcrypt.hash('admin', BCRYPT_SALT_ROUNDS); +} + +async function getAdminData() { + try { + if (fs.existsSync(adminPath)) { + const data = JSON.parse(fs.readFileSync(adminPath, 'utf8')); + if (typeof data.hash === 'string') return data; + } + } catch (e) { + // Log error and continue to fallback + } + // Fallback: default admin/admin + return { hash: await getDefaultHash() }; +} + +function setAdminDataAtomic(hash: string) { + // Write to a temp file first, then rename + fs.writeFileSync(tempPath, JSON.stringify({ hash }, null, 2), 'utf8'); + fs.renameSync(tempPath, adminPath); +} + +export async function GET() { + // Check if a password is set (if admin.json exists) + const exists = fs.existsSync(adminPath); + return NextResponse.json({ passwordSet: exists }); +} + +export async function POST(request: Request) { + const body = await request.json(); + const { password, mode } = body; + if (!password || typeof password !== 'string') { + return NextResponse.json({ error: 'Kein Passwort angegeben.' }, { status: 400 }); + } + if (Buffer.byteLength(password, 'utf8') > 72) { + return NextResponse.json({ error: 'Passwort zu lang (max. 72 Zeichen).' }, { status: 400 }); + } + const { hash } = await getAdminData(); + if (mode === 'login') { + const match = await bcrypt.compare(password, hash); + if (match) { + return NextResponse.json({ success: true }); + } else { + return NextResponse.json({ error: 'Falsches Passwort.' }, { status: 401 }); + } + } else { + // Set/change password atomically + const newHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS); + try { + setAdminDataAtomic(newHash); + return NextResponse.json({ success: true }); + } catch (e) { + return NextResponse.json({ error: 'Fehler beim Speichern des Passworts.' }, { status: 500 }); + } + } +} \ No newline at end of file