From f60a390f5c51039fd1efc1df9a6a7f3864ce0062 Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Sat, 21 Mar 2026 21:49:57 +0100 Subject: Add profile page for editing username, email, password, and notifications --- src/app/Controllers/AuthController.php | 8 -- src/app/Controllers/ProfileController.php | 176 ++++++++++++++++++++++++++++++ src/app/Models/User.php | 26 +++++ src/app/Views/profile/edit.php | 59 ++++++++++ src/config/routes.php | 7 ++ src/public/css/style.css | 77 +++++++++++++ 6 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 src/app/Controllers/ProfileController.php create mode 100644 src/app/Views/profile/edit.php (limited to 'src') diff --git a/src/app/Controllers/AuthController.php b/src/app/Controllers/AuthController.php index ae94295..aad40be 100644 --- a/src/app/Controllers/AuthController.php +++ b/src/app/Controllers/AuthController.php @@ -19,8 +19,6 @@ class AuthController $this->user = new User(); } - // ── Registration ── - public function registerForm(): void { $content = __DIR__ . '/../Views/auth/register.php'; @@ -79,8 +77,6 @@ class AuthController header('Location: /login'); } - // ── Email verification ── - public function verify(): void { $token = $_GET['token'] ?? ''; @@ -97,8 +93,6 @@ class AuthController header('Location: /login'); } - // ── Login / Logout ── - public function loginForm(): void { $content = __DIR__ . '/../Views/auth/login.php'; @@ -146,8 +140,6 @@ class AuthController header('Location: /'); } - // ── Password reset ── - public function forgotPasswordForm(): void { $content = __DIR__ . '/../Views/auth/forgot-password.php'; diff --git a/src/app/Controllers/ProfileController.php b/src/app/Controllers/ProfileController.php new file mode 100644 index 0000000..45a9d28 --- /dev/null +++ b/src/app/Controllers/ProfileController.php @@ -0,0 +1,176 @@ +user = new User(); + } + + public function show(): void + { + if (!isset($_SESSION['user_id'])) { + header('Location: /login'); + return; + } + + $user = $this->user->findById($_SESSION['user_id']); + $content = __DIR__ . '/../Views/profile/edit.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } + + public function updateUsername(): void + { + if (!isset($_SESSION['user_id'])) { + header('Location: /login'); + return; + } + + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /profile'); + return; + } + + $username = trim($_POST['username'] ?? ''); + + if (!preg_match('/^[a-zA-Z0-9_]{3,20}$/', $username)) { + Flash::set('error', 'Username must be 3-20 characters (letters, numbers, underscores).'); + header('Location: /profile'); + return; + } + + $existing = $this->user->findByUsername($username); + if ($existing && $existing['id'] !== $_SESSION['user_id']) { + Flash::set('error', 'Username is already taken.'); + header('Location: /profile'); + return; + } + + $this->user->updateUsername($_SESSION['user_id'], $username); + // Keep the session in sync so the nav bar shows the new name + $_SESSION['username'] = $username; + + Flash::set('success', 'Username updated.'); + header('Location: /profile'); + } + + public function updateEmail(): void + { + if (!isset($_SESSION['user_id'])) { + header('Location: /login'); + return; + } + + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /profile'); + return; + } + + $email = trim($_POST['email'] ?? ''); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + Flash::set('error', 'Invalid email address.'); + header('Location: /profile'); + return; + } + + $existing = $this->user->findByEmail($email); + if ($existing && $existing['id'] !== $_SESSION['user_id']) { + Flash::set('error', 'Email is already registered.'); + header('Location: /profile'); + return; + } + + // Changing email requires re-verification: the user must confirm + // they own the new address before they can log in again + $token = $this->user->updateEmail($_SESSION['user_id'], $email); + Mail::sendVerification($email, $token); + + // Log the user out so they must re-verify + session_destroy(); + // Start a fresh session for the flash message + session_start(); + Flash::set('success', 'Email updated. Check your new email to re-verify your account.'); + header('Location: /login'); + } + + public function updatePassword(): void + { + if (!isset($_SESSION['user_id'])) { + header('Location: /login'); + return; + } + + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /profile'); + return; + } + + $currentPassword = $_POST['current_password'] ?? ''; + $newPassword = $_POST['new_password'] ?? ''; + $confirmPassword = $_POST['new_password_confirm'] ?? ''; + + $user = $this->user->findById($_SESSION['user_id']); + + // Verify the current password so an attacker who steals a session + // can't silently change the password + if (!password_verify($currentPassword, $user['password_hash'])) { + Flash::set('error', 'Current password is incorrect.'); + header('Location: /profile'); + return; + } + + if (\strlen($newPassword) < 8) { + Flash::set('error', 'New password must be at least 8 characters.'); + header('Location: /profile'); + return; + } + + if ($newPassword !== $confirmPassword) { + Flash::set('error', 'New passwords do not match.'); + header('Location: /profile'); + return; + } + + $this->user->updatePassword($user['id'], $newPassword); + Flash::set('success', 'Password updated.'); + header('Location: /profile'); + } + + public function updateNotifications(): void + { + if (!isset($_SESSION['user_id'])) { + header('Location: /login'); + return; + } + + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /profile'); + return; + } + + // Checkbox value: present in POST when checked, absent when unchecked + $notify = isset($_POST['notify_comments']); + $this->user->updateNotifyComments($_SESSION['user_id'], $notify); + + Flash::set('success', 'Notification preferences updated.'); + header('Location: /profile'); + } +} diff --git a/src/app/Models/User.php b/src/app/Models/User.php index d4c5c88..7cfbf3c 100644 --- a/src/app/Models/User.php +++ b/src/app/Models/User.php @@ -115,4 +115,30 @@ class User $row = $stmt->fetch(); return $row ? $row['verification_token'] : null; } + + public function updateUsername(int $id, string $username): void + { + $stmt = $this->pdo->prepare('UPDATE users SET username = :username WHERE id = :id'); + $stmt->execute(['username' => $username, 'id' => $id]); + } + + /** + * Change email and require re-verification. + * Returns the new verification token so the caller can send a verification email. + */ + public function updateEmail(int $id, string $email): string + { + $token = bin2hex(random_bytes(32)); + $stmt = $this->pdo->prepare( + 'UPDATE users SET email = :email, is_verified = FALSE, verification_token = :token WHERE id = :id' + ); + $stmt->execute(['email' => $email, 'token' => $token, 'id' => $id]); + return $token; + } + + public function updateNotifyComments(int $id, bool $notify): void + { + $stmt = $this->pdo->prepare('UPDATE users SET notify_comments = :notify WHERE id = :id'); + $stmt->execute(['notify' => $notify ? 1 : 0, 'id' => $id]); + } } diff --git a/src/app/Views/profile/edit.php b/src/app/Views/profile/edit.php new file mode 100644 index 0000000..45c9be9 --- /dev/null +++ b/src/app/Views/profile/edit.php @@ -0,0 +1,59 @@ + +
+

