Upgraded to 1.1.0
1.1.0 (2025-10-09) - **User Notifications System** - In-app notification center with 7 notification types, filtering, pagination - **Advanced Session Management** - Database-backed sessions with geolocation (country, city, ISP) - **Remote Session Control** - Terminate any device instantly with immediate logout validation - **Enhanced Profile Page** - Sidebar navigation with 4 tabs, hash-based routing (#profile, #security, #sessions) - **MVC Architecture Refactoring** - 3 new Helpers (Layout, Domain, Session), ~265 lines cleaned from views - **Geolocation Tracking** - IP-based location detection using ip-api.com, country flags with flag-icons - **Device Detection** - Browser & device type parsing (Chrome/Firefox/Safari, Desktop/Mobile/Tablet) - **Auto-Detected Cron Paths** - Settings show actual installation paths (thanks @jadeops) - **Welcome Notifications** - Sent to new users on registration or fresh install - **Upgrade Notifications** - Admins notified on system updates with version & migration count - **Web-Based Installer** - Replaces CLI, auto-generates encryption key, one-time password display - **Web-Based Updater** - `/install/update` for running new migrations with smart detection - **User Registration** - Full signup flow with email verification, password reset, resend verification - **User Management** - CRUD for users with filtering, sorting, pagination (admin-only) - **Remember Me** - 30-day secure tokens linked to sessions, cascade deletion on logout - **Session Validator** - Middleware validates sessions on every request for instant remote logout - **Consistent UI/UX** - Unified filtering, sorting, pagination across Domains, Users, Notifications, TLD Registry - **Smart Migrations** - Consolidated schema for fresh installs, incremental for upgrades - **XSS Protection** - htmlspecialchars() applied across all user-facing data (thanks @jadeops)
This commit is contained in:
@@ -4,14 +4,21 @@ namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
use App\Models\User;
|
||||
use App\Models\Setting;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
private User $userModel;
|
||||
private Setting $settingModel;
|
||||
private $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new User();
|
||||
$this->settingModel = new Setting();
|
||||
$this->db = \Core\Database::getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,9 +30,13 @@ class AuthController extends Controller
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
$this->redirect('/');
|
||||
}
|
||||
|
||||
// Check if registration is enabled
|
||||
$registrationEnabled = $this->settingModel->getValue('registration_enabled');
|
||||
|
||||
$this->view('auth/login', [
|
||||
'title' => 'Login'
|
||||
'title' => 'Login',
|
||||
'registrationEnabled' => $registrationEnabled
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -41,6 +52,7 @@ class AuthController extends Controller
|
||||
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$remember = isset($_POST['remember']);
|
||||
|
||||
// Validate input
|
||||
if (empty($username) || empty($password)) {
|
||||
@@ -65,10 +77,29 @@ class AuthController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email verification is required
|
||||
$requireVerification = $this->settingModel->getValue('require_email_verification');
|
||||
if ($requireVerification && !$user['email_verified'] && $user['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Please verify your email address before logging in';
|
||||
$_SESSION['pending_verification_email'] = $user['email'];
|
||||
$this->redirect('/verify-email');
|
||||
return;
|
||||
}
|
||||
|
||||
// Login successful - create session
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $user['username'];
|
||||
$_SESSION['full_name'] = $user['full_name'];
|
||||
$_SESSION['email'] = $user['email'];
|
||||
$_SESSION['role'] = $user['role'];
|
||||
|
||||
// Session is automatically tracked by DatabaseSessionHandler
|
||||
// No need to manually create session record
|
||||
|
||||
// Handle remember me
|
||||
if ($remember) {
|
||||
$this->createRememberToken($user['id']);
|
||||
}
|
||||
|
||||
// Update last login
|
||||
$this->userModel->updateLastLogin($user['id']);
|
||||
@@ -77,12 +108,618 @@ class AuthController extends Controller
|
||||
$this->redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show registration form
|
||||
*/
|
||||
public function showRegister()
|
||||
{
|
||||
// Check if already logged in
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
$this->redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if registration is enabled
|
||||
$registrationEnabled = $this->settingModel->getValue('registration_enabled');
|
||||
if (!$registrationEnabled) {
|
||||
$_SESSION['error'] = 'Registration is currently disabled';
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->view('auth/register', [
|
||||
'title' => 'Register'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process registration
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if registration is enabled
|
||||
$registrationEnabled = $this->settingModel->getValue('registration_enabled');
|
||||
if (!$registrationEnabled) {
|
||||
$_SESSION['error'] = 'Registration is currently disabled';
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$fullName = trim($_POST['full_name'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$passwordConfirm = $_POST['password_confirm'] ?? '';
|
||||
|
||||
// Validate inputs
|
||||
if (empty($username) || empty($email) || empty($fullName) || empty($password)) {
|
||||
$_SESSION['error'] = 'All fields are required';
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['error'] = 'Please enter a valid email address';
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
|
||||
$_SESSION['error'] = 'Username can only contain letters, numbers, and underscores';
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
$_SESSION['error'] = 'Password must be at least 8 characters long';
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($password !== $passwordConfirm) {
|
||||
$_SESSION['error'] = 'Passwords do not match';
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
$existingUser = $this->userModel->findByUsername($username);
|
||||
if ($existingUser) {
|
||||
$_SESSION['error'] = 'Username is already taken';
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
$existingEmail = $this->userModel->where('email', $email);
|
||||
if (!empty($existingEmail)) {
|
||||
$_SESSION['error'] = 'Email address is already registered';
|
||||
$this->redirect('/register');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create user account
|
||||
$userId = $this->userModel->createUser($username, $password, $email, $fullName);
|
||||
|
||||
// Create welcome notification
|
||||
try {
|
||||
$notificationService = new \App\Services\NotificationService();
|
||||
$notificationService->notifyWelcome($userId, $username);
|
||||
} catch (\Exception $e) {
|
||||
// Don't fail registration if notification fails
|
||||
error_log("Failed to create welcome notification: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Check if email verification is required
|
||||
$requireVerification = $this->settingModel->getValue('require_email_verification');
|
||||
|
||||
if ($requireVerification) {
|
||||
// Generate verification token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
|
||||
// Save token to database
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$token, $userId]);
|
||||
|
||||
// Send verification email
|
||||
$this->sendVerificationEmail($email, $fullName, $token);
|
||||
|
||||
$_SESSION['success'] = 'Account created successfully! Please check your email to verify your account.';
|
||||
$_SESSION['pending_verification_email'] = $email;
|
||||
$this->redirect('/verify-email');
|
||||
} else {
|
||||
// Mark as verified and log them in
|
||||
$stmt = $this->db->prepare("UPDATE users SET email_verified = 1 WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
$_SESSION['success'] = 'Account created successfully! You can now log in.';
|
||||
$this->redirect('/login');
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to create account: ' . $e->getMessage();
|
||||
$this->redirect('/register');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show email verification page
|
||||
*/
|
||||
public function showVerifyEmail()
|
||||
{
|
||||
$token = $_GET['token'] ?? null;
|
||||
|
||||
if ($token) {
|
||||
// Verify the token
|
||||
$this->verifyEmail($token);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show pending verification page
|
||||
$email = $_SESSION['pending_verification_email'] ?? 'your email';
|
||||
$this->view('auth/verify-email', [
|
||||
'title' => 'Verify Email',
|
||||
'email' => $email
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email with token
|
||||
*/
|
||||
private function verifyEmail($token)
|
||||
{
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if (!$user) {
|
||||
$this->view('auth/verify-email', [
|
||||
'title' => 'Verification Failed',
|
||||
'error' => true,
|
||||
'errorMessage' => 'Invalid or expired verification link.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if token is expired (24 hours)
|
||||
$sentAt = strtotime($user['email_verification_sent_at']);
|
||||
if (time() - $sentAt > 86400) {
|
||||
$this->view('auth/verify-email', [
|
||||
'title' => 'Verification Failed',
|
||||
'error' => true,
|
||||
'errorMessage' => 'Verification link has expired. Please request a new one.'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark email as verified
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verified = 1, email_verification_token = NULL WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$user['id']]);
|
||||
|
||||
$this->view('auth/verify-email', [
|
||||
'title' => 'Email Verified',
|
||||
'verified' => true
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->view('auth/verify-email', [
|
||||
'title' => 'Verification Failed',
|
||||
'error' => true,
|
||||
'errorMessage' => 'An error occurred during verification.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend verification email
|
||||
*/
|
||||
public function resendVerification()
|
||||
{
|
||||
// Only allow resend if email is in session (from registration or login attempt)
|
||||
$email = $_SESSION['pending_verification_email'] ?? '';
|
||||
|
||||
if (empty($email)) {
|
||||
$_SESSION['error'] = 'Please try logging in first to resend verification email';
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$users = $this->userModel->where('email', $email);
|
||||
|
||||
if (empty($users)) {
|
||||
$_SESSION['error'] = 'Email address not found';
|
||||
$this->redirect('/verify-email');
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $users[0];
|
||||
|
||||
if ($user['email_verified']) {
|
||||
$_SESSION['info'] = 'Email is already verified. You can log in now.';
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate new verification token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$token, $user['id']]);
|
||||
|
||||
// Send verification email
|
||||
$this->sendVerificationEmail($user['email'], $user['full_name'], $token);
|
||||
|
||||
$_SESSION['success'] = 'Verification email sent! Please check your inbox.';
|
||||
$_SESSION['pending_verification_email'] = $email;
|
||||
$this->redirect('/verify-email');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to resend verification email. Please try again.';
|
||||
$this->redirect('/verify-email');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show forgot password form
|
||||
*/
|
||||
public function showForgotPassword()
|
||||
{
|
||||
if (isset($_SESSION['user_id'])) {
|
||||
$this->redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->view('auth/forgot-password', [
|
||||
'title' => 'Forgot Password'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process forgot password request
|
||||
*/
|
||||
public function forgotPassword()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/forgot-password');
|
||||
return;
|
||||
}
|
||||
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
|
||||
if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['error'] = 'Please enter a valid email address';
|
||||
$this->redirect('/forgot-password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$users = $this->userModel->where('email', $email);
|
||||
|
||||
// Always show success message to prevent email enumeration
|
||||
$_SESSION['success'] = 'If an account exists with that email, you will receive password reset instructions.';
|
||||
|
||||
if (!empty($users)) {
|
||||
$user = $users[0];
|
||||
|
||||
// Generate reset token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour'));
|
||||
|
||||
// Save token to database
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$user['id'], $token, $expiresAt]);
|
||||
|
||||
// Send reset email
|
||||
$this->sendPasswordResetEmail($user['email'], $user['full_name'], $token);
|
||||
}
|
||||
|
||||
$this->redirect('/forgot-password');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'An error occurred. Please try again.';
|
||||
$this->redirect('/forgot-password');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show reset password form
|
||||
*/
|
||||
public function showResetPassword()
|
||||
{
|
||||
$token = $_GET['token'] ?? '';
|
||||
|
||||
if (empty($token)) {
|
||||
$_SESSION['error'] = 'Invalid reset link';
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify token exists and is not expired
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$resetToken = $stmt->fetch();
|
||||
|
||||
if (!$resetToken) {
|
||||
$_SESSION['error'] = 'Invalid or expired reset link';
|
||||
$this->redirect('/forgot-password');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->view('auth/reset-password', [
|
||||
'title' => 'Reset Password',
|
||||
'token' => $token
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process password reset
|
||||
*/
|
||||
public function resetPassword()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $_POST['token'] ?? '';
|
||||
$password = $_POST['password'] ?? '';
|
||||
$passwordConfirm = $_POST['password_confirm'] ?? '';
|
||||
|
||||
// Validate inputs
|
||||
if (empty($token) || empty($password) || empty($passwordConfirm)) {
|
||||
$_SESSION['error'] = 'All fields are required';
|
||||
$this->redirect('/reset-password?token=' . urlencode($token));
|
||||
return;
|
||||
}
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
$_SESSION['error'] = 'Password must be at least 8 characters long';
|
||||
$this->redirect('/reset-password?token=' . urlencode($token));
|
||||
return;
|
||||
}
|
||||
|
||||
if ($password !== $passwordConfirm) {
|
||||
$_SESSION['error'] = 'Passwords do not match';
|
||||
$this->redirect('/reset-password?token=' . urlencode($token));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify token
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$resetToken = $stmt->fetch();
|
||||
|
||||
if (!$resetToken) {
|
||||
$_SESSION['error'] = 'Invalid or expired reset link';
|
||||
$this->redirect('/forgot-password');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update password
|
||||
$this->userModel->changePassword($resetToken['user_id'], $password);
|
||||
|
||||
// Mark token as used
|
||||
$stmt = $this->db->prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?");
|
||||
$stmt->execute([$resetToken['id']]);
|
||||
|
||||
$_SESSION['success'] = 'Password reset successfully! You can now log in.';
|
||||
$this->redirect('/login');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to reset password. Please try again.';
|
||||
$this->redirect('/reset-password?token=' . urlencode($token));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create remember me token linked to current session
|
||||
*/
|
||||
private function createRememberToken($userId)
|
||||
{
|
||||
try {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||
$sessionId = session_id();
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO remember_tokens (user_id, session_id, token, expires_at) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$userId, $sessionId, $token, $expiresAt]);
|
||||
|
||||
// Set cookie
|
||||
setcookie('remember_token', $token, [
|
||||
'expires' => strtotime('+30 days'),
|
||||
'path' => '/',
|
||||
'secure' => isset($_SERVER['HTTPS']),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail - remember me is not critical
|
||||
error_log("Failed to create remember token: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and process remember me token
|
||||
*/
|
||||
public function checkRememberToken()
|
||||
{
|
||||
$token = $_COOKIE['remember_token'] ?? null;
|
||||
|
||||
if (!$token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT user_id FROM remember_tokens WHERE token = ? AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$rememberToken = $stmt->fetch();
|
||||
|
||||
if ($rememberToken) {
|
||||
$user = $this->userModel->find($rememberToken['user_id']);
|
||||
|
||||
if ($user && $user['is_active']) {
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['username'] = $user['username'];
|
||||
$_SESSION['full_name'] = $user['full_name'];
|
||||
$_SESSION['email'] = $user['email'];
|
||||
$_SESSION['role'] = $user['role'];
|
||||
|
||||
// Session is automatically tracked by DatabaseSessionHandler
|
||||
// No need to manually create session record
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid token - clear cookie
|
||||
setcookie('remember_token', '', time() - 3600, '/');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification email
|
||||
*/
|
||||
private function sendVerificationEmail($email, $fullName, $token)
|
||||
{
|
||||
try {
|
||||
$emailSettings = $this->settingModel->getEmailSettings();
|
||||
$appSettings = $this->settingModel->getAppSettings();
|
||||
|
||||
$verifyUrl = $appSettings['app_url'] . '/verify-email?token=' . $token;
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $emailSettings['mail_host'];
|
||||
$mail->SMTPAuth = !empty($emailSettings['mail_username']);
|
||||
$mail->Username = $emailSettings['mail_username'];
|
||||
$mail->Password = $emailSettings['mail_password'];
|
||||
$mail->SMTPSecure = $emailSettings['mail_encryption'];
|
||||
$mail->Port = (int)$emailSettings['mail_port'];
|
||||
$mail->CharSet = 'UTF-8';
|
||||
|
||||
$mail->setFrom($emailSettings['mail_from_address'], $emailSettings['mail_from_name']);
|
||||
$mail->addAddress($email, $fullName);
|
||||
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = 'Verify Your Email Address';
|
||||
$mail->Body = "
|
||||
<h2>Welcome to Domain Monitor!</h2>
|
||||
<p>Hello {$fullName},</p>
|
||||
<p>Thank you for registering. Please click the link below to verify your email address:</p>
|
||||
<p><a href='{$verifyUrl}' style='background: #4A90E2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;'>Verify Email Address</a></p>
|
||||
<p>Or copy and paste this URL into your browser:</p>
|
||||
<p>{$verifyUrl}</p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you did not create an account, please ignore this email.</p>
|
||||
";
|
||||
|
||||
$mail->send();
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Log error but don't fail the registration
|
||||
error_log('Failed to send verification email: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
private function sendPasswordResetEmail($email, $fullName, $token)
|
||||
{
|
||||
try {
|
||||
$emailSettings = $this->settingModel->getEmailSettings();
|
||||
$appSettings = $this->settingModel->getAppSettings();
|
||||
|
||||
$resetUrl = $appSettings['app_url'] . '/reset-password?token=' . $token;
|
||||
|
||||
$mail = new PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $emailSettings['mail_host'];
|
||||
$mail->SMTPAuth = !empty($emailSettings['mail_username']);
|
||||
$mail->Username = $emailSettings['mail_username'];
|
||||
$mail->Password = $emailSettings['mail_password'];
|
||||
$mail->SMTPSecure = $emailSettings['mail_encryption'];
|
||||
$mail->Port = (int)$emailSettings['mail_port'];
|
||||
$mail->CharSet = 'UTF-8';
|
||||
|
||||
$mail->setFrom($emailSettings['mail_from_address'], $emailSettings['mail_from_name']);
|
||||
$mail->addAddress($email, $fullName);
|
||||
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = 'Reset Your Password';
|
||||
$mail->Body = "
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>Hello {$fullName},</p>
|
||||
<p>We received a request to reset your password. Click the link below to create a new password:</p>
|
||||
<p><a href='{$resetUrl}' style='background: #4A90E2; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;'>Reset Password</a></p>
|
||||
<p>Or copy and paste this URL into your browser:</p>
|
||||
<p>{$resetUrl}</p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<p>If you did not request a password reset, please ignore this email and your password will remain unchanged.</p>
|
||||
";
|
||||
|
||||
$mail->send();
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Log error
|
||||
error_log('Failed to send password reset email: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
// Destroy session
|
||||
// Clear remember me token if exists
|
||||
if (isset($_COOKIE['remember_token'])) {
|
||||
$token = $_COOKIE['remember_token'];
|
||||
|
||||
try {
|
||||
$stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?");
|
||||
$stmt->execute([$token]);
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
setcookie('remember_token', '', time() - 3600, '/');
|
||||
}
|
||||
|
||||
// Destroy session (DatabaseSessionHandler automatically deletes from DB)
|
||||
session_destroy();
|
||||
session_start();
|
||||
|
||||
@@ -90,4 +727,3 @@ class AuthController extends Controller
|
||||
$this->redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,15 @@ class DashboardController extends Controller
|
||||
|
||||
// Check system status
|
||||
$systemStatus = $this->checkSystemStatus();
|
||||
|
||||
// Format domains for display
|
||||
$formattedRecentDomains = \App\Helpers\DomainHelper::formatMultiple($recentDomains);
|
||||
$formattedExpiringDomains = \App\Helpers\DomainHelper::formatMultiple($expiringThisMonth);
|
||||
|
||||
$this->view('dashboard/index', [
|
||||
'stats' => $stats,
|
||||
'recentDomains' => $recentDomains,
|
||||
'expiringThisMonth' => $expiringThisMonth,
|
||||
'recentDomains' => $formattedRecentDomains,
|
||||
'expiringThisMonth' => $formattedExpiringDomains,
|
||||
'expiringCount' => count($allExpiringDomains),
|
||||
'recentLogs' => $recentLogs,
|
||||
'groups' => $groups,
|
||||
|
||||
@@ -88,9 +88,12 @@ class DomainController extends Controller
|
||||
$paginatedDomains = array_slice($domains, $offset, $perPage);
|
||||
|
||||
$groups = $this->groupModel->all();
|
||||
|
||||
// Format domains for display
|
||||
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($paginatedDomains);
|
||||
|
||||
$this->view('domains/index', [
|
||||
'domains' => $paginatedDomains,
|
||||
'domains' => $formattedDomains,
|
||||
'groups' => $groups,
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
@@ -353,9 +356,25 @@ class DomainController extends Controller
|
||||
|
||||
$logModel = new \App\Models\NotificationLog();
|
||||
$logs = $logModel->getByDomain($id, 20);
|
||||
|
||||
// Format domain for display
|
||||
$formattedDomain = \App\Helpers\DomainHelper::formatForDisplay($domain);
|
||||
|
||||
// Parse WHOIS data for display
|
||||
$whoisData = json_decode($domain['whois_data'] ?? '{}', true);
|
||||
if (!empty($whoisData['status']) && is_array($whoisData['status'])) {
|
||||
$formattedDomain['parsedStatuses'] = \App\Helpers\DomainHelper::parseWhoisStatuses($whoisData['status']);
|
||||
} else {
|
||||
$formattedDomain['parsedStatuses'] = [];
|
||||
}
|
||||
|
||||
// Calculate active channel count
|
||||
if (!empty($domain['channels'])) {
|
||||
$formattedDomain['activeChannelCount'] = \App\Helpers\DomainHelper::getActiveChannelCount($domain['channels']);
|
||||
}
|
||||
|
||||
$this->view('domains/view', [
|
||||
'domain' => $domain,
|
||||
'domain' => $formattedDomain,
|
||||
'logs' => $logs,
|
||||
'title' => $domain['domain_name']
|
||||
]);
|
||||
|
||||
423
app/Controllers/InstallerController.php
Normal file
423
app/Controllers/InstallerController.php
Normal file
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
|
||||
class InstallerController extends Controller
|
||||
{
|
||||
private $db = null;
|
||||
|
||||
/**
|
||||
* Check if system is already installed
|
||||
*/
|
||||
private function isInstalled(): bool
|
||||
{
|
||||
try {
|
||||
$pdo = \Core\Database::getConnection();
|
||||
$stmt = $pdo->query("SELECT COUNT(*) FROM users");
|
||||
return $stmt->fetchColumn() > 0;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check pending migrations
|
||||
*/
|
||||
private function getPendingMigrations(): array
|
||||
{
|
||||
// For fresh installs - use consolidated schema
|
||||
$freshInstallMigration = ['000_initial_schema_v1.1.0.sql'];
|
||||
|
||||
// For incremental updates from v1.0.0
|
||||
$incrementalMigrations = [
|
||||
'001_create_tables.sql',
|
||||
'002_create_users_table.sql',
|
||||
'003_add_whois_fields.sql',
|
||||
'004_create_tld_registry_table.sql',
|
||||
'005_update_tld_import_logs.sql',
|
||||
'006_add_complete_workflow_import_type.sql',
|
||||
'007_add_app_and_email_settings.sql',
|
||||
'008_add_notes_to_domains.sql',
|
||||
'009_add_authentication_features.sql',
|
||||
'010_add_app_version_setting.sql',
|
||||
'011_create_sessions_table.sql',
|
||||
'012_link_remember_tokens_to_sessions.sql',
|
||||
'013_create_user_notifications_table.sql',
|
||||
];
|
||||
|
||||
try {
|
||||
$pdo = \Core\Database::getConnection();
|
||||
|
||||
// Check if this is a v1.0.0 install (has tables but no migrations tracking)
|
||||
$hasUsers = false;
|
||||
$hasDomains = false;
|
||||
|
||||
try {
|
||||
$stmt = $pdo->query("SELECT COUNT(*) FROM users");
|
||||
$hasUsers = $stmt->fetchColumn() > 0;
|
||||
|
||||
$stmt = $pdo->query("SELECT COUNT(*) FROM domains");
|
||||
$hasDomains = true; // Table exists
|
||||
} catch (\Exception $e) {
|
||||
// Tables don't exist - fresh install
|
||||
}
|
||||
|
||||
// Create migrations table if it doesn't exist
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
migration VARCHAR(255) NOT NULL UNIQUE,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_migration (migration)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
");
|
||||
|
||||
// Get executed migrations
|
||||
$stmt = $pdo->query("SELECT migration FROM migrations");
|
||||
$executed = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||
|
||||
// If no migrations executed but has data - v1.0.0 upgrade
|
||||
if (empty($executed) && ($hasUsers || $hasDomains)) {
|
||||
// Mark 001-008 as executed (v1.0.0 migrations)
|
||||
$v1Migrations = [
|
||||
'001_create_tables.sql',
|
||||
'002_create_users_table.sql',
|
||||
'003_add_whois_fields.sql',
|
||||
'004_create_tld_registry_table.sql',
|
||||
'005_update_tld_import_logs.sql',
|
||||
'006_add_complete_workflow_import_type.sql',
|
||||
'007_add_app_and_email_settings.sql',
|
||||
'008_add_notes_to_domains.sql'
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare("INSERT IGNORE INTO migrations (migration) VALUES (?)");
|
||||
foreach ($v1Migrations as $migration) {
|
||||
$stmt->execute([$migration]);
|
||||
}
|
||||
|
||||
// Return only new migrations for v1.1.0
|
||||
return [
|
||||
'009_add_authentication_features.sql',
|
||||
'010_add_app_version_setting.sql',
|
||||
'011_create_sessions_table.sql',
|
||||
'012_link_remember_tokens_to_sessions.sql',
|
||||
'013_create_user_notifications_table.sql'
|
||||
];
|
||||
}
|
||||
|
||||
// If no migrations executed and no data - fresh install (use consolidated)
|
||||
if (empty($executed)) {
|
||||
return $freshInstallMigration;
|
||||
}
|
||||
|
||||
// If has executed migrations - check for pending incremental ones
|
||||
return array_diff($incrementalMigrations, $executed);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// If critical error - assume fresh install
|
||||
return $freshInstallMigration;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show installer welcome page
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
if ($this->isInstalled()) {
|
||||
$pending = $this->getPendingMigrations();
|
||||
if (empty($pending)) {
|
||||
$_SESSION['info'] = 'System is already installed and up to date';
|
||||
$this->redirect('/');
|
||||
return;
|
||||
}
|
||||
// Has pending migrations - show updater
|
||||
$this->redirect('/install/update');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->view('installer/welcome', [
|
||||
'title' => 'Install Domain Monitor'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check database connection
|
||||
*/
|
||||
public function checkDatabase()
|
||||
{
|
||||
try {
|
||||
$pdo = \Core\Database::getConnection();
|
||||
$pdo->query("SELECT 1");
|
||||
|
||||
$this->view('installer/database-check', [
|
||||
'title' => 'Database Connection',
|
||||
'success' => true
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
$this->view('installer/database-check', [
|
||||
'title' => 'Database Connection',
|
||||
'success' => false,
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run installation
|
||||
*/
|
||||
public function install()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/install');
|
||||
return;
|
||||
}
|
||||
|
||||
$adminPassword = trim($_POST['admin_password'] ?? '');
|
||||
$adminEmail = trim($_POST['admin_email'] ?? '');
|
||||
|
||||
// Validate
|
||||
if (empty($adminPassword) || strlen($adminPassword) < 8) {
|
||||
$_SESSION['error'] = 'Admin password must be at least 8 characters';
|
||||
$this->redirect('/install');
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($adminEmail) || !filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['error'] = 'Please enter a valid admin email';
|
||||
$this->redirect('/install');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = \Core\Database::getConnection();
|
||||
|
||||
// Run all migrations
|
||||
$migrations = $this->getPendingMigrations();
|
||||
$results = [];
|
||||
|
||||
foreach ($migrations as $migration) {
|
||||
$file = __DIR__ . '/../../database/migrations/' . $migration;
|
||||
if (!file_exists($file)) continue;
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
|
||||
// Replace password placeholder for user migration
|
||||
if ($migration === '002_create_users_table.sql') {
|
||||
$passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT);
|
||||
$sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $passwordHash, $sql);
|
||||
}
|
||||
|
||||
// Execute SQL
|
||||
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||
foreach ($statements as $statement) {
|
||||
if (!empty($statement)) {
|
||||
try {
|
||||
$pdo->exec($statement);
|
||||
} catch (\PDOException $e) {
|
||||
// Ignore duplicate/already exists errors
|
||||
if (strpos($e->getMessage(), 'Duplicate') === false &&
|
||||
strpos($e->getMessage(), 'already exists') === false) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as executed
|
||||
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||
$stmt->execute([$migration]);
|
||||
|
||||
$results[] = $migration;
|
||||
}
|
||||
|
||||
// Update admin email and ensure admin role and verified status
|
||||
$stmt = $pdo->prepare("UPDATE users SET email = ?, role = 'admin', email_verified = 1 WHERE username = 'admin'");
|
||||
$stmt->execute([$adminEmail]);
|
||||
|
||||
// Generate encryption key if not exists
|
||||
if (empty($_ENV['APP_ENCRYPTION_KEY'])) {
|
||||
$this->generateEncryptionKey();
|
||||
}
|
||||
|
||||
// Create .installed flag file
|
||||
$installedFile = __DIR__ . '/../../.installed';
|
||||
file_put_contents($installedFile, date('Y-m-d H:i:s'));
|
||||
|
||||
// Create welcome notification for admin
|
||||
try {
|
||||
// Get the admin user ID
|
||||
$stmt = $pdo->query("SELECT id FROM users WHERE username = 'admin' LIMIT 1");
|
||||
$adminUser = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if ($adminUser) {
|
||||
$notificationService = new \App\Services\NotificationService();
|
||||
$notificationService->notifyWelcome($adminUser['id'], 'admin');
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Don't fail install if notification fails
|
||||
error_log("Failed to create welcome notification: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Redirect to complete page
|
||||
$_SESSION['install_complete'] = true;
|
||||
$_SESSION['admin_password'] = $adminPassword;
|
||||
$this->redirect('/install/complete');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Installation failed: ' . $e->getMessage();
|
||||
$this->redirect('/install');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show update page
|
||||
*/
|
||||
public function showUpdate()
|
||||
{
|
||||
$pending = $this->getPendingMigrations();
|
||||
|
||||
if (empty($pending)) {
|
||||
$_SESSION['info'] = 'No updates available';
|
||||
$this->redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->view('installer/update', [
|
||||
'title' => 'System Update',
|
||||
'migrations' => $pending
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run update
|
||||
*/
|
||||
public function runUpdate()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/install/update');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = \Core\Database::getConnection();
|
||||
$migrations = $this->getPendingMigrations();
|
||||
$executed = [];
|
||||
|
||||
foreach ($migrations as $migration) {
|
||||
$file = __DIR__ . '/../../database/migrations/' . $migration;
|
||||
if (!file_exists($file)) continue;
|
||||
|
||||
$sql = file_get_contents($file);
|
||||
|
||||
// Execute SQL
|
||||
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||
foreach ($statements as $statement) {
|
||||
if (!empty($statement)) {
|
||||
try {
|
||||
$pdo->exec($statement);
|
||||
} catch (\PDOException $e) {
|
||||
// Ignore duplicate/already exists errors
|
||||
if (strpos($e->getMessage(), 'Duplicate') === false &&
|
||||
strpos($e->getMessage(), 'already exists') === false) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as executed
|
||||
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||
$stmt->execute([$migration]);
|
||||
|
||||
$executed[] = $migration;
|
||||
}
|
||||
|
||||
// Create .installed flag file if doesn't exist (for v1.0.0 upgrades)
|
||||
$installedFile = __DIR__ . '/../../.installed';
|
||||
if (!file_exists($installedFile)) {
|
||||
file_put_contents($installedFile, date('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
// Notify admins about upgrade (if migrations were executed)
|
||||
if (!empty($executed)) {
|
||||
try {
|
||||
$settingModel = new \App\Models\Setting();
|
||||
$currentVersion = $settingModel->getAppVersion();
|
||||
|
||||
// Determine from/to versions based on migrations
|
||||
$fromVersion = '1.0.0';
|
||||
$toVersion = '1.1.0';
|
||||
|
||||
// Detect version based on which migrations were run
|
||||
if (in_array('011_create_sessions_table.sql', $executed) ||
|
||||
in_array('012_link_remember_tokens_to_sessions.sql', $executed) ||
|
||||
in_array('013_create_user_notifications_table.sql', $executed)) {
|
||||
$toVersion = '1.1.0';
|
||||
}
|
||||
|
||||
$notificationService = new \App\Services\NotificationService();
|
||||
$notificationService->notifyAdminsUpgrade($fromVersion, $toVersion, count($executed));
|
||||
} catch (\Exception $e) {
|
||||
// Don't fail upgrade if notification fails
|
||||
error_log("Failed to create upgrade notification: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['success'] = count($executed) . ' migration(s) executed successfully';
|
||||
$this->redirect('/');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Update failed: ' . $e->getMessage();
|
||||
$this->redirect('/install/update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show installation complete page
|
||||
*/
|
||||
public function complete()
|
||||
{
|
||||
if (!isset($_SESSION['install_complete'])) {
|
||||
$this->redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
$adminPassword = $_SESSION['admin_password'] ?? null;
|
||||
unset($_SESSION['admin_password']);
|
||||
unset($_SESSION['install_complete']);
|
||||
|
||||
$this->view('installer/complete', [
|
||||
'title' => 'Installation Complete',
|
||||
'adminPassword' => $adminPassword
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate encryption key
|
||||
*/
|
||||
private function generateEncryptionKey()
|
||||
{
|
||||
$encryptionKey = base64_encode(random_bytes(32));
|
||||
$envFile = __DIR__ . '/../../.env';
|
||||
|
||||
if (file_exists($envFile)) {
|
||||
$envContent = file_get_contents($envFile);
|
||||
|
||||
if (strpos($envContent, 'APP_ENCRYPTION_KEY=') !== false) {
|
||||
$envContent = preg_replace(
|
||||
'/APP_ENCRYPTION_KEY=.*$/m',
|
||||
"APP_ENCRYPTION_KEY=$encryptionKey",
|
||||
$envContent
|
||||
);
|
||||
} else {
|
||||
$envContent .= "\nAPP_ENCRYPTION_KEY=$encryptionKey\n";
|
||||
}
|
||||
|
||||
file_put_contents($envFile, $envContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
227
app/Controllers/NotificationController.php
Normal file
227
app/Controllers/NotificationController.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
use Core\Auth;
|
||||
use App\Models\Notification;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
private Notification $notificationModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->notificationModel = new Notification();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show all notifications page
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
// Get filter parameters
|
||||
$statusFilter = $_GET['status'] ?? '';
|
||||
$typeFilter = $_GET['type'] ?? '';
|
||||
$dateRange = $_GET['date_range'] ?? '';
|
||||
$perPage = (int)($_GET['per_page'] ?? 10);
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
|
||||
// Build filters array
|
||||
$filters = [
|
||||
'status' => $statusFilter,
|
||||
'type' => $typeFilter,
|
||||
'date_range' => $dateRange
|
||||
];
|
||||
|
||||
// Count total records
|
||||
$totalRecords = $this->notificationModel->countForUser($userId, $filters);
|
||||
|
||||
// Calculate pagination
|
||||
$totalPages = ceil($totalRecords / $perPage);
|
||||
$page = min($page, max(1, $totalPages));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$showingFrom = $totalRecords > 0 ? $offset + 1 : 0;
|
||||
$showingTo = min($offset + $perPage, $totalRecords);
|
||||
|
||||
// Get notifications
|
||||
$notifications = $this->notificationModel->getForUser($userId, $filters, $perPage, $offset);
|
||||
|
||||
// Get unread count
|
||||
$unreadCount = $this->notificationModel->getUnreadCount($userId);
|
||||
|
||||
// Format notifications for display
|
||||
foreach ($notifications as &$notification) {
|
||||
$notification['time_ago'] = $this->timeAgo($notification['created_at']);
|
||||
$notification['icon'] = $this->getNotificationIcon($notification['type']);
|
||||
$notification['color'] = $this->getNotificationColor($notification['type']);
|
||||
}
|
||||
|
||||
$this->view('notifications/index', [
|
||||
'title' => 'Notifications',
|
||||
'notifications' => $notifications,
|
||||
'unreadCount' => $unreadCount,
|
||||
'filters' => $filters,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'total_pages' => $totalPages,
|
||||
'per_page' => $perPage,
|
||||
'total' => $totalRecords,
|
||||
'showing_from' => $showingFrom,
|
||||
'showing_to' => $showingTo
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notification as read
|
||||
*/
|
||||
public function markAsRead($params = [])
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$notificationId = (int)($params['id'] ?? 0);
|
||||
|
||||
if ($notificationId <= 0) {
|
||||
$_SESSION['error'] = 'Invalid notification';
|
||||
$this->redirect('/notifications');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notificationModel->markAsRead($notificationId, $userId);
|
||||
$_SESSION['success'] = 'Notification marked as read';
|
||||
$this->redirect('/notifications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all notifications as read
|
||||
*/
|
||||
public function markAllAsRead()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$this->notificationModel->markAllAsRead($userId);
|
||||
$_SESSION['success'] = 'All notifications marked as read';
|
||||
$this->redirect('/notifications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete notification
|
||||
*/
|
||||
public function delete($params = [])
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$notificationId = (int)($params['id'] ?? 0);
|
||||
|
||||
if ($notificationId <= 0) {
|
||||
$_SESSION['error'] = 'Invalid notification';
|
||||
$this->redirect('/notifications');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notificationModel->deleteNotification($notificationId, $userId);
|
||||
$_SESSION['success'] = 'Notification deleted';
|
||||
$this->redirect('/notifications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all notifications
|
||||
*/
|
||||
public function clearAll()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$this->notificationModel->clearAll($userId);
|
||||
$_SESSION['success'] = 'All notifications cleared';
|
||||
$this->redirect('/notifications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count (for bell icon badge - AJAX)
|
||||
*/
|
||||
public function getUnreadCount()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$count = $this->notificationModel->getUnreadCount($userId);
|
||||
$this->json(['count' => $count]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent notifications for dropdown (AJAX)
|
||||
*/
|
||||
public function getRecent()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$notifications = $this->notificationModel->getRecentUnread($userId, 5);
|
||||
|
||||
// Format for display
|
||||
foreach ($notifications as &$notification) {
|
||||
$notification['time_ago'] = $this->timeAgo($notification['created_at']);
|
||||
$notification['icon'] = $this->getNotificationIcon($notification['type']);
|
||||
$notification['color'] = $this->getNotificationColor($notification['type']);
|
||||
}
|
||||
|
||||
$this->json([
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => count($notifications)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification icon based on type
|
||||
*/
|
||||
private function getNotificationIcon(string $type): string
|
||||
{
|
||||
return match($type) {
|
||||
'domain_expiring' => 'exclamation-triangle',
|
||||
'domain_expired' => 'times-circle',
|
||||
'domain_updated' => 'sync-alt',
|
||||
'session_new' => 'sign-in-alt',
|
||||
'whois_failed' => 'exclamation-circle',
|
||||
'system_welcome' => 'hand-sparkles',
|
||||
'system_upgrade' => 'arrow-up',
|
||||
default => 'bell'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification color based on type
|
||||
*/
|
||||
private function getNotificationColor(string $type): string
|
||||
{
|
||||
return match($type) {
|
||||
'domain_expiring' => 'orange',
|
||||
'domain_expired' => 'red',
|
||||
'domain_updated' => 'green',
|
||||
'session_new' => 'blue',
|
||||
'whois_failed' => 'gray',
|
||||
'system_welcome' => 'purple',
|
||||
'system_upgrade' => 'indigo',
|
||||
default => 'gray'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert timestamp to "time ago" format
|
||||
*/
|
||||
private function timeAgo(string $datetime): string
|
||||
{
|
||||
$timestamp = strtotime($datetime);
|
||||
$diff = time() - $timestamp;
|
||||
|
||||
if ($diff < 60) {
|
||||
return 'just now';
|
||||
} elseif ($diff < 3600) {
|
||||
$mins = floor($diff / 60);
|
||||
return $mins . ' minute' . ($mins > 1 ? 's' : '') . ' ago';
|
||||
} elseif ($diff < 86400) {
|
||||
$hours = floor($diff / 3600);
|
||||
return $hours . ' hour' . ($hours > 1 ? 's' : '') . ' ago';
|
||||
} elseif ($diff < 604800) {
|
||||
$days = floor($diff / 86400);
|
||||
return $days . ' day' . ($days > 1 ? 's' : '') . ' ago';
|
||||
} else {
|
||||
return date('M d, Y', $timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
328
app/Controllers/ProfileController.php
Normal file
328
app/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
use Core\Auth;
|
||||
use App\Models\User;
|
||||
use App\Models\SessionManager;
|
||||
use App\Models\RememberToken;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
private User $userModel;
|
||||
private SessionManager $sessionModel;
|
||||
private RememberToken $rememberTokenModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new User();
|
||||
$this->sessionModel = new SessionManager();
|
||||
$this->rememberTokenModel = new RememberToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show profile page
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = 'User not found';
|
||||
$this->redirect('/');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean old sessions when user views their profile (perfect time!)
|
||||
// This happens naturally when users check their sessions
|
||||
try {
|
||||
$this->sessionModel->cleanOldSessions();
|
||||
} catch (\Exception $e) {
|
||||
// Silent fail - don't break the page
|
||||
error_log("Session cleanup failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Get all active sessions
|
||||
$sessions = $this->sessionModel->getByUserId($userId);
|
||||
|
||||
// Mark current session and check for remember tokens
|
||||
$currentSessionId = session_id();
|
||||
foreach ($sessions as &$session) {
|
||||
$session['is_current'] = ($session['id'] === $currentSessionId);
|
||||
// Format timestamps for display
|
||||
$session['last_activity'] = date('Y-m-d H:i:s', $session['last_activity']);
|
||||
$session['created_at'] = date('Y-m-d H:i:s', $session['created_at']);
|
||||
|
||||
// Check if this session has a remember token
|
||||
$rememberToken = $this->rememberTokenModel->getBySessionId($session['id']);
|
||||
$session['has_remember_token'] = !empty($rememberToken);
|
||||
}
|
||||
|
||||
// Format sessions for display (adds deviceIcon, browserInfo, timeAgo, sessionAge)
|
||||
$formattedSessions = \App\Helpers\SessionHelper::formatForDisplay($sessions);
|
||||
|
||||
$this->view('profile/index', [
|
||||
'user' => $user,
|
||||
'sessions' => $formattedSessions,
|
||||
'title' => 'My Profile'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update profile
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = Auth::id();
|
||||
$fullName = trim($_POST['full_name'] ?? '');
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
|
||||
// Validate
|
||||
if (empty($fullName) || empty($email)) {
|
||||
$_SESSION['error'] = 'Full name and email are required';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['error'] = 'Please enter a valid email address';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
$existingUsers = $this->userModel->where('email', $email);
|
||||
foreach ($existingUsers as $existingUser) {
|
||||
if ($existingUser['id'] != $userId) {
|
||||
$_SESSION['error'] = 'Email address is already in use';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
$this->userModel->update($userId, [
|
||||
'full_name' => $fullName,
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
// Update session
|
||||
$_SESSION['full_name'] = $fullName;
|
||||
$_SESSION['email'] = $email;
|
||||
|
||||
$_SESSION['success'] = 'Profile updated successfully';
|
||||
$this->redirect('/profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Change password
|
||||
*/
|
||||
public function changePassword()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = Auth::id();
|
||||
$currentPassword = $_POST['current_password'] ?? '';
|
||||
$newPassword = $_POST['new_password'] ?? '';
|
||||
$newPasswordConfirm = $_POST['new_password_confirm'] ?? '';
|
||||
|
||||
// Validate
|
||||
if (empty($currentPassword) || empty($newPassword) || empty($newPasswordConfirm)) {
|
||||
$_SESSION['error'] = 'All fields are required';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
if (strlen($newPassword) < 8) {
|
||||
$_SESSION['error'] = 'Password must be at least 8 characters long';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($newPassword !== $newPasswordConfirm) {
|
||||
$_SESSION['error'] = 'New passwords do not match';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get user
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
// Verify current password
|
||||
if (!$this->userModel->verifyPassword($currentPassword, $user['password'])) {
|
||||
$_SESSION['error'] = 'Current password is incorrect';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update password
|
||||
$this->userModel->changePassword($userId, $newPassword);
|
||||
|
||||
$_SESSION['success'] = 'Password changed successfully';
|
||||
$this->redirect('/profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete account
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
// Don't allow admins to delete their own account
|
||||
if ($user['role'] === 'admin') {
|
||||
$_SESSION['error'] = 'Admin accounts cannot be deleted';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete user (cascade will handle related records)
|
||||
$this->userModel->delete($userId);
|
||||
|
||||
// Logout
|
||||
session_destroy();
|
||||
session_start();
|
||||
|
||||
$_SESSION['success'] = 'Your account has been deleted';
|
||||
$this->redirect('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend email verification
|
||||
*/
|
||||
public function resendVerification()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if ($user['email_verified']) {
|
||||
$_SESSION['info'] = 'Your email is already verified';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use AuthController logic
|
||||
$authController = new AuthController();
|
||||
|
||||
$_SESSION['pending_verification_email'] = $user['email'];
|
||||
$_SESSION['success'] = 'Verification email sent! Please check your inbox.';
|
||||
|
||||
$this->redirect('/profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout other sessions (actually terminates them!)
|
||||
*/
|
||||
public function logoutOtherSessions()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
$currentSessionId = session_id();
|
||||
|
||||
if (!$currentSessionId) {
|
||||
$_SESSION['error'] = 'No active session found';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all other sessions first to delete their remember tokens
|
||||
$allSessions = $this->sessionModel->getByUserId($userId);
|
||||
$deletedTokens = 0;
|
||||
foreach ($allSessions as $session) {
|
||||
if ($session['id'] !== $currentSessionId) {
|
||||
$deletedTokens += $this->rememberTokenModel->deleteBySessionId($session['id']);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all other sessions (this actually logs them out!)
|
||||
$count = $this->sessionModel->deleteOtherSessions($userId, $currentSessionId);
|
||||
|
||||
// Perfect time to clean all old sessions (user is security-conscious)
|
||||
$this->sessionModel->cleanOldSessions();
|
||||
|
||||
$message = "Terminated {$count} other session(s) - those devices are now logged out";
|
||||
if ($deletedTokens > 0) {
|
||||
$message .= " ({$deletedTokens} remember tokens removed)";
|
||||
}
|
||||
$_SESSION['success'] = $message;
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to terminate other sessions';
|
||||
}
|
||||
|
||||
$this->redirect('/profile#sessions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout specific session (actually terminates it!)
|
||||
*/
|
||||
public function logoutSession($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
$sessionId = $params['sessionId'] ?? '';
|
||||
$userId = Auth::id();
|
||||
$currentSessionId = session_id();
|
||||
|
||||
if (empty($sessionId)) {
|
||||
$_SESSION['error'] = 'Invalid session';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the session to verify ownership
|
||||
$session = $this->sessionModel->getById($sessionId);
|
||||
|
||||
if (!$session) {
|
||||
$_SESSION['error'] = 'Session not found';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify session belongs to current user
|
||||
if ($session['user_id'] != $userId) {
|
||||
$_SESSION['error'] = 'Unauthorized action';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent deleting current session
|
||||
if ($session['id'] === $currentSessionId) {
|
||||
$_SESSION['error'] = 'Cannot delete your current session. Use logout instead.';
|
||||
$this->redirect('/profile');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the session (this actually logs out that device!)
|
||||
$this->sessionModel->deleteById($sessionId);
|
||||
|
||||
// Also delete any remember token associated with this session
|
||||
$deletedTokens = $this->rememberTokenModel->deleteBySessionId($sessionId);
|
||||
|
||||
$message = 'Session terminated - that device is now logged out';
|
||||
if ($deletedTokens > 0) {
|
||||
$message .= ' (remember me disabled)';
|
||||
}
|
||||
$_SESSION['success'] = $message;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to terminate session';
|
||||
}
|
||||
|
||||
$this->redirect('/profile#sessions');
|
||||
}
|
||||
}
|
||||
@@ -58,9 +58,12 @@ class SearchController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Format existing domains for display
|
||||
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($existingDomains);
|
||||
|
||||
$this->view('search/results', [
|
||||
'query' => $query,
|
||||
'existingDomains' => $existingDomains,
|
||||
'existingDomains' => $formattedDomains,
|
||||
'whoisData' => $whoisData,
|
||||
'whoisError' => $whoisError,
|
||||
'isDomainLike' => $isDomainLike,
|
||||
|
||||
@@ -12,6 +12,13 @@ class SettingsController extends Controller
|
||||
public function __construct()
|
||||
{
|
||||
$this->settingModel = new Setting();
|
||||
|
||||
// Ensure only admins can access settings
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||
$this->redirect('/');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -204,7 +211,16 @@ class SettingsController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// Update app settings
|
||||
$this->settingModel->updateAppSettings($appSettings);
|
||||
|
||||
// Update registration settings
|
||||
$registrationEnabled = isset($_POST['registration_enabled']) ? '1' : '0';
|
||||
$requireEmailVerification = isset($_POST['require_email_verification']) ? '1' : '0';
|
||||
|
||||
$this->settingModel->setValue('registration_enabled', $registrationEnabled);
|
||||
$this->settingModel->setValue('require_email_verification', $requireEmailVerification);
|
||||
|
||||
$_SESSION['success'] = 'Application settings updated successfully';
|
||||
$this->redirect('/settings#app');
|
||||
|
||||
|
||||
@@ -19,6 +19,18 @@ class TldRegistryController extends Controller
|
||||
$this->importLogModel = new TldImportLog();
|
||||
$this->tldService = new TldRegistryService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
*/
|
||||
private function requireAdmin()
|
||||
{
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||
$this->redirect('/tld-registry');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display TLD registry dashboard
|
||||
@@ -76,6 +88,8 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function importTldList()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
return;
|
||||
@@ -109,6 +123,8 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function importRdap()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
return;
|
||||
@@ -142,6 +158,8 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function importWhois()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
return;
|
||||
@@ -179,6 +197,8 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function checkUpdates()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
try {
|
||||
$updateInfo = $this->tldService->checkForUpdates();
|
||||
|
||||
@@ -219,6 +239,8 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function startProgressiveImport()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
return;
|
||||
@@ -312,6 +334,8 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function bulkDelete()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
return;
|
||||
@@ -347,6 +371,8 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function toggleActive($params = [])
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
$id = $params['id'] ?? 0;
|
||||
$tld = $this->tldModel->find($id);
|
||||
|
||||
@@ -369,6 +395,8 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function refresh($params = [])
|
||||
{
|
||||
$this->requireAdmin();
|
||||
|
||||
$id = $params['id'] ?? 0;
|
||||
$tld = $this->tldModel->find($id);
|
||||
|
||||
|
||||
352
app/Controllers/UserController.php
Normal file
352
app/Controllers/UserController.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
use Core\Auth;
|
||||
use App\Models\User;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
private User $userModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new User();
|
||||
|
||||
// Ensure only admins can access user management
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||
$this->redirect('/');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Get filter parameters
|
||||
$search = trim($_GET['search'] ?? '');
|
||||
$roleFilter = $_GET['role'] ?? '';
|
||||
$statusFilter = $_GET['status'] ?? '';
|
||||
$sort = $_GET['sort'] ?? 'username';
|
||||
$order = $_GET['order'] ?? 'asc';
|
||||
$perPage = (int)($_GET['per_page'] ?? 25);
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
|
||||
// Build filters array
|
||||
$filters = [
|
||||
'search' => $search,
|
||||
'role' => $roleFilter,
|
||||
'status' => $statusFilter
|
||||
];
|
||||
|
||||
// Count total records
|
||||
$totalRecords = $this->userModel->countFiltered($filters);
|
||||
|
||||
// Calculate pagination
|
||||
$totalPages = ceil($totalRecords / $perPage);
|
||||
$page = min($page, max(1, $totalPages)); // Ensure page is within bounds
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$showingFrom = $totalRecords > 0 ? $offset + 1 : 0;
|
||||
$showingTo = min($offset + $perPage, $totalRecords);
|
||||
|
||||
// Get filtered users
|
||||
$users = $this->userModel->getFiltered($filters, $sort, strtoupper($order), $perPage, $offset);
|
||||
|
||||
$this->view('users/index', [
|
||||
'users' => $users,
|
||||
'title' => 'User Management',
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'role' => $roleFilter,
|
||||
'status' => $statusFilter,
|
||||
'sort' => $sort,
|
||||
'order' => strtolower($order)
|
||||
],
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'total_pages' => $totalPages,
|
||||
'per_page' => $perPage,
|
||||
'total' => $totalRecords,
|
||||
'showing_from' => $showingFrom,
|
||||
'showing_to' => $showingTo
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show create user form
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
$this->view('users/create', [
|
||||
'title' => 'Create User'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store new user
|
||||
*/
|
||||
public function store()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$fullName = trim($_POST['full_name'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$passwordConfirm = $_POST['password_confirm'] ?? '';
|
||||
$role = $_POST['role'] ?? 'user';
|
||||
|
||||
// Validation
|
||||
if (empty($username) || empty($email) || empty($fullName) || empty($password)) {
|
||||
$_SESSION['error'] = 'All fields are required';
|
||||
$this->redirect('/users/create');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['error'] = 'Invalid email address';
|
||||
$this->redirect('/users/create');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
|
||||
$_SESSION['error'] = 'Username can only contain letters, numbers, and underscores';
|
||||
$this->redirect('/users/create');
|
||||
return;
|
||||
}
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
$_SESSION['error'] = 'Password must be at least 8 characters';
|
||||
$this->redirect('/users/create');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($password !== $passwordConfirm) {
|
||||
$_SESSION['error'] = 'Passwords do not match';
|
||||
$this->redirect('/users/create');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
if ($this->userModel->findByUsername($username)) {
|
||||
$_SESSION['error'] = 'Username already exists';
|
||||
$this->redirect('/users/create');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email exists
|
||||
if (!empty($this->userModel->where('email', $email))) {
|
||||
$_SESSION['error'] = 'Email already exists';
|
||||
$this->redirect('/users/create');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$userId = $this->userModel->createUser($username, $password, $email, $fullName);
|
||||
|
||||
// Update role if not default
|
||||
if ($role !== 'user') {
|
||||
$this->userModel->update($userId, ['role' => $role]);
|
||||
}
|
||||
|
||||
// Mark as verified by default (admin created)
|
||||
$this->userModel->update($userId, ['email_verified' => 1]);
|
||||
|
||||
// Create welcome notification
|
||||
try {
|
||||
$notificationService = new \App\Services\NotificationService();
|
||||
$notificationService->notifyWelcome($userId, $username);
|
||||
} catch (\Exception $e) {
|
||||
// Don't fail user creation if notification fails
|
||||
error_log("Failed to create welcome notification: " . $e->getMessage());
|
||||
}
|
||||
|
||||
$_SESSION['success'] = 'User created successfully';
|
||||
$this->redirect('/users');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to create user: ' . $e->getMessage();
|
||||
$this->redirect('/users/create');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show edit user form
|
||||
*/
|
||||
public function edit()
|
||||
{
|
||||
$userId = (int)($_GET['id'] ?? 0);
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = 'User not found';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->view('users/edit', [
|
||||
'user' => $user,
|
||||
'title' => 'Edit User'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$userId = (int)($_POST['id'] ?? 0);
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = 'User not found';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$fullName = trim($_POST['full_name'] ?? '');
|
||||
$role = $_POST['role'] ?? 'user';
|
||||
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
||||
$password = $_POST['password'] ?? '';
|
||||
|
||||
// Validation
|
||||
if (empty($email) || empty($fullName)) {
|
||||
$_SESSION['error'] = 'Email and full name are required';
|
||||
$this->redirect('/users/edit?id=' . $userId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$_SESSION['error'] = 'Invalid email address';
|
||||
$this->redirect('/users/edit?id=' . $userId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if email is taken by another user
|
||||
$existingUsers = $this->userModel->where('email', $email);
|
||||
if (!empty($existingUsers) && $existingUsers[0]['id'] != $userId) {
|
||||
$_SESSION['error'] = 'Email already in use by another user';
|
||||
$this->redirect('/users/edit?id=' . $userId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$updateData = [
|
||||
'email' => $email,
|
||||
'full_name' => $fullName,
|
||||
'role' => $role,
|
||||
'is_active' => $isActive
|
||||
];
|
||||
|
||||
$this->userModel->update($userId, $updateData);
|
||||
|
||||
// Update password if provided
|
||||
if (!empty($password)) {
|
||||
if (strlen($password) < 8) {
|
||||
$_SESSION['error'] = 'Password must be at least 8 characters';
|
||||
$this->redirect('/users/edit?id=' . $userId);
|
||||
return;
|
||||
}
|
||||
$this->userModel->changePassword($userId, $password);
|
||||
}
|
||||
|
||||
$_SESSION['success'] = 'User updated successfully';
|
||||
$this->redirect('/users');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to update user: ' . $e->getMessage();
|
||||
$this->redirect('/users/edit?id=' . $userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$userId = (int)($_GET['id'] ?? 0);
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = 'User not found';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent deleting yourself
|
||||
if ($userId == Auth::id()) {
|
||||
$_SESSION['error'] = 'You cannot delete your own account';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent deleting the last admin
|
||||
if ($user['role'] === 'admin') {
|
||||
$allAdmins = $this->userModel->where('role', 'admin');
|
||||
if (count($allAdmins) <= 1) {
|
||||
$_SESSION['error'] = 'Cannot delete the last admin user';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->userModel->delete($userId);
|
||||
$_SESSION['success'] = 'User deleted successfully';
|
||||
$this->redirect('/users');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to delete user: ' . $e->getMessage();
|
||||
$this->redirect('/users');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user active status
|
||||
*/
|
||||
public function toggleStatus()
|
||||
{
|
||||
$userId = (int)($_GET['id'] ?? 0);
|
||||
$user = $this->userModel->find($userId);
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = 'User not found';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent deactivating yourself
|
||||
if ($userId == Auth::id()) {
|
||||
$_SESSION['error'] = 'You cannot deactivate your own account';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$newStatus = $user['is_active'] ? 0 : 1;
|
||||
$this->userModel->update($userId, ['is_active' => $newStatus]);
|
||||
|
||||
$_SESSION['success'] = 'User status updated successfully';
|
||||
$this->redirect('/users');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$_SESSION['error'] = 'Failed to update user status: ' . $e->getMessage();
|
||||
$this->redirect('/users');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user