diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/Controllers/AuthController.php | 8 | ||||
| -rw-r--r-- | src/app/Controllers/ProfileController.php | 176 | ||||
| -rw-r--r-- | src/app/Models/User.php | 26 | ||||
| -rw-r--r-- | src/app/Views/profile/edit.php | 59 | ||||
| -rw-r--r-- | src/config/routes.php | 7 | ||||
| -rw-r--r-- | src/public/css/style.css | 77 |
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; |