Profile settings

+ + +
+

Username

+
+ + + + +
+
+ +
+

Email

+

Changing your email will require re-verification.

+
+ + + + +
+
+ +
+

Password

+
+ + + + + + + + + + + +
+
+ +
+

Notifications

+
+ + + +
+
+
diff --git a/src/config/routes.php b/src/config/routes.php index e5f289c..e5b4a9a 100644 --- a/src/config/routes.php +++ b/src/config/routes.php @@ -17,3 +17,10 @@ $router->get('/forgot-password', 'AuthController', 'forgotPasswordForm'); $router->post('/forgot-password', 'AuthController', 'forgotPassword'); $router->get('/reset-password', 'AuthController', 'resetPasswordForm'); $router->post('/reset-password', 'AuthController', 'resetPassword'); + +// Profile +$router->get('/profile', 'ProfileController', 'show'); +$router->post('/profile/username', 'ProfileController', 'updateUsername'); +$router->post('/profile/email', 'ProfileController', 'updateEmail'); +$router->post('/profile/password', 'ProfileController', 'updatePassword'); +$router->post('/profile/notifications', 'ProfileController', 'updateNotifications'); diff --git a/src/public/css/style.css b/src/public/css/style.css index ddba25d..897f54b 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -130,6 +130,83 @@ footer { border: 1px solid #c8e6c9; } +/* Profile page */ +.profile-page { + max-width: 500px; + margin: 0 auto; +} + +.profile-page h1 { + margin-bottom: 1.5rem; +} + +.profile-section { + background: #fff; + border: 1px solid #dbdbdb; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1rem; +} + +.profile-section h2 { + font-size: 1.1rem; + margin-bottom: 0.75rem; +} + +.profile-form { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.profile-form label { + font-weight: 600; + margin-top: 0.25rem; +} + +.profile-form input[type="text"], +.profile-form input[type="email"], +.profile-form input[type="password"] { + padding: 0.5rem; + border: 1px solid #dbdbdb; + border-radius: 4px; + font-size: 1rem; +} + +.profile-form button { + margin-top: 0.5rem; + padding: 0.6rem; + background: #0095f6; + color: #fff; + border: none; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; +} + +.profile-form button:hover { + background: #007ad9; +} + +.hint { + color: #999; + font-size: 0.85rem; + margin-bottom: 0.5rem; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: normal !important; + cursor: pointer; +} + +.checkbox-label input[type="checkbox"] { + width: 1rem; + height: 1rem; +} + @media (max-width: 600px) { header nav { flex-direction: column; -- cgit v1.2.3