aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorThomas Vanbesien <tvanbesi@proton.me>2026-03-21 21:49:57 +0100
committerThomas Vanbesien <tvanbesi@proton.me>2026-03-21 21:49:57 +0100
commitf60a390f5c51039fd1efc1df9a6a7f3864ce0062 (patch)
tree9ec829dd92a93a79a2047494d07c95b7c0197389
parentbc54c8c31e7f50a7a365f9b4d22fe8c74a29f61a (diff)
downloadcamagru-f60a390f5c51039fd1efc1df9a6a7f3864ce0062.tar.gz
camagru-f60a390f5c51039fd1efc1df9a6a7f3864ce0062.zip
Add profile page for editing username, email, password, and notifications
-rw-r--r--src/app/Controllers/AuthController.php8
-rw-r--r--src/app/Controllers/ProfileController.php176
-rw-r--r--src/app/Models/User.php26
-rw-r--r--src/app/Views/profile/edit.php59
-rw-r--r--src/config/routes.php7
-rw-r--r--src/public/css/style.css77
6 files changed, 345 insertions, 8 deletions
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 @@
+<?php
+
+declare(strict_types=1);
+// Profile management: lets the logged-in user change username, email, password,
+// and notification preferences.
+
+namespace App\Controllers;
+
+use App\Csrf;
+use App\Flash;
+use App\Mail;
+use App\Models\User;
+
+class ProfileController
+{
+ private User $user;
+
+ public function __construct()
+ {
+ $this->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 @@
+<?php // Profile editing page: separate forms for username, email, password, and notifications.?>
+<div class="profile-page">
+ <h1>Profile settings</h1>
+ <?php include __DIR__ . '/../partials/flash.php'; ?>
+
+ <section class="profile-section">
+ <h2>Username</h2>
+ <form method="POST" action="/profile/username" class="profile-form">
+ <?= \App\Csrf::field() ?>
+ <label for="username">Username</label>
+ <input type="text" id="username" name="username"
+ value="<?= htmlspecialchars($user['username']) ?>"
+ required pattern="[a-zA-Z0-9_]{3,20}">
+ <button type="submit">Update username</button>
+ </form>
+ </section>
+
+ <section class="profile-section">
+ <h2>Email</h2>
+ <p class="hint">Changing your email will require re-verification.</p>
+ <form method="POST" action="/profile/email" class="profile-form">
+ <?= \App\Csrf::field() ?>
+ <label for="email">Email</label>
+ <input type="email" id="email" name="email"
+ value="<?= htmlspecialchars($user['email']) ?>" required>
+ <button type="submit">Update email</button>
+ </form>
+ </section>
+
+ <section class="profile-section">
+ <h2>Password</h2>
+ <form method="POST" action="/profile/password" class="profile-form">
+ <?= \App\Csrf::field() ?>
+ <label for="current_password">Current password</label>
+ <input type="password" id="current_password" name="current_password" required>
+
+ <label for="new_password">New password</label>
+ <input type="password" id="new_password" name="new_password" required minlength="8">
+
+ <label for="new_password_confirm">Confirm new password</label>
+ <input type="password" id="new_password_confirm" name="new_password_confirm" required minlength="8">
+
+ <button type="submit">Update password</button>
+ </form>
+ </section>
+
+ <section class="profile-section">
+ <h2>Notifications</h2>
+ <form method="POST" action="/profile/notifications" class="profile-form">
+ <?= \App\Csrf::field() ?>
+ <label class="checkbox-label">
+ <input type="checkbox" name="notify_comments"
+ <?= $user['notify_comments'] ? 'checked' : '' ?>>
+ Email me when someone comments on my posts
+ </label>
+ <button type="submit">Save preferences</button>
+ </form>
+ </section>
+</div>
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;