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:
Hosteroid
2025-10-09 18:02:46 +03:00
parent adc28b97f0
commit e5b9599755
61 changed files with 6838 additions and 812 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
.env .env
.env.local .env.local
.env.*.local .env.*.local
.installed
# Composer # Composer
/vendor/ /vendor/

View File

@@ -5,10 +5,146 @@ All notable changes to Domain Monitor will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [1.1.0] - 2025-10-09
### Added ### Added
- TLD Registry System with IANA integration - **User Notifications System** - In-app notification center with filtering and pagination
- **Welcome Notifications** - Automatically sent to new users on registration or fresh install
- **System Upgrade Notifications** - Admins notified when system is upgraded with migration details
- **Notification Types**:
- System: Welcome, Upgrade notifications
- Domain: Expiring, Expired, Updated
- Security: New login detection
- WHOIS: Lookup failures
- **Notification Features**:
- Unread notification count in top navigation
- Dropdown preview of recent notifications
- Full notification page with filtering (status, type, date range)
- Pagination and sorting
- Mark as read / Mark all as read
- Delete individual / Clear all notifications
- **Database-Backed Sessions** - Full session management stored in database
- **Active Session Management** - View, monitor, and control all logged-in devices
- **Geolocation Tracking** - IP-based location detection (country, city, region, ISP)
- **Session Details Display**:
- Country flags with flag-icons library
- City and country name
- ISP/Network provider
- Device type detection (Desktop/Mobile/Tablet)
- Browser detection (Chrome/Firefox/Safari/Edge/Opera)
- Session age and last activity timestamps
- Remember me indicator (cookie badge)
- **Remote Session Control**:
- Terminate individual sessions with delete button
- Logout all other sessions with one click
- Immediate logout validation (deleted sessions can't access anything)
- **Enhanced Profile Page**:
- Sidebar navigation layout
- Four sections: Profile Information, Security, Active Sessions, Danger Zone
- URL hash navigation (#profile, #security, #sessions, #danger)
- Clean design matching application theme
- **Remember Token Security**:
- Remember tokens linked to specific sessions
- Deleting session also invalidates remember token
- Prevents auto-login after remote logout
- **Session Validator Middleware** - Validates sessions on every request
- **Auto-Detected Cron Paths** - Settings page shows actual installation paths (thanks @jadeops)
- **Automatic Session Cleanup** - Multiple cleanup triggers (no cron job needed)
- User registration with email verification
- Password reset via email
- Remember me functionality (30-day cookies)
- User profile management
- Change password
- Email verification with token expiry (24h)
- Password reset tokens (1h expiry)
- Registration enable/disable toggle
- User CRUD management (admin-only)
- Role-based access control (admin/user)
- Centralized app version in database
- Web-based installer (replaces CLI migrate.php)
- Web-based updater for new migrations
- Auto-detection of installation status
- Migration tracking system
- Consolidated database schema for v1.1.0 fresh installs
- Smart migration system (consolidated for new, incremental for upgrades)
### Changed
- Profile page completely redesigned with sidebar layout
- Session system migrated from file-based to database-backed
- Top navigation dropdown links updated with hash navigation
- Settings → System tab now shows auto-detected cron paths
- Help & Support menu links to GitHub repository
- Auth views refactored with base layout
- System section (Settings/Users) restricted to admins
- TLD Registry read-only for regular users
- Sidebar shows role-based links
- Profile integrated with dashboard layout
- Installation now via web UI instead of CLI
- Auto-redirect to installer on first run
### Security
- **Database Session Storage** - True session control with remote termination
- **Session Validation** - Every request validates session exists in database
- **Geolocation Logging** - Track suspicious login locations
- **Remember Token Linking** - Tokens tied to sessions, deleted together
- **Immediate Logout** - Deleted sessions invalidated within seconds
- Bcrypt password hashing
- Secure 32-byte tokens
- Time-limited tokens
- One-time use reset tokens
- HttpOnly secure cookies
- Email enumeration protection
- Session-based verification resend
- Admin-only route protection
### Technical
- **MVC Architecture Refactoring** - Complete separation of concerns
- `LayoutHelper` - Global layout data (notifications, stats, settings)
- `DomainHelper` - Domain formatting and business logic
- `SessionHelper` - Session display formatting
- `NotificationService` - Notification creation and management
- All business logic removed from views (~265 lines cleaned)
- Database session handler implementing SessionHandlerInterface
- IP geolocation via ip-api.com (free, 45 req/min)
- Session validator middleware for real-time validation
- Automatic session cleanup (no cron needed for sessions)
- Flag-icons library integration for country flags
- User-agent parsing for device and browser detection
- Remember token cascade deletion on session termination
- Notification system with 7 notification types
- Welcome notifications on user creation and fresh install
- Upgrade notifications for admins with version tracking
### Contributors
- Special thanks to @jadeops for auto-detected cron path improvement & XSS protection enhancement (PR #1)
## [1.0.0] - 2024-10-08
### Added
- Initial release of Domain Monitor
- Modern PHP 8.1+ MVC architecture
- Domain management system with CRUD operations
- Automatic WHOIS lookup for domain information
- Multi-channel notification system:
- Email notifications via PHPMailer
- Telegram bot integration
- Discord webhook support
- Slack webhook support
- Notification groups feature
- Assign domains to notification groups
- Dashboard with real-time statistics
- Domain status tracking (active, expiring_soon, expired, error)
- Notification logging system
- Customizable notification intervals
- Cron job for automated domain checks
- Test notification script
- Responsive, modern UI design
- Database migration system
- Comprehensive documentation
- Installation guide
- Basic login/logout authentication
- Security features (prepared statements, session management)
- **TLD Registry System with IANA integration**
- Import and manage TLD data (RDAP servers, WHOIS servers, registry URLs) - Import and manage TLD data (RDAP servers, WHOIS servers, registry URLs)
- Progressive import workflow with real-time progress tracking - Progressive import workflow with real-time progress tracking
- Support for 1,400+ TLDs with automatic updates - Support for 1,400+ TLDs with automatic updates
@@ -47,38 +183,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Removed hardcoded default credentials - Removed hardcoded default credentials
- 16-character cryptographically secure admin passwords - 16-character cryptographically secure admin passwords
## [1.0.0] - 2024-10-08
### Added
- Initial release of Domain Monitor
- Modern PHP 8.1+ MVC architecture
- Domain management system with CRUD operations
- Automatic WHOIS lookup for domain information
- Multi-channel notification system:
- Email notifications via PHPMailer
- Telegram bot integration
- Discord webhook support
- Slack webhook support
- Notification groups feature
- Assign domains to notification groups
- Dashboard with real-time statistics
- Domain status tracking (active, expiring_soon, expired, error)
- Notification logging system
- Customizable notification intervals
- Cron job for automated domain checks
- Test notification script
- Responsive, modern UI design
- Database migration system
- Comprehensive documentation
- Installation guide
- User authentication system
- Security features (prepared statements, session management)
### Features ### Features
- ✅ Add, edit, delete, and view domains - ✅ Add, edit, delete, and view domains
- ✅ Automatic expiration date detection via WHOIS - ✅ Automatic expiration date detection via WHOIS
- ✅ Support for multiple notification channels per group - ✅ Support for multiple notification channels per group
- ✅ Flexible notification scheduling (60,30, 15, 7, 3, 1 days before) - ✅ Flexible notification scheduling (60, 30, 21, 14, 7, 5, 3, 2, 1 days before)
- ✅ Email notifications with HTML templates - ✅ Email notifications with HTML templates
- ✅ Rich Discord embeds with color coding - ✅ Rich Discord embeds with color coding
- ✅ Telegram messages with formatting - ✅ Telegram messages with formatting
@@ -110,14 +219,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Documentation ### Documentation
- README.md with comprehensive guide - README.md with comprehensive guide
- INSTALL.md with step-by-step installation
- Inline code documentation - Inline code documentation
- Configuration examples - Configuration examples
- Troubleshooting guide - Troubleshooting guide
### Future Enhancements (Roadmap) ---
- [ ] User authentication system
- [ ] Multi-user support with permissions ## Roadmap - Future Enhancements
- [x] User authentication system (completed - v1.1.0)
- [x] Session management with geolocation (completed - v1.1.0)
- [x] TLD Registry System (completed - v1.0.0)
- [x] Remote session termination (completed - v1.1.0)
- [x] In-app user notifications (completed - v1.1.0)
- [ ] Multi-user support with advanced permissions and roles
- [ ] API for external integrations - [ ] API for external integrations
- [ ] Domain grouping/tagging - [ ] Domain grouping/tagging
- [ ] Custom notification templates - [ ] Custom notification templates
@@ -146,7 +261,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Version History ## Version History
### 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)
### 1.0.0 (2024-10-08) ### 1.0.0 (2024-10-08)
- Initial public release - Initial public release
- Created by [Hosteroid](https://www.hosteroid.uk) - Premium Hosting Solutions - Created by [Hosteroid](https://www.hosteroid.uk) - Premium Hosting Solutions
---
## 🙏 Special Thanks
### Contributors
- **@jadeops** - Auto-detected cron path improvement & XSS protection enhancement (PR #1)

View File

@@ -65,7 +65,7 @@ Use our [Feature Request Template](.github/ISSUE_TEMPLATE/feature_request.md).
3. **Set up your development environment** 3. **Set up your development environment**
- Copy `env.example.txt` to `.env` - Copy `env.example.txt` to `.env`
- Configure database settings - Configure database settings
- Run migrations: `php database/migrate.php` - Run web installer: Visit `http://localhost:8000` (or your local domain)
4. **Create a feature branch** 4. **Create a feature branch**
```bash ```bash
@@ -131,12 +131,15 @@ public function getDomainInfo(string $domain): ?array
If your contribution includes database changes: If your contribution includes database changes:
1. **Create a new migration file** in `database/migrations/` 1. **Create a new migration file** in `database/migrations/`
- Name it: `XXX_descriptive_name.sql` (e.g., `007_add_timezone_column.sql`) - Name it: `XXX_descriptive_name.sql` (e.g., `014_add_new_feature.sql`)
- Use sequential numbering (next available number)
- Include `IF NOT EXISTS` checks where appropriate - Include `IF NOT EXISTS` checks where appropriate
2. **Update `database/migrate.php`** to include the new migration 2. **Update `app/Controllers/InstallerController.php`**
- Add your migration to the `$incrementalMigrations` array
- Add it to the appropriate version upgrade path
3. **Test the migration** on a fresh database 3. **Test the migration** using the web updater at `/install/update`
### Frontend Changes ### Frontend Changes

162
README.md
View File

@@ -22,12 +22,17 @@ A modern PHP MVC application for monitoring domain expiration dates and sending
- 🎨 **Modern UI** - Clean, responsive design with intuitive interface - 🎨 **Modern UI** - Clean, responsive design with intuitive interface
### Advanced Features ### Advanced Features
- 🔐 **Secure by Default** - Random passwords, session management, prepared statements - 🔐 **Secure by Default** - Random passwords, database-backed sessions, prepared statements
- 🔔 **User Notifications System** - In-app notification center with real-time updates
- 📬 **Smart Notifications** - Welcome messages, upgrade alerts, domain warnings
- 🌐 **Advanced Session Management** - View all active sessions with geolocation and device tracking
- 🚨 **Remote Session Termination** - Logout any device immediately from anywhere
- 📈 **Bulk Operations** - Import, refresh, and manage multiple domains at once - 📈 **Bulk Operations** - Import, refresh, and manage multiple domains at once
- 🎯 **Flexible Alerts** - Customizable notification thresholds (60, 30, 21, 14, 7, 5, 3, 2, 1 days) - 🎯 **Flexible Alerts** - Customizable notification thresholds (60, 30, 21, 14, 7, 5, 3, 2, 1 days)
- 🔄 **Auto WHOIS Refresh** - Keep domain data up-to-date automatically - 🔄 **Auto WHOIS Refresh** - Keep domain data up-to-date automatically
- 📱 **Monitoring Controls** - Enable/disable notifications per domain with alerts - 📱 **Monitoring Controls** - Enable/disable notifications per domain with alerts
- 🌍 **RDAP Support** - Modern protocol for faster, structured domain data - 🌍 **RDAP Support** - Modern protocol for faster, structured domain data
- 🏴 **Geolocation Tracking** - See location, ISP, and device info for all sessions
## 📋 Requirements ## 📋 Requirements
@@ -43,9 +48,12 @@ A modern PHP MVC application for monitoring domain expiration dates and sending
The application includes built-in authentication with secure practices: The application includes built-in authentication with secure practices:
- 🔑 **Random Password Generation** - Unique secure password created on installation - 🔑 **Random Password Generation** - Unique secure password created on installation
- 🛡️ **Session Management** - Secure session handling with httpOnly cookies - 🛡️ **Database-Backed Sessions** - True session management with immediate remote logout
- 🌍 **Session Tracking** - Monitor all active sessions with location and device info
- 🚨 **Remote Session Control** - Terminate suspicious sessions from any device
- 💉 **SQL Injection Protection** - All queries use prepared statements - 💉 **SQL Injection Protection** - All queries use prepared statements
- 🔒 **One-time Credentials** - Admin password shown only once during setup - 🔒 **One-time Credentials** - Admin password shown only once during setup
- 🍪 **Secure Remember Me** - Cryptographically secure 30-day tokens linked to sessions
⚠️ **Important:** Save your admin password during installation - it won't be shown again! ⚠️ **Important:** Save your admin password during installation - it won't be shown again!
@@ -88,7 +96,7 @@ DB_PASSWORD=your_password
``` ```
**Note:** **Note:**
- The encryption key (APP_ENCRYPTION_KEY) will be automatically generated during migration - The encryption key (APP_ENCRYPTION_KEY) will be automatically generated during web installation
- Application name, URL, timezone, email settings, and monitoring schedules are configured through the web interface in **Settings** (not .env) - Application name, URL, timezone, email settings, and monitoring schedules are configured through the web interface in **Settings** (not .env)
### 4. Create Database ### 4. Create Database
@@ -99,48 +107,44 @@ Create a MySQL database:
CREATE DATABASE domain_monitor CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE domain_monitor CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
``` ```
### 5. Run Migrations ### 5. Run Web Installer
#### Option A: Apache/Nginx (Recommended)
Configure your web server (see step 7 below), then visit your domain in a browser:
```
http://your-domain.com
```
You'll be automatically redirected to the installer.
#### Option B: PHP Built-in Server
```bash ```bash
php database/migrate.php php -S localhost:8000 -t public
``` ```
**⚠️ IMPORTANT:** The migration will: Then visit: `http://localhost:8000`
1. **Generate an encryption key** (if not already set) and save it to `.env`
2. **Generate a random admin password** and display it **only once**
Example output: The web installer will:
``` 1. ✅ Create all database tables
🔑 Generating encryption key... 2. Generate encryption key and save to `.env`
✓ Encryption key generated and saved to .env 3. ✅ Let you set admin email and password
Key: base64_encoded_key_here 4. ✅ Show credentials on completion (save them!)
⚠️ Keep this key secret and backup securely!
... **⚠️ IMPORTANT:** The installer will display your admin credentials **only once**. Save them to a secure password manager!
🔑 Admin credentials (SAVE THESE!):
═══════════════════════════════════════
Username: admin
Password: 3f8a2b9c4d5e6f7a
═══════════════════════════════════════
⚠️ This password will not be shown again!
💾 Save it to a secure password manager.
```
**Save these immediately:**
- The encryption key is needed to decrypt sensitive data (backup securely!)
- The admin password is needed to access the dashboard
### 6. Import TLD Registry Data (Optional but Recommended) ### 6. Import TLD Registry Data (Optional but Recommended)
For enhanced WHOIS lookups with automatic server discovery: After logging in, go to **TLD Registry** page and click **"Import TLDs"** to download RDAP and WHOIS server data for 1,400+ TLDs from IANA.
Alternatively, use the CLI:
```bash ```bash
php cron/import_tld_registry.php php cron/import_tld_registry.php
``` ```
This imports RDAP and WHOIS server data for 1,400+ TLDs from IANA.
### 7. Configure Web Server ### 7. Configure Web Server
#### Apache #### Apache
@@ -217,16 +221,18 @@ All application and email settings are now managed through the **Settings** page
The application requires a cron job to check domains periodically. The application requires a cron job to check domains periodically.
**💡 Pro Tip:** The cron path is automatically detected! Go to **Settings → System** to copy the exact command for your installation.
### Linux/Mac ### Linux/Mac
```bash ```bash
crontab -e crontab -e
``` ```
Add this line to run daily at 9 AM: Add this line (or copy from Settings → System):
```cron ```cron
0 9 * * * /usr/bin/php /path/to/project/cron/check_domains.php 0 9 * * * /usr/bin/php /your/actual/path/cron/check_domains.php
``` ```
### Windows ### Windows
@@ -238,17 +244,16 @@ Use Task Scheduler:
3. Set trigger (e.g., Daily at 9:00 AM) 3. Set trigger (e.g., Daily at 9:00 AM)
4. Action: Start a program 4. Action: Start a program
5. Program: `C:\php\php.exe` 5. Program: `C:\php\php.exe`
6. Arguments: `C:\path\to\domain-monitor\cron\check_domains.php` 6. Arguments: Copy from **Settings → System** tab (auto-detected path)
## 🧪 Testing Notifications ## 🧪 Testing Notifications
Before setting up the cron job, test your notification channels: Before setting up the cron job, test your notification channels through the web interface:
```bash 1. Go to **Settings → Email** tab
php cron/test_notification.php 2. Enter a test email address
``` 3. Click **"Send Test Email"** to verify SMTP configuration
4. For Telegram/Discord/Slack, send a test from the notification group settings
Follow the prompts to test Email, Telegram, Discord, or Slack.
## 📖 Usage Guide ## 📖 Usage Guide
@@ -298,6 +303,8 @@ All system settings are managed through the **Settings** page (`/settings`) in y
- **Application Name**: Customize the display name - **Application Name**: Customize the display name
- **Application URL**: Base URL for links in emails - **Application URL**: Base URL for links in emails
- **Timezone**: Set your preferred timezone - **Timezone**: Set your preferred timezone
- **User Registration**: Enable/disable new user signups
- **Email Verification**: Require email verification for new users
#### Email Settings #### Email Settings
- **SMTP Configuration**: Host, port, encryption - **SMTP Configuration**: Host, port, encryption
@@ -319,6 +326,60 @@ All system settings are managed through the **Settings** page (`/settings`) in y
- Every 2 days - Every 2 days
- Weekly - Weekly
#### System Settings
- **Auto-Detected Cron Path**: Copy-paste ready cron commands with your actual installation path
- **Log File Locations**: Find logs for troubleshooting
### User Notifications
Stay informed with the in-app notification system:
#### Notification Center
- **Bell Icon**: Top navigation shows unread count with animated indicator
- **Dropdown Preview**: Quick view of 5 most recent unread notifications
- **Full Page**: `/notifications` with complete history and management
#### Notification Types
- 📬 **Welcome** - Sent when you create an account or system is installed
- ⬆️ **System Upgrade** - Admins notified when system is updated (includes version & migration count)
- 🔴 **Domain Expiring** - Alerts based on your configured thresholds
- ⚠️ **Domain Expired** - Critical alerts for expired domains
- 🔄 **Domain Updated** - WHOIS data changes detected
- 🔐 **New Login** - Security alerts for new device logins
-**WHOIS Failed** - Lookup errors and issues
#### Notification Features
- **Filter by Status**: Unread, Read, or All
- **Filter by Type**: Domain, System, or Security notifications
- **Date Ranges**: Today, This Week, This Month, All Time
- **Pagination**: View 10, 25, 50, or 100 per page
- **Quick Actions**: Mark as read, Delete, Mark all read, Clear all
### Profile Management
Access your profile settings via the top-right user menu:
#### My Profile
- Update full name and email
- View account creation date
- Check last login timestamp
- Email verification status
#### Security
- Change password securely
- Password strength requirements
- Security best practices
#### Active Sessions
- **View All Sessions**: See every device where you're logged in
- **Session Details**: Location (country, city), ISP, device type, browser
- **Country Flags**: Visual indicators for each session location
- **Session Age**: See when each session was created
- **Last Activity**: Monitor recent activity per session
- **Remember Me Indicator**: See which sessions have "remember me" enabled
- **Remote Logout**: Terminate individual sessions or all other sessions
- **Instant Termination**: Deleted sessions are logged out immediately
All settings are stored in the database and can be updated at any time through the web interface. All settings are stored in the database and can be updated at any time through the web interface.
## 📁 Project Structure ## 📁 Project Structure
@@ -327,11 +388,20 @@ All settings are stored in the database and can be updated at any time through t
Domain Monitor/ Domain Monitor/
├── app/ ├── app/
│ ├── Controllers/ # Application controllers │ ├── Controllers/ # Application controllers
│ ├── Models/ # Database models │ ├── Models/ # Database models (User, Domain, SessionManager, etc.)
│ ├── Services/ # Business logic & services │ ├── Services/ # Business logic & services
│ │ ── Channels/ # Notification channel implementations │ │ ── Channels/ # Notification channel implementations
│ └── Views/ # HTML views │ └── NotificationService.php # Notification creation & management
│ ├── Helpers/ # Helper classes for formatting & display logic
│ │ ├── LayoutHelper.php # Global layout data (notifications, stats)
│ │ ├── DomainHelper.php # Domain formatting & calculations
│ │ └── SessionHelper.php # Session display formatting
│ └── Views/ # HTML views (pure display, no business logic)
├── core/ # Core MVC framework ├── core/ # Core MVC framework
│ ├── DatabaseSessionHandler.php # Database session storage
│ ├── SessionValidator.php # Session validation middleware
│ ├── Auth.php # Authentication helpers
│ └── ...
├── cron/ # Cron job scripts ├── cron/ # Cron job scripts
├── database/ ├── database/
│ └── migrations/ # Database migrations │ └── migrations/ # Database migrations
@@ -361,9 +431,9 @@ Domain Monitor/
### Notifications Not Sending ### Notifications Not Sending
1. Check logs: `logs/cron.log` 1. Check logs: `logs/cron.log`
2. Verify notification channel configuration 2. Verify notification channel configuration in **Settings → Email**
3. Test using: `php cron/test_notification.php` 3. Test email using the built-in test function in Settings
4. Check SMTP/API credentials 4. Check SMTP/API credentials in Settings
### Database Connection Error ### Database Connection Error

View File

@@ -4,14 +4,21 @@ namespace App\Controllers;
use Core\Controller; use Core\Controller;
use App\Models\User; use App\Models\User;
use App\Models\Setting;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class AuthController extends Controller class AuthController extends Controller
{ {
private User $userModel; private User $userModel;
private Setting $settingModel;
private $db;
public function __construct() public function __construct()
{ {
$this->userModel = new User(); $this->userModel = new User();
$this->settingModel = new Setting();
$this->db = \Core\Database::getConnection();
} }
/** /**
@@ -24,8 +31,12 @@ class AuthController extends Controller
$this->redirect('/'); $this->redirect('/');
} }
// Check if registration is enabled
$registrationEnabled = $this->settingModel->getValue('registration_enabled');
$this->view('auth/login', [ $this->view('auth/login', [
'title' => 'Login' 'title' => 'Login',
'registrationEnabled' => $registrationEnabled
]); ]);
} }
@@ -41,6 +52,7 @@ class AuthController extends Controller
$username = trim($_POST['username'] ?? ''); $username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? ''; $password = $_POST['password'] ?? '';
$remember = isset($_POST['remember']);
// Validate input // Validate input
if (empty($username) || empty($password)) { if (empty($username) || empty($password)) {
@@ -65,10 +77,29 @@ class AuthController extends Controller
return; 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 // Login successful - create session
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username']; $_SESSION['username'] = $user['username'];
$_SESSION['full_name'] = $user['full_name']; $_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 // Update last login
$this->userModel->updateLastLogin($user['id']); $this->userModel->updateLastLogin($user['id']);
@@ -77,12 +108,618 @@ class AuthController extends Controller
$this->redirect('/'); $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 * Logout
*/ */
public function 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_destroy();
session_start(); session_start();
@@ -90,4 +727,3 @@ class AuthController extends Controller
$this->redirect('/login'); $this->redirect('/login');
} }
} }

View File

@@ -40,10 +40,14 @@ class DashboardController extends Controller
// Check system status // Check system status
$systemStatus = $this->checkSystemStatus(); $systemStatus = $this->checkSystemStatus();
// Format domains for display
$formattedRecentDomains = \App\Helpers\DomainHelper::formatMultiple($recentDomains);
$formattedExpiringDomains = \App\Helpers\DomainHelper::formatMultiple($expiringThisMonth);
$this->view('dashboard/index', [ $this->view('dashboard/index', [
'stats' => $stats, 'stats' => $stats,
'recentDomains' => $recentDomains, 'recentDomains' => $formattedRecentDomains,
'expiringThisMonth' => $expiringThisMonth, 'expiringThisMonth' => $formattedExpiringDomains,
'expiringCount' => count($allExpiringDomains), 'expiringCount' => count($allExpiringDomains),
'recentLogs' => $recentLogs, 'recentLogs' => $recentLogs,
'groups' => $groups, 'groups' => $groups,

View File

@@ -89,8 +89,11 @@ class DomainController extends Controller
$groups = $this->groupModel->all(); $groups = $this->groupModel->all();
// Format domains for display
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($paginatedDomains);
$this->view('domains/index', [ $this->view('domains/index', [
'domains' => $paginatedDomains, 'domains' => $formattedDomains,
'groups' => $groups, 'groups' => $groups,
'filters' => [ 'filters' => [
'search' => $search, 'search' => $search,
@@ -354,8 +357,24 @@ class DomainController extends Controller
$logModel = new \App\Models\NotificationLog(); $logModel = new \App\Models\NotificationLog();
$logs = $logModel->getByDomain($id, 20); $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', [ $this->view('domains/view', [
'domain' => $domain, 'domain' => $formattedDomain,
'logs' => $logs, 'logs' => $logs,
'title' => $domain['domain_name'] 'title' => $domain['domain_name']
]); ]);

View 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);
}
}
}

View 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);
}
}
}

View 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');
}
}

View File

@@ -58,9 +58,12 @@ class SearchController extends Controller
} }
} }
// Format existing domains for display
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($existingDomains);
$this->view('search/results', [ $this->view('search/results', [
'query' => $query, 'query' => $query,
'existingDomains' => $existingDomains, 'existingDomains' => $formattedDomains,
'whoisData' => $whoisData, 'whoisData' => $whoisData,
'whoisError' => $whoisError, 'whoisError' => $whoisError,
'isDomainLike' => $isDomainLike, 'isDomainLike' => $isDomainLike,

View File

@@ -12,6 +12,13 @@ class SettingsController extends Controller
public function __construct() public function __construct()
{ {
$this->settingModel = new Setting(); $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() public function index()
@@ -204,7 +211,16 @@ class SettingsController extends Controller
return; return;
} }
// Update app settings
$this->settingModel->updateAppSettings($appSettings); $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'; $_SESSION['success'] = 'Application settings updated successfully';
$this->redirect('/settings#app'); $this->redirect('/settings#app');

View File

@@ -20,6 +20,18 @@ class TldRegistryController extends Controller
$this->tldService = new TldRegistryService(); $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 * Display TLD registry dashboard
*/ */
@@ -76,6 +88,8 @@ class TldRegistryController extends Controller
*/ */
public function importTldList() public function importTldList()
{ {
$this->requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tld-registry'); $this->redirect('/tld-registry');
return; return;
@@ -109,6 +123,8 @@ class TldRegistryController extends Controller
*/ */
public function importRdap() public function importRdap()
{ {
$this->requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tld-registry'); $this->redirect('/tld-registry');
return; return;
@@ -142,6 +158,8 @@ class TldRegistryController extends Controller
*/ */
public function importWhois() public function importWhois()
{ {
$this->requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tld-registry'); $this->redirect('/tld-registry');
return; return;
@@ -179,6 +197,8 @@ class TldRegistryController extends Controller
*/ */
public function checkUpdates() public function checkUpdates()
{ {
$this->requireAdmin();
try { try {
$updateInfo = $this->tldService->checkForUpdates(); $updateInfo = $this->tldService->checkForUpdates();
@@ -219,6 +239,8 @@ class TldRegistryController extends Controller
*/ */
public function startProgressiveImport() public function startProgressiveImport()
{ {
$this->requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tld-registry'); $this->redirect('/tld-registry');
return; return;
@@ -312,6 +334,8 @@ class TldRegistryController extends Controller
*/ */
public function bulkDelete() public function bulkDelete()
{ {
$this->requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') { if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$this->redirect('/tld-registry'); $this->redirect('/tld-registry');
return; return;
@@ -347,6 +371,8 @@ class TldRegistryController extends Controller
*/ */
public function toggleActive($params = []) public function toggleActive($params = [])
{ {
$this->requireAdmin();
$id = $params['id'] ?? 0; $id = $params['id'] ?? 0;
$tld = $this->tldModel->find($id); $tld = $this->tldModel->find($id);
@@ -369,6 +395,8 @@ class TldRegistryController extends Controller
*/ */
public function refresh($params = []) public function refresh($params = [])
{ {
$this->requireAdmin();
$id = $params['id'] ?? 0; $id = $params['id'] ?? 0;
$tld = $this->tldModel->find($id); $tld = $this->tldModel->find($id);

View 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');
}
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Helpers;
class DomainHelper
{
/**
* Format domain data for display
* Adds computed fields: daysLeft, expiryClass, displayStatus, statusClass, statusIcon
*/
public static function formatForDisplay(array $domain): array
{
// Calculate days until expiry
$domain['daysLeft'] = !empty($domain['expiration_date'])
? floor((strtotime($domain['expiration_date']) - time()) / 86400)
: null;
// Determine expiry class for styling
$domain['expiryClass'] = self::getExpiryClass($domain['daysLeft']);
// Recalculate domain status if needed (backward compatibility)
$domain['displayStatus'] = self::determineStatus($domain);
// Get status badge styling
$statusBadge = self::getStatusBadge($domain['displayStatus'], $domain['daysLeft']);
$domain['statusClass'] = $statusBadge['class'];
$domain['statusText'] = $statusBadge['text'];
$domain['statusIcon'] = $statusBadge['icon'];
// Determine expiry color for labels
$domain['expiryColor'] = self::getExpiryColor($domain['daysLeft']);
return $domain;
}
/**
* Determine domain status from WHOIS data
*/
private static function determineStatus(array $domain): string
{
$status = $domain['status'] ?? '';
// If status is already set and valid, use it
if (!empty($status) && $status !== 'error') {
return $status;
}
// Parse WHOIS data
$whoisData = json_decode($domain['whois_data'] ?? '{}', true);
$statusArray = $whoisData['status'] ?? [];
// Check if domain is available
foreach ($statusArray as $statusLine) {
if (stripos($statusLine, 'AVAILABLE') !== false || stripos($statusLine, 'FREE') !== false) {
return 'available';
}
}
// Determine from days left
if ($domain['daysLeft'] !== null) {
if ($domain['daysLeft'] < 0) return 'expired';
if ($domain['daysLeft'] <= 30) return 'expiring_soon';
return 'active';
}
return 'error';
}
/**
* Get CSS class for expiry date styling
*/
private static function getExpiryClass(?int $daysLeft): string
{
if ($daysLeft === null) return '';
if ($daysLeft < 0) return 'text-red-600 font-semibold';
if ($daysLeft <= 30) return 'text-orange-600 font-semibold';
if ($daysLeft <= 90) return 'text-yellow-600';
return '';
}
/**
* Get color name for expiry
*/
private static function getExpiryColor(?int $daysLeft): string
{
if ($daysLeft === null) return 'gray';
if ($daysLeft < 0) return 'red';
if ($daysLeft <= 30) return 'orange';
if ($daysLeft <= 90) return 'yellow';
return 'green';
}
/**
* Get status badge properties (class, text, icon)
*/
private static function getStatusBadge(string $status, ?int $daysLeft): array
{
// Check for expiring soon override
if ($daysLeft !== null && $daysLeft <= 30 && $daysLeft >= 0) {
return [
'class' => 'bg-orange-100 text-orange-700 border-orange-200',
'text' => 'Expiring Soon',
'icon' => 'fa-exclamation-triangle'
];
}
return match($status) {
'available' => [
'class' => 'bg-blue-100 text-blue-700 border-blue-200',
'text' => 'Available',
'icon' => 'fa-info-circle'
],
'active' => [
'class' => 'bg-green-100 text-green-700 border-green-200',
'text' => 'Active',
'icon' => 'fa-check-circle'
],
'expired' => [
'class' => 'bg-red-100 text-red-700 border-red-200',
'text' => 'Expired',
'icon' => 'fa-times-circle'
],
'error' => [
'class' => 'bg-gray-100 text-gray-700 border-gray-200',
'text' => 'Error',
'icon' => 'fa-exclamation-circle'
],
default => [
'class' => 'bg-gray-100 text-gray-700 border-gray-200',
'text' => ucfirst($status),
'icon' => 'fa-question-circle'
]
};
}
/**
* Format multiple domains for display
*/
public static function formatMultiple(array $domains): array
{
return array_map([self::class, 'formatForDisplay'], $domains);
}
/**
* Parse and clean WHOIS status array
*/
public static function parseWhoisStatuses(array $statusArray): array
{
$validStatuses = [];
foreach ($statusArray as $status) {
$cleanStatus = trim($status);
// Skip if it's just a URL or starts with http/https or //
if (empty($cleanStatus) ||
strpos($cleanStatus, 'http') === 0 ||
strpos($cleanStatus, '//') === 0 ||
strpos($cleanStatus, 'www.') === 0) {
continue;
}
$validStatuses[] = $cleanStatus;
}
return $validStatuses;
}
/**
* Convert status to readable format
* Handles camelCase, underscores, etc.
*/
public static function formatStatusText(string $status): string
{
// Convert camelCase to readable format (e.g., "clientTransferProhibited" -> "client Transfer Prohibited")
$readable = preg_replace('/([a-z])([A-Z])/', '$1 $2', $status);
// Convert underscores to spaces and capitalize words
$readable = str_replace('_', ' ', $readable);
$readable = ucwords(strtolower($readable));
return $readable;
}
/**
* Get active channel count from domain channels
*/
public static function getActiveChannelCount(array $channels): int
{
return count(array_filter($channels, fn($ch) => $ch['is_active']));
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Helpers;
use App\Models\Notification;
use App\Models\Setting;
class LayoutHelper
{
/**
* Get notifications for the top nav dropdown
*/
public static function getNotifications(int $userId): array
{
try {
$notificationModel = new Notification();
$notifications = $notificationModel->getRecentUnread($userId, 4);
$unreadCount = $notificationModel->getUnreadCount($userId);
// Format each notification
foreach ($notifications as &$notif) {
$notif['time_ago'] = self::timeAgo($notif['created_at']);
$notif['icon'] = self::getNotificationIcon($notif['type']);
$notif['color'] = self::getNotificationColor($notif['type']);
}
return [
'items' => $notifications,
'unread_count' => $unreadCount
];
} catch (\Exception $e) {
// If table doesn't exist yet
return ['items' => [], 'unread_count' => 0];
}
}
/**
* Get global stats for sidebar
*/
public static function getGlobalStats(): array
{
try {
$pdo = \Core\Database::getConnection();
// Get total domains
$totalStmt = $pdo->query("SELECT COUNT(*) as count FROM domains");
$total = $totalStmt->fetch(\PDO::FETCH_ASSOC)['count'] ?? 0;
// Get active domains
$activeStmt = $pdo->query("SELECT COUNT(*) as count FROM domains WHERE is_active = 1");
$active = $activeStmt->fetch(\PDO::FETCH_ASSOC)['count'] ?? 0;
// Get expiring soon
$settingModel = new Setting();
$notificationDays = $settingModel->getNotificationDays();
$threshold = !empty($notificationDays) ? max($notificationDays) : 30;
$expiringSoonStmt = $pdo->prepare(
"SELECT COUNT(*) as count FROM domains
WHERE is_active = 1
AND expiration_date IS NOT NULL
AND expiration_date <= DATE_ADD(NOW(), INTERVAL ? DAY)
AND expiration_date >= NOW()"
);
$expiringSoonStmt->execute([$threshold]);
$expiringSoon = $expiringSoonStmt->fetch(\PDO::FETCH_ASSOC)['count'] ?? 0;
return [
'total' => $total,
'active' => $active,
'expiring_soon' => $expiringSoon,
'expiring_threshold' => $threshold
];
} catch (\Exception $e) {
return [
'total' => 0,
'active' => 0,
'expiring_soon' => 0,
'expiring_threshold' => 30
];
}
}
/**
* Convert timestamp to "time ago" format
*/
private static function timeAgo(string $datetime): string
{
$timestamp = strtotime($datetime);
$diff = time() - $timestamp;
if ($diff < 60) return 'just now';
if ($diff < 3600) {
$mins = floor($diff / 60);
return $mins . ' min' . ($mins > 1 ? 's' : '') . ' ago';
}
if ($diff < 86400) {
$hours = floor($diff / 3600);
return $hours . ' hour' . ($hours > 1 ? 's' : '') . ' ago';
}
$days = floor($diff / 86400);
return $days . ' day' . ($days > 1 ? 's' : '') . ' ago';
}
/**
* Get notification icon based on type
*/
private static 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 static 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'
};
}
/**
* Get application settings
*/
public static function getAppSettings(): array
{
try {
$settingModel = new Setting();
$appSettings = $settingModel->getAppSettings();
return [
'app_name' => htmlspecialchars($appSettings['app_name']),
'app_timezone' => $appSettings['app_timezone'],
'app_version' => $appSettings['app_version']
];
} catch (\Exception $e) {
// Fallback defaults
$settingModel = new Setting();
return [
'app_name' => 'Domain Monitor',
'app_timezone' => 'UTC',
'app_version' => $settingModel->getAppVersion()
];
}
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Helpers;
class SessionHelper
{
/**
* Format sessions for display
* Adds: deviceIcon, browserInfo, timeAgo, sessionAge
*/
public static function formatForDisplay(array $sessions): array
{
return array_map(function($session) {
// Determine device icon
$userAgent = strtolower($session['user_agent'] ?? '');
if (strpos($userAgent, 'mobile') !== false || strpos($userAgent, 'android') !== false || strpos($userAgent, 'iphone') !== false) {
$session['deviceIcon'] = 'fa-mobile-alt';
} elseif (strpos($userAgent, 'tablet') !== false || strpos($userAgent, 'ipad') !== false) {
$session['deviceIcon'] = 'fa-tablet-alt';
} else {
$session['deviceIcon'] = 'fa-desktop';
}
// Parse browser info
if (strpos($userAgent, 'chrome') !== false) {
$session['browserInfo'] = 'Chrome';
} elseif (strpos($userAgent, 'safari') !== false) {
$session['browserInfo'] = 'Safari';
} elseif (strpos($userAgent, 'firefox') !== false) {
$session['browserInfo'] = 'Firefox';
} elseif (strpos($userAgent, 'edge') !== false) {
$session['browserInfo'] = 'Edge';
} elseif (strpos($userAgent, 'opera') !== false) {
$session['browserInfo'] = 'Opera';
} else {
$session['browserInfo'] = 'Unknown Browser';
}
// Time ago
$lastActivity = strtotime($session['last_activity']);
$diff = time() - $lastActivity;
if ($diff < 60) {
$session['timeAgo'] = 'Just now';
} elseif ($diff < 3600) {
$session['timeAgo'] = floor($diff / 60) . ' min ago';
} elseif ($diff < 86400) {
$session['timeAgo'] = floor($diff / 3600) . 'h ago';
} else {
$session['timeAgo'] = date('M j, Y', $lastActivity);
}
// Session age
$createdTime = strtotime($session['created_at']);
$sessionAge = time() - $createdTime;
if ($sessionAge < 3600) {
$session['sessionAge'] = floor($sessionAge / 60) . ' min old';
} elseif ($sessionAge < 86400) {
$session['sessionAge'] = floor($sessionAge / 3600) . 'h old';
} else {
$session['sessionAge'] = floor($sessionAge / 86400) . 'd old';
}
return $session;
}, $sessions);
}
}

180
app/Models/Notification.php Normal file
View File

@@ -0,0 +1,180 @@
<?php
namespace App\Models;
use Core\Model;
class Notification extends Model
{
protected static string $table = 'user_notifications';
/**
* Get notifications for a user with filters
*/
public function getForUser(int $userId, array $filters = [], int $limit = 10, int $offset = 0): array
{
$query = "SELECT * FROM user_notifications WHERE user_id = ?";
$params = [$userId];
// Apply status filter
if (isset($filters['status']) && $filters['status'] !== '') {
if ($filters['status'] === 'unread') {
$query .= " AND is_read = 0";
} elseif ($filters['status'] === 'read') {
$query .= " AND is_read = 1";
}
}
// Apply type filter
if (!empty($filters['type'])) {
$query .= " AND type = ?";
$params[] = $filters['type'];
}
// Apply date range filter (future enhancement)
if (!empty($filters['date_range'])) {
switch ($filters['date_range']) {
case 'today':
$query .= " AND DATE(created_at) = CURDATE()";
break;
case 'week':
$query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
break;
case 'month':
$query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)";
break;
}
}
// Order by newest first
$query .= " ORDER BY created_at DESC";
// Apply pagination
$query .= " LIMIT ? OFFSET ?";
$params[] = $limit;
$params[] = $offset;
$stmt = $this->db->prepare($query);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Count notifications for a user with filters
*/
public function countForUser(int $userId, array $filters = []): int
{
$query = "SELECT COUNT(*) as total FROM user_notifications WHERE user_id = ?";
$params = [$userId];
// Apply status filter
if (isset($filters['status']) && $filters['status'] !== '') {
if ($filters['status'] === 'unread') {
$query .= " AND is_read = 0";
} elseif ($filters['status'] === 'read') {
$query .= " AND is_read = 1";
}
}
// Apply type filter
if (!empty($filters['type'])) {
$query .= " AND type = ?";
$params[] = $filters['type'];
}
// Apply date range filter
if (!empty($filters['date_range'])) {
switch ($filters['date_range']) {
case 'today':
$query .= " AND DATE(created_at) = CURDATE()";
break;
case 'week':
$query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)";
break;
case 'month':
$query .= " AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)";
break;
}
}
$stmt = $this->db->prepare($query);
$stmt->execute($params);
return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['total'];
}
/**
* Get unread count for a user
*/
public function getUnreadCount(int $userId): int
{
$stmt = $this->db->prepare("SELECT COUNT(*) as count FROM user_notifications WHERE user_id = ? AND is_read = 0");
$stmt->execute([$userId]);
return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['count'];
}
/**
* Mark notification as read
*/
public function markAsRead(int $notificationId, int $userId): bool
{
$stmt = $this->db->prepare("UPDATE user_notifications SET is_read = 1, read_at = NOW() WHERE id = ? AND user_id = ?");
return $stmt->execute([$notificationId, $userId]);
}
/**
* Mark all notifications as read for a user
*/
public function markAllAsRead(int $userId): bool
{
$stmt = $this->db->prepare("UPDATE user_notifications SET is_read = 1, read_at = NOW() WHERE user_id = ? AND is_read = 0");
return $stmt->execute([$userId]);
}
/**
* Delete notification
*/
public function deleteNotification(int $notificationId, int $userId): bool
{
$stmt = $this->db->prepare("DELETE FROM user_notifications WHERE id = ? AND user_id = ?");
return $stmt->execute([$notificationId, $userId]);
}
/**
* Clear all notifications for a user
*/
public function clearAll(int $userId): bool
{
$stmt = $this->db->prepare("DELETE FROM user_notifications WHERE user_id = ?");
return $stmt->execute([$userId]);
}
/**
* Create a new notification
*/
public function createNotification(int $userId, string $type, string $title, string $message, ?int $domainId = null): int
{
return $this->create([
'user_id' => $userId,
'type' => $type,
'title' => $title,
'message' => $message,
'domain_id' => $domainId
]);
}
/**
* Get recent unread notifications for dropdown (limit 5)
*/
public function getRecentUnread(int $userId, int $limit = 5): array
{
$stmt = $this->db->prepare(
"SELECT * FROM user_notifications
WHERE user_id = ? AND is_read = 0
ORDER BY created_at DESC
LIMIT ?"
);
$stmt->execute([$userId, $limit]);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Core\Model;
class RememberToken extends Model
{
protected static string $table = 'remember_tokens';
/**
* Delete remember tokens by session ID
* Called when a session is terminated
*/
public function deleteBySessionId(string $sessionId): int
{
$stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE session_id = ?");
$stmt->execute([$sessionId]);
return $stmt->rowCount();
}
/**
* Get remember token by session ID
*/
public function getBySessionId(string $sessionId): ?array
{
$stmt = $this->db->prepare("SELECT * FROM remember_tokens WHERE session_id = ?");
$stmt->execute([$sessionId]);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Clean old expired tokens
*/
public function cleanExpired(): int
{
$stmt = $this->db->query("DELETE FROM remember_tokens WHERE expires_at < NOW()");
return $stmt->rowCount();
}
}

View File

@@ -0,0 +1,188 @@
<?php
namespace App\Models;
use Core\Model;
/**
* Session Manager Model
*
* Manages database-backed sessions with geolocation tracking.
* Works with DatabaseSessionHandler to provide true session control.
*/
class SessionManager extends Model
{
protected static string $table = 'sessions';
/**
* Get all active sessions for a user
*/
public function getByUserId(int $userId): array
{
$stmt = $this->db->prepare(
"SELECT
id,
user_id,
ip_address,
user_agent,
country,
country_code,
region,
city,
isp,
timezone,
last_activity,
created_at
FROM sessions
WHERE user_id = ?
ORDER BY last_activity DESC"
);
$stmt->execute([$userId]);
return $stmt->fetchAll();
}
/**
* Get session by ID
*/
public function getById(string $sessionId): ?array
{
$stmt = $this->db->prepare(
"SELECT
id,
user_id,
ip_address,
user_agent,
country,
country_code,
region,
city,
isp,
timezone,
last_activity,
created_at
FROM sessions
WHERE id = ?"
);
$stmt->execute([$sessionId]);
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Get geolocation data from IP address
* (Moved from old Session model)
*/
public static function getGeolocationData(string $ipAddress): array
{
// Skip for localhost/private IPs
if (in_array($ipAddress, ['127.0.0.1', '::1', 'localhost']) ||
preg_match('/^(10|172\.16|192\.168)\./', $ipAddress)) {
return [
'country' => 'Local',
'country_code' => 'xx',
'region' => 'Local',
'city' => 'Local',
'isp' => 'Local Network',
'timezone' => date_default_timezone_get(),
];
}
try {
// Using ip-api.com (free, no API key needed, 45 requests/minute)
$url = "http://ip-api.com/json/{$ipAddress}?fields=status,country,countryCode,region,city,isp,timezone";
$context = stream_context_create([
'http' => [
'timeout' => 3,
'user_agent' => 'Domain Monitor/1.0'
]
]);
$response = @file_get_contents($url, false, $context);
if ($response === false) {
return self::getDefaultGeolocation();
}
$data = json_decode($response, true);
if (!$data || $data['status'] !== 'success') {
return self::getDefaultGeolocation();
}
return [
'country' => $data['country'] ?? 'Unknown',
'country_code' => strtolower($data['countryCode'] ?? 'xx'),
'region' => $data['region'] ?? 'Unknown',
'city' => $data['city'] ?? 'Unknown',
'isp' => $data['isp'] ?? 'Unknown ISP',
'timezone' => $data['timezone'] ?? date_default_timezone_get(),
];
} catch (\Exception $e) {
error_log("Geolocation lookup failed: " . $e->getMessage());
return self::getDefaultGeolocation();
}
}
/**
* Get default geolocation data for fallback
*/
private static function getDefaultGeolocation(): array
{
return [
'country' => 'Unknown',
'country_code' => 'xx',
'region' => 'Unknown',
'city' => 'Unknown',
'isp' => 'Unknown ISP',
'timezone' => date_default_timezone_get(),
];
}
/**
* Delete session by ID (this actually logs out the user!)
*/
public function deleteById(string $sessionId): bool
{
$stmt = $this->db->prepare("DELETE FROM sessions WHERE id = ?");
return $stmt->execute([$sessionId]);
}
/**
* Delete all sessions for user except current
*/
public function deleteOtherSessions(int $userId, string $currentSessionId): int
{
$stmt = $this->db->prepare(
"DELETE FROM sessions WHERE user_id = ? AND id != ?"
);
$stmt->execute([$userId, $currentSessionId]);
return $stmt->rowCount();
}
/**
* Delete all sessions for user
*/
public function deleteAllUserSessions(int $userId): int
{
$stmt = $this->db->prepare("DELETE FROM sessions WHERE user_id = ?");
$stmt->execute([$userId]);
return $stmt->rowCount();
}
/**
* Clean old sessions (older than session lifetime)
*/
public function cleanOldSessions(int $lifetimeMinutes = 1440): int
{
$cutoff = time() - ($lifetimeMinutes * 60);
$stmt = $this->db->prepare(
"DELETE FROM sessions WHERE last_activity < ?"
);
$stmt->execute([$cutoff]);
return $stmt->rowCount();
}
}

View File

@@ -109,6 +109,14 @@ class Setting extends Model
return $this->setValue('last_check_run', date('Y-m-d H:i:s')); return $this->setValue('last_check_run', date('Y-m-d H:i:s'));
} }
/**
* Get application version
*/
public function getAppVersion(): string
{
return $this->getValue('app_version', '1.1.0');
}
/** /**
* Get application settings * Get application settings
*/ */
@@ -117,7 +125,8 @@ class Setting extends Model
return [ return [
'app_name' => $this->getValue('app_name', 'Domain Monitor'), 'app_name' => $this->getValue('app_name', 'Domain Monitor'),
'app_url' => $this->getValue('app_url', 'http://localhost:8000'), 'app_url' => $this->getValue('app_url', 'http://localhost:8000'),
'app_timezone' => $this->getValue('app_timezone', 'UTC') 'app_timezone' => $this->getValue('app_timezone', 'UTC'),
'app_version' => $this->getAppVersion()
]; ];
} }

View File

@@ -61,5 +61,88 @@ class User extends Model
$stmt = $this->db->prepare("UPDATE users SET password = ? WHERE id = ?"); $stmt = $this->db->prepare("UPDATE users SET password = ? WHERE id = ?");
return $stmt->execute([$hashedPassword, $userId]); return $stmt->execute([$hashedPassword, $userId]);
} }
/**
* Get users with filters, sorting, and pagination
*/
public function getFiltered(array $filters = [], string $sort = 'username', string $order = 'ASC', int $limit = 25, int $offset = 0): array
{
$query = "SELECT * FROM users WHERE 1=1";
$params = [];
// Apply search filter
if (!empty($filters['search'])) {
$query .= " AND (username LIKE ? OR email LIKE ? OR full_name LIKE ?)";
$searchTerm = "%{$filters['search']}%";
$params[] = $searchTerm;
$params[] = $searchTerm;
$params[] = $searchTerm;
}
// Apply role filter
if (!empty($filters['role'])) {
$query .= " AND role = ?";
$params[] = $filters['role'];
}
// Apply status filter
if (isset($filters['status']) && $filters['status'] !== '') {
$isActive = ($filters['status'] === 'active') ? 1 : 0;
$query .= " AND is_active = ?";
$params[] = $isActive;
}
// Apply sorting
$allowedSortColumns = ['username', 'email', 'full_name', 'role', 'is_active', 'email_verified', 'last_login', 'created_at'];
if (!in_array($sort, $allowedSortColumns)) {
$sort = 'username';
}
$order = strtoupper($order) === 'DESC' ? 'DESC' : 'ASC';
$query .= " ORDER BY {$sort} {$order}";
// Apply pagination
$query .= " LIMIT ? OFFSET ?";
$params[] = $limit;
$params[] = $offset;
$stmt = $this->db->prepare($query);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
}
/**
* Count users with filters
*/
public function countFiltered(array $filters = []): int
{
$query = "SELECT COUNT(*) as total FROM users WHERE 1=1";
$params = [];
// Apply search filter
if (!empty($filters['search'])) {
$query .= " AND (username LIKE ? OR email LIKE ? OR full_name LIKE ?)";
$searchTerm = "%{$filters['search']}%";
$params[] = $searchTerm;
$params[] = $searchTerm;
$params[] = $searchTerm;
}
// Apply role filter
if (!empty($filters['role'])) {
$query .= " AND role = ?";
$params[] = $filters['role'];
}
// Apply status filter
if (isset($filters['status']) && $filters['status'] !== '') {
$isActive = ($filters['status'] === 'active') ? 1 : 0;
$query .= " AND is_active = ?";
$params[] = $isActive;
}
$stmt = $this->db->prepare($query);
$stmt->execute($params);
return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['total'];
}
} }

View File

@@ -2,153 +2,156 @@
namespace App\Services; namespace App\Services;
use App\Services\Channels\EmailChannel; use App\Models\Notification;
use App\Services\Channels\TelegramChannel;
use App\Services\Channels\DiscordChannel;
use App\Services\Channels\SlackChannel;
class NotificationService class NotificationService
{ {
private array $channels = []; private Notification $notificationModel;
public function __construct() public function __construct()
{ {
$this->channels = [ $this->notificationModel = new Notification();
'email' => new EmailChannel(),
'telegram' => new TelegramChannel(),
'discord' => new DiscordChannel(),
'slack' => new SlackChannel(),
];
} }
/** /**
* Send notification to specified channel * Create a domain expiring notification
*/ */
public function send(string $channelType, array $config, string $message, array $data = []): bool public function notifyDomainExpiring(int $userId, string $domainName, int $daysLeft, int $domainId): void
{ {
if (!isset($this->channels[$channelType])) { $this->notificationModel->createNotification(
return false; $userId,
'domain_expiring',
'Domain Expiring Soon',
"{$domainName} expires in {$daysLeft} day" . ($daysLeft > 1 ? 's' : ''),
$domainId
);
} }
/**
* Create a domain expired notification
*/
public function notifyDomainExpired(int $userId, string $domainName, int $domainId): void
{
$this->notificationModel->createNotification(
$userId,
'domain_expired',
'Domain Expired',
"{$domainName} has expired - renew immediately",
$domainId
);
}
/**
* Create a domain WHOIS updated notification
*/
public function notifyDomainUpdated(int $userId, string $domainName, int $domainId, string $changes = ''): void
{
$message = !empty($changes) ?
"{$domainName} - {$changes}" :
"{$domainName} WHOIS data updated";
$this->notificationModel->createNotification(
$userId,
'domain_updated',
'Domain WHOIS Updated',
$message,
$domainId
);
}
/**
* Create a WHOIS lookup failed notification
*/
public function notifyWhoisFailed(int $userId, string $domainName, int $domainId, string $reason = ''): void
{
$message = !empty($reason) ?
"Could not refresh {$domainName} - {$reason}" :
"Could not refresh {$domainName}";
$this->notificationModel->createNotification(
$userId,
'whois_failed',
'WHOIS Lookup Failed',
$message,
$domainId
);
}
/**
* Create a new login notification
*/
public function notifyNewLogin(int $userId, string $location, string $ipAddress): void
{
$this->notificationModel->createNotification(
$userId,
'session_new',
'New Login Detected',
"Login from {$location} ({$ipAddress})",
null
);
}
/**
* Create welcome notification for new users/fresh install
*/
public function notifyWelcome(int $userId, string $username): void
{
$this->notificationModel->createNotification(
$userId,
'system_welcome',
'Welcome to Domain Monitor! 🎉',
"Hi {$username}! Your account is ready. Start by adding your first domain to monitor.",
null
);
}
/**
* Create system upgrade notification for admins
*/
public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount): void
{
$this->notificationModel->createNotification(
$userId,
'system_upgrade',
'System Upgraded Successfully',
"Domain Monitor upgraded from v{$fromVersion} to v{$toVersion} ({$migrationsCount} migration" . ($migrationsCount > 1 ? 's' : '') . " applied)",
null
);
}
/**
* Notify all admins about system upgrade
*/
public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount): void
{
try { try {
return $this->channels[$channelType]->send($config, $message, $data); $pdo = \Core\Database::getConnection();
$stmt = $pdo->query("SELECT id FROM users WHERE role = 'admin'");
$admins = $stmt->fetchAll(\PDO::FETCH_ASSOC);
foreach ($admins as $admin) {
$this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount);
}
} catch (\Exception $e) { } catch (\Exception $e) {
error_log("Notification send failed [$channelType]: " . $e->getMessage()); error_log("Failed to notify admins about upgrade: " . $e->getMessage());
return false;
} }
} }
/** /**
* Send notification to all active channels in a group * Delete old read notifications (cleanup)
*/ */
public function sendToGroup(int $groupId, string $subject, string $message, array $data = []): array public function cleanOldNotifications(int $daysOld = 30): void
{ {
// Get active channels for the group try {
$channelModel = new \App\Models\NotificationChannel(); $pdo = \Core\Database::getConnection();
$channels = $channelModel->getByGroupId($groupId); $stmt = $pdo->prepare(
"DELETE FROM user_notifications
$results = []; WHERE is_read = 1
AND read_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
foreach ($channels as $channel) {
if (!$channel['is_active']) {
continue; // Skip inactive channels
}
$config = json_decode($channel['channel_config'], true);
// Add subject to data for channels that support it (like email)
$channelData = array_merge(['subject' => $subject], $data);
$success = $this->send(
$channel['channel_type'],
$config,
$message,
$channelData
); );
$stmt->execute([$daysOld]);
$results[] = [ } catch (\Exception $e) {
'channel' => $channel['channel_type'], error_log("Failed to clean old notifications: " . $e->getMessage());
'success' => $success }
];
}
return $results;
}
/**
* Send domain expiration notification
*/
public function sendDomainExpirationAlert(array $domain, array $notificationChannels): array
{
$daysLeft = $this->calculateDaysLeft($domain['expiration_date']);
$message = $this->formatExpirationMessage($domain, $daysLeft);
$results = [];
foreach ($notificationChannels as $channel) {
$config = json_decode($channel['channel_config'], true);
$success = $this->send(
$channel['channel_type'],
$config,
$message,
[
'domain' => $domain['domain_name'],
'domain_id' => $domain['id'],
'days_left' => $daysLeft,
'expiration_date' => $domain['expiration_date'],
'registrar' => $domain['registrar']
]
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success
];
}
return $results;
}
/**
* Format expiration message
*/
private function formatExpirationMessage(array $domain, int $daysLeft): string
{
$domainName = $domain['domain_name'];
$expirationDate = date('F j, Y', strtotime($domain['expiration_date']));
$registrar = $domain['registrar'] ?? 'Unknown';
if ($daysLeft <= 0) {
return "🚨 URGENT: Domain '$domainName' has EXPIRED on $expirationDate!\n\n" .
"Registrar: $registrar\n" .
"Please renew immediately to avoid losing your domain.";
}
if ($daysLeft == 1) {
return "⚠️ CRITICAL: Domain '$domainName' expires TOMORROW ($expirationDate)!\n\n" .
"Registrar: $registrar\n" .
"Please renew as soon as possible.";
}
if ($daysLeft <= 7) {
return "⚠️ WARNING: Domain '$domainName' expires in $daysLeft days ($expirationDate)!\n\n" .
"Registrar: $registrar\n" .
"Please renew soon.";
}
return " REMINDER: Domain '$domainName' expires in $daysLeft days ($expirationDate).\n\n" .
"Registrar: $registrar\n" .
"Please plan for renewal.";
}
/**
* Calculate days left until expiration
*/
private function calculateDaysLeft(string $expirationDate): int
{
$expiration = strtotime($expirationDate);
$now = time();
return (int)floor(($expiration - $now) / 86400);
} }
} }

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?? 'Authentication' ?> - Domain Monitor</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#4A90E2',
dark: '#357ABD',
light: '#6BA3E8',
}
}
}
}
}
</script>
<style>
body {
background-color: #f8f9fa;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-md w-full">
<!-- Auth Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<?= $content ?>
</div>
<!-- Footer -->
<div class="text-center mt-6">
<p class="text-gray-500 text-xs">
© <?= date('Y') ?> Domain Monitor. All rights reserved.
</p>
</div>
</div>
<?php if (isset($scripts)): ?>
<?= $scripts ?>
<?php endif; ?>
</body>
</html>

View File

@@ -0,0 +1,79 @@
<?php
$title = 'Forgot Password';
ob_start();
?>
<!-- Logo and Title -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
<i class="fas fa-key text-white text-2xl"></i>
</div>
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Forgot Password?</h1>
<p class="text-sm text-gray-500">No worries, we'll send you reset instructions</p>
</div>
<!-- Error/Success Alert -->
<?php if (isset($_SESSION['error'])): ?>
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
</div>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<?php if (isset($_SESSION['success'])): ?>
<div class="mb-6 bg-green-50 border border-green-200 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
<span class="text-sm text-green-700"><?= htmlspecialchars($_SESSION['success']) ?></span>
</div>
</div>
<?php unset($_SESSION['success']); ?>
<?php endif; ?>
<!-- Forgot Password Form -->
<form method="POST" action="/forgot-password" class="space-y-5">
<!-- Email Field -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
Email Address
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-gray-400 text-sm"></i>
</div>
<input
type="email"
id="email"
name="email"
required
autofocus
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Enter your email address">
</div>
<p class="text-xs text-gray-500 mt-1">Enter the email associated with your account</p>
</div>
<!-- Submit Button -->
<button
type="submit"
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm">
<i class="fas fa-paper-plane mr-2"></i>
Send Reset Link
</button>
</form>
<!-- Back to Login Link -->
<div class="text-center mt-6 pt-6 border-t border-gray-200">
<a href="/login" class="inline-flex items-center text-sm text-gray-600 hover:text-gray-800">
<i class="fas fa-arrow-left mr-2"></i>
Back to Login
</a>
</div>
<?php
$content = ob_get_clean();
require __DIR__ . '/base-auth.php';
?>

View File

@@ -1,42 +1,8 @@
<!DOCTYPE html> <?php
<html lang="en"> $title = 'Login';
<head> ob_start();
<meta charset="UTF-8"> ?>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Domain Monitor</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#4A90E2',
dark: '#357ABD',
light: '#6BA3E8',
}
}
}
}
}
</script>
<style>
body {
background-color: #f8f9fa;
}
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-md w-full">
<!-- Login Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<!-- Logo and Title --> <!-- Logo and Title -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4"> <div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
@@ -110,10 +76,11 @@
<input <input
type="checkbox" type="checkbox"
name="remember" name="remember"
value="1"
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"> class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<span class="ml-2 text-sm text-gray-600">Remember me</span> <span class="ml-2 text-sm text-gray-600">Remember me</span>
</label> </label>
<a href="#" class="text-sm text-primary hover:text-primary-dark"> <a href="/forgot-password" class="text-sm text-primary hover:text-primary-dark">
Forgot password? Forgot password?
</a> </a>
</div> </div>
@@ -126,16 +93,22 @@
Sign In Sign In
</button> </button>
</form> </form>
</div>
<!-- Footer --> <?php if ($registrationEnabled ?? false): ?>
<div class="text-center mt-6"> <!-- Sign Up Link -->
<p class="text-gray-500 text-xs"> <div class="text-center mt-6 pt-6 border-t border-gray-200">
© <?= date('Y') ?> Domain Monitor. All rights reserved. <p class="text-sm text-gray-600">
Don't have an account?
<a href="/register" class="text-primary hover:text-primary-dark font-medium">
Create Account
</a>
</p> </p>
</div> </div>
</div> <?php endif; ?>
<?php
$content = ob_get_clean();
$scripts = <<<'SCRIPT'
<script> <script>
function togglePassword() { function togglePassword() {
const passwordInput = document.getElementById('password'); const passwordInput = document.getElementById('password');
@@ -152,5 +125,6 @@
} }
} }
</script> </script>
</body> SCRIPT;
</html> require __DIR__ . '/base-auth.php';
?>

217
app/Views/auth/register.php Normal file
View File

@@ -0,0 +1,217 @@
<?php
$title = 'Register';
ob_start();
?>
<!-- Logo and Title -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
<i class="fas fa-user-plus text-white text-2xl"></i>
</div>
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Create Account</h1>
<p class="text-sm text-gray-500">Join Domain Monitor today</p>
</div>
<!-- Error/Success Alert -->
<?php if (isset($_SESSION['error'])): ?>
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
</div>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<?php if (isset($_SESSION['success'])): ?>
<div class="mb-6 bg-green-50 border border-green-200 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-2"></i>
<span class="text-sm text-green-700"><?= htmlspecialchars($_SESSION['success']) ?></span>
</div>
</div>
<?php unset($_SESSION['success']); ?>
<?php endif; ?>
<!-- Registration Form -->
<form method="POST" action="/register" class="space-y-4">
<!-- Full Name Field -->
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1.5">
Full Name
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-user text-gray-400 text-sm"></i>
</div>
<input
type="text"
id="full_name"
name="full_name"
required
autofocus
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Enter your full name">
</div>
</div>
<!-- Username Field -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1.5">
Username
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-at text-gray-400 text-sm"></i>
</div>
<input
type="text"
id="username"
name="username"
required
pattern="[a-zA-Z0-9_]+"
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Choose a username">
</div>
<p class="text-xs text-gray-500 mt-1">Letters, numbers, and underscores only</p>
</div>
<!-- Email Field -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1.5">
Email Address
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-gray-400 text-sm"></i>
</div>
<input
type="email"
id="email"
name="email"
required
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="your.email@example.com">
</div>
</div>
<!-- Password Field -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400 text-sm"></i>
</div>
<input
type="password"
id="password"
name="password"
required
minlength="8"
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Create a strong password">
<button
type="button"
onclick="togglePassword('password')"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
<i class="fas fa-eye text-sm" id="toggleIcon-password"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<!-- Confirm Password Field -->
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-700 mb-1.5">
Confirm Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400 text-sm"></i>
</div>
<input
type="password"
id="password_confirm"
name="password_confirm"
required
minlength="8"
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Re-enter your password">
<button
type="button"
onclick="togglePassword('password_confirm')"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
<i class="fas fa-eye text-sm" id="toggleIcon-password_confirm"></i>
</button>
</div>
</div>
<!-- Terms Checkbox -->
<div class="flex items-start pt-2">
<div class="flex items-center h-5">
<input
type="checkbox"
id="terms"
name="terms"
required
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
</div>
<label for="terms" class="ml-2 text-xs text-gray-600">
I agree to the Terms of Service and Privacy Policy
</label>
</div>
<!-- Submit Button -->
<button
type="submit"
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm mt-6">
<i class="fas fa-user-plus mr-2"></i>
Create Account
</button>
</form>
<!-- Sign In Link -->
<div class="text-center mt-6 pt-6 border-t border-gray-200">
<p class="text-sm text-gray-600">
Already have an account?
<a href="/login" class="text-primary hover:text-primary-dark font-medium">
Sign In
</a>
</p>
</div>
<?php
$content = ob_get_clean();
$scripts = <<<'SCRIPT'
<script>
function togglePassword(fieldId) {
const passwordInput = document.getElementById(fieldId);
const toggleIcon = document.getElementById('toggleIcon-' + fieldId);
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.classList.remove('fa-eye');
toggleIcon.classList.add('fa-eye-slash');
} else {
passwordInput.type = 'password';
toggleIcon.classList.remove('fa-eye-slash');
toggleIcon.classList.add('fa-eye');
}
}
// Client-side password match validation
document.querySelector('form').addEventListener('submit', function(e) {
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('password_confirm').value;
if (password !== passwordConfirm) {
e.preventDefault();
alert('Passwords do not match!');
}
});
</script>
SCRIPT;
require __DIR__ . '/base-auth.php';
?>

View File

@@ -0,0 +1,147 @@
<?php
$title = 'Reset Password';
ob_start();
?>
<!-- Logo and Title -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-14 h-14 bg-primary rounded-lg mb-4">
<i class="fas fa-lock-open text-white text-2xl"></i>
</div>
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Reset Password</h1>
<p class="text-sm text-gray-500">Enter your new password below</p>
</div>
<!-- Error/Success Alert -->
<?php if (isset($_SESSION['error'])): ?>
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
</div>
</div>
<?php unset($_SESSION['error']); ?>
<?php endif; ?>
<!-- Reset Password Form -->
<form method="POST" action="/reset-password" class="space-y-4">
<!-- Hidden token field -->
<input type="hidden" name="token" value="<?= htmlspecialchars($token ?? '') ?>">
<!-- Password Field -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
New Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400 text-sm"></i>
</div>
<input
type="password"
id="password"
name="password"
required
minlength="8"
autofocus
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Enter new password">
<button
type="button"
onclick="togglePassword('password')"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
<i class="fas fa-eye text-sm" id="toggleIcon-password"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<!-- Confirm Password Field -->
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-700 mb-1.5">
Confirm New Password
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400 text-sm"></i>
</div>
<input
type="password"
id="password_confirm"
name="password_confirm"
required
minlength="8"
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-colors text-sm"
placeholder="Re-enter new password">
<button
type="button"
onclick="togglePassword('password_confirm')"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none">
<i class="fas fa-eye text-sm" id="toggleIcon-password_confirm"></i>
</button>
</div>
</div>
<!-- Password Strength Indicator -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p class="text-xs text-blue-800 mb-2">
<i class="fas fa-shield-alt mr-1"></i>
<strong>Password Requirements:</strong>
</p>
<ul class="text-xs text-blue-700 space-y-1 ml-5 list-disc">
<li>At least 8 characters long</li>
<li>Mix of uppercase and lowercase letters recommended</li>
<li>Include numbers and special characters for extra security</li>
</ul>
</div>
<!-- Submit Button -->
<button
type="submit"
class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors duration-200 flex items-center justify-center text-sm mt-6">
<i class="fas fa-check mr-2"></i>
Reset Password
</button>
</form>
<!-- Back to Login Link -->
<div class="text-center mt-6 pt-6 border-t border-gray-200">
<a href="/login" class="inline-flex items-center text-sm text-gray-600 hover:text-gray-800">
<i class="fas fa-arrow-left mr-2"></i>
Back to Login
</a>
</div>
<?php
$content = ob_get_clean();
$scripts = <<<'SCRIPT'
<script>
function togglePassword(fieldId) {
const passwordInput = document.getElementById(fieldId);
const toggleIcon = document.getElementById('toggleIcon-' + fieldId);
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
toggleIcon.classList.remove('fa-eye');
toggleIcon.classList.add('fa-eye-slash');
} else {
passwordInput.type = 'password';
toggleIcon.classList.remove('fa-eye-slash');
toggleIcon.classList.add('fa-eye');
}
}
// Client-side password match validation
document.querySelector('form').addEventListener('submit', function(e) {
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('password_confirm').value;
if (password !== passwordConfirm) {
e.preventDefault();
alert('Passwords do not match!');
}
});
</script>
SCRIPT;
require __DIR__ . '/base-auth.php';
?>

View File

@@ -0,0 +1,79 @@
<?php
$title = 'Verify Email';
ob_start();
?>
<?php if ($verified ?? false): ?>
<!-- Success State -->
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-green-100 rounded-full mb-4">
<i class="fas fa-check-circle text-green-600 text-3xl"></i>
</div>
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Email Verified!</h1>
<p class="text-gray-600 mb-6">Your email address has been successfully verified.</p>
<a href="/login" class="inline-flex items-center px-6 py-2.5 bg-primary hover:bg-primary-dark text-white text-sm rounded-lg transition-colors font-medium">
<i class="fas fa-sign-in-alt mr-2"></i>
Sign In to Your Account
</a>
</div>
<?php elseif ($error ?? false): ?>
<!-- Error State -->
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-red-100 rounded-full mb-4">
<i class="fas fa-times-circle text-red-600 text-3xl"></i>
</div>
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Verification Failed</h1>
<p class="text-gray-600 mb-6"><?= htmlspecialchars($errorMessage ?? 'Invalid or expired verification link.') ?></p>
<div class="space-y-2">
<a href="/login" class="block text-center px-6 py-2.5 bg-primary hover:bg-primary-dark text-white text-sm rounded-lg transition-colors font-medium">
<i class="fas fa-sign-in-alt mr-2"></i>
Go to Login
</a>
<a href="/resend-verification" class="block text-center px-6 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm rounded-lg transition-colors font-medium">
<i class="fas fa-redo mr-2"></i>
Resend Verification Email
</a>
</div>
</div>
<?php else: ?>
<!-- Pending State -->
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 bg-blue-100 rounded-full mb-4">
<i class="fas fa-envelope text-blue-600 text-3xl"></i>
</div>
<h1 class="text-2xl font-semibold text-gray-900 mb-2">Check Your Email</h1>
<p class="text-gray-600 mb-6">
We've sent a verification link to <strong><?= htmlspecialchars($email ?? 'your email') ?></strong>.
Please check your inbox and click the link to verify your account.
</p>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-left">
<p class="text-sm text-blue-800 mb-2">
<i class="fas fa-info-circle mr-1"></i>
<strong>Didn't receive the email?</strong>
</p>
<ul class="text-xs text-blue-700 space-y-1 ml-5 list-disc">
<li>Check your spam or junk folder</li>
<li>Make sure you entered the correct email address</li>
<li>Wait a few minutes for the email to arrive</li>
</ul>
</div>
<div class="space-y-2">
<a href="/resend-verification" class="block text-center px-6 py-2.5 bg-primary hover:bg-primary-dark text-white text-sm rounded-lg transition-colors font-medium">
<i class="fas fa-redo mr-2"></i>
Resend Verification Email
</a>
<a href="/login" class="block text-center text-sm text-gray-600 hover:text-gray-800">
Back to Login
</a>
</div>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
require __DIR__ . '/base-auth.php';
?>

View File

@@ -104,19 +104,12 @@ ob_start();
</div> </div>
<div class="flex items-center space-x-2 flex-shrink-0"> <div class="flex items-center space-x-2 flex-shrink-0">
<?php <?php
$status = $domain['status'] ?? 'active'; // Display data prepared by DomainHelper in controller
$statusClasses = [ $statusClass = $domain['statusClass'];
'active' => 'bg-green-100 text-green-700', $statusText = $domain['statusText'];
'expiring_soon' => 'bg-orange-100 text-orange-700',
'expired' => 'bg-red-100 text-red-700',
'error' => 'bg-red-100 text-red-700',
'available' => 'bg-blue-100 text-blue-700'
];
$statusClass = $statusClasses[$status] ?? 'bg-gray-100 text-gray-700';
$statusLabel = $status === 'expiring_soon' ? 'Expiring Soon' : ($status === 'available' ? 'Available' : ucfirst($status));
?> ?>
<span class="px-2 py-1 rounded text-xs font-medium <?= $statusClass ?>"> <span class="px-2 py-1 rounded text-xs font-medium <?= $statusClass ?>">
<?= $statusLabel ?> <?= $statusText ?>
</span> </span>
<a href="/domains/<?= $domain['id'] ?>" class="text-gray-400 hover:text-primary"> <a href="/domains/<?= $domain['id'] ?>" class="text-gray-400 hover:text-primary">
<i class="fas fa-chevron-right text-sm"></i> <i class="fas fa-chevron-right text-sm"></i>
@@ -238,7 +231,8 @@ ob_start();
<div class="p-4 space-y-2"> <div class="p-4 space-y-2">
<?php foreach ($expiringThisMonth as $domain): ?> <?php foreach ($expiringThisMonth as $domain): ?>
<?php <?php
$daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400); // Display data prepared by DomainHelper in controller
$daysLeft = $domain['daysLeft'];
$urgencyClass = $daysLeft <= 7 ? 'text-red-600' : ($daysLeft <= 30 ? 'text-orange-600' : 'text-yellow-600'); $urgencyClass = $daysLeft <= 7 ? 'text-red-600' : ($daysLeft <= 30 ? 'text-orange-600' : 'text-yellow-600');
?> ?>
<div class="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:border-gray-300 hover:shadow-sm transition-all duration-200"> <div class="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:border-gray-300 hover:shadow-sm transition-all duration-200">

View File

@@ -132,11 +132,15 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
<div class="flex items-end"> <div class="flex items-end space-x-2">
<button type="submit" class="w-full px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium"> <button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i> <i class="fas fa-filter mr-2"></i>
Apply Filters Apply Filters
</button> </button>
<a href="/domains" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
<i class="fas fa-times mr-2"></i>
Clear
</a>
</div> </div>
</div> </div>
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>"> <input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
@@ -217,73 +221,12 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($domains as $domain): ?> <?php foreach ($domains as $domain): ?>
<?php <?php
// Calculate days until expiry and determine status color // Display data prepared by DomainHelper in controller
$daysLeft = !empty($domain['expiration_date']) ? floor((strtotime($domain['expiration_date']) - time()) / 86400) : null; $daysLeft = $domain['daysLeft'];
$expiryClass = ''; $expiryClass = $domain['expiryClass'];
if ($daysLeft !== null) { $statusClass = $domain['statusClass'];
if ($daysLeft < 0) { $statusText = $domain['statusText'];
$expiryClass = 'text-red-600 font-semibold'; $statusIcon = $domain['statusIcon'];
} elseif ($daysLeft <= 30) {
$expiryClass = 'text-orange-600 font-semibold';
} elseif ($daysLeft <= 90) {
$expiryClass = 'text-yellow-600';
}
}
// Recalculate domain status if it's empty or error (for backward compatibility)
$domainStatus = $domain['status'];
if (empty($domainStatus) || $domainStatus === 'error') {
$whoisData = json_decode($domain['whois_data'] ?? '{}', true);
$statusArray = $whoisData['status'] ?? [];
$isAvailable = false;
foreach ($statusArray as $status) {
if (stripos($status, 'AVAILABLE') !== false || stripos($status, 'FREE') !== false) {
$isAvailable = true;
break;
}
}
if ($isAvailable) {
$domainStatus = 'available';
} elseif ($daysLeft !== null) {
if ($daysLeft < 0) {
$domainStatus = 'expired';
} elseif ($daysLeft <= 30) {
$domainStatus = 'expiring_soon';
} else {
$domainStatus = 'active';
}
} else {
$domainStatus = 'error';
}
}
// Status badge color
if ($domainStatus === 'available') {
$statusClass = 'bg-blue-100 text-blue-700 border-blue-200';
$statusText = 'Available';
$statusIcon = 'fa-info-circle';
} elseif ($daysLeft !== null && $daysLeft <= 30 && $daysLeft >= 0) {
$statusClass = 'bg-orange-100 text-orange-700 border-orange-200';
$statusText = 'Expiring Soon';
$statusIcon = 'fa-exclamation-triangle';
} elseif ($domainStatus === 'active') {
$statusClass = 'bg-green-100 text-green-700 border-green-200';
$statusText = 'Active';
$statusIcon = 'fa-check-circle';
} elseif ($domainStatus === 'expired') {
$statusClass = 'bg-red-100 text-red-700 border-red-200';
$statusText = 'Expired';
$statusIcon = 'fa-times-circle';
} elseif ($domainStatus === 'error') {
$statusClass = 'bg-gray-100 text-gray-700 border-gray-200';
$statusText = 'Error';
$statusIcon = 'fa-exclamation-circle';
} else {
$statusClass = 'bg-gray-100 text-gray-700 border-gray-200';
$statusText = ucfirst($domainStatus);
$statusIcon = 'fa-times-circle';
}
?> ?>
<tr class="hover:bg-gray-50 transition-colors duration-150 domain-row"> <tr class="hover:bg-gray-50 transition-colors duration-150 domain-row">
<td class="px-4 py-4"> <td class="px-4 py-4">

View File

@@ -3,44 +3,12 @@ $title = 'Domain Details';
$pageTitle = htmlspecialchars($domain['domain_name']); $pageTitle = htmlspecialchars($domain['domain_name']);
$pageDescription = 'Domain information and monitoring status'; $pageDescription = 'Domain information and monitoring status';
$pageIcon = 'fas fa-globe'; $pageIcon = 'fas fa-globe';
// Data already formatted by controller via DomainHelper
$whoisData = json_decode($domain['whois_data'] ?? '{}', true); $whoisData = json_decode($domain['whois_data'] ?? '{}', true);
$daysLeft = !empty($domain['expiration_date']) ? floor((strtotime($domain['expiration_date']) - time()) / 86400) : null; $daysLeft = $domain['daysLeft'];
$domainStatus = $domain['displayStatus'];
// Recalculate domain status if it's empty or error (for backward compatibility) $expiryColor = $domain['expiryColor'];
$domainStatus = $domain['status'];
if (empty($domainStatus) || $domainStatus === 'error') {
// Check WHOIS data for AVAILABLE status
$statusArray = $whoisData['status'] ?? [];
$isAvailable = false;
foreach ($statusArray as $status) {
if (stripos($status, 'AVAILABLE') !== false || stripos($status, 'FREE') !== false) {
$isAvailable = true;
break;
}
}
if ($isAvailable) {
$domainStatus = 'available';
} elseif ($daysLeft !== null) {
if ($daysLeft < 0) {
$domainStatus = 'expired';
} elseif ($daysLeft <= 30) {
$domainStatus = 'expiring_soon';
} else {
$domainStatus = 'active';
}
} else {
$domainStatus = 'error';
}
}
// Determine expiry color
$expiryColor = 'green';
if ($daysLeft !== null) {
if ($daysLeft < 0) $expiryColor = 'red';
elseif ($daysLeft <= 30) $expiryColor = 'orange';
elseif ($daysLeft <= 90) $expiryColor = 'yellow';
}
ob_start(); ob_start();
?> ?>
@@ -49,32 +17,10 @@ ob_start();
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center"> <div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
<div class="flex gap-2"> <div class="flex gap-2">
<?php <?php
// Determine domain status badge // Status badge data prepared by DomainHelper in controller
if ($domainStatus === 'available') { $statusClass = $domain['statusClass'];
$statusClass = 'bg-blue-100 text-blue-700 border-blue-200'; $statusText = $domain['statusText'];
$statusText = 'Available (Not Registered)'; $statusIcon = $domain['statusIcon'];
$statusIcon = 'fa-info-circle';
} elseif ($domainStatus === 'expired') {
$statusClass = 'bg-red-100 text-red-700 border-red-200';
$statusText = 'Expired';
$statusIcon = 'fa-times-circle';
} elseif ($domainStatus === 'expiring_soon' || ($daysLeft !== null && $daysLeft <= 30 && $daysLeft >= 0)) {
$statusClass = 'bg-orange-100 text-orange-700 border-orange-200';
$statusText = 'Expiring Soon';
$statusIcon = 'fa-exclamation-triangle';
} elseif ($domainStatus === 'active') {
$statusClass = 'bg-green-100 text-green-700 border-green-200';
$statusText = 'Active';
$statusIcon = 'fa-check-circle';
} elseif ($domainStatus === 'error') {
$statusClass = 'bg-gray-100 text-gray-700 border-gray-200';
$statusText = 'Error';
$statusIcon = 'fa-exclamation-circle';
} else {
$statusClass = 'bg-gray-100 text-gray-700 border-gray-200';
$statusText = ucfirst($domainStatus);
$statusIcon = 'fa-question-circle';
}
?> ?>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold <?= $statusClass ?>"> <span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold <?= $statusClass ?>">
<i class="fas <?= $statusIcon ?> mr-1.5"></i> <i class="fas <?= $statusIcon ?> mr-1.5"></i>
@@ -257,51 +203,20 @@ ob_start();
<?php endif; ?> <?php endif; ?>
<!-- Domain Status --> <!-- Domain Status -->
<?php if (!empty($whoisData['status']) && is_array($whoisData['status'])): ?> <?php if (!empty($domain['parsedStatuses'])): ?>
<?php
// Pre-filter to count only valid statuses
$validStatuses = [];
foreach ($whoisData['status'] as $status) {
$cleanStatus = trim($status);
// Skip if it's just a URL or starts with http/https or //
if (empty($cleanStatus) ||
strpos($cleanStatus, 'http') === 0 ||
strpos($cleanStatus, '//') === 0 ||
strpos($cleanStatus, 'www.') === 0) {
continue;
}
// Keep the full status text, don't split by spaces
// Skip if after cleaning it's empty or just a URL
if (empty($cleanStatus) || strpos($cleanStatus, 'http') === 0 || strpos($cleanStatus, '//') === 0) {
continue;
}
$validStatuses[] = $cleanStatus;
}
?>
<?php if (!empty($validStatuses)): ?>
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 bg-gray-50"> <div class="px-4 py-2 border-b border-gray-200 bg-gray-50">
<h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center"> <h3 class="text-xs font-semibold text-gray-700 uppercase tracking-wider flex items-center">
<i class="fas fa-info-circle text-gray-400 mr-2" style="font-size: 10px;"></i> <i class="fas fa-info-circle text-gray-400 mr-2" style="font-size: 10px;"></i>
Domain Status (<?= count($validStatuses) ?>) Domain Status (<?= count($domain['parsedStatuses']) ?>)
</h3> </h3>
</div> </div>
<div class="p-4"> <div class="p-4">
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
<?php foreach ($validStatuses as $cleanStatus): ?> <?php foreach ($domain['parsedStatuses'] as $cleanStatus): ?>
<?php <?php
// Convert to readable format // Format status text using helper
$readableStatus = $cleanStatus; $readableStatus = \App\Helpers\DomainHelper::formatStatusText($cleanStatus);
// Convert camelCase to readable format (for cases like "clientTransferProhibited")
$readableStatus = preg_replace('/([a-z])([A-Z])/', '$1 $2', $readableStatus);
// Convert underscores to spaces and capitalize words
$readableStatus = str_replace('_', ' ', $readableStatus);
$readableStatus = ucwords(strtolower($readableStatus));
?> ?>
<span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium" title="<?= htmlspecialchars($cleanStatus) ?>"> <span class="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium" title="<?= htmlspecialchars($cleanStatus) ?>">
<?= htmlspecialchars($readableStatus) ?> <?= htmlspecialchars($readableStatus) ?>
@@ -311,7 +226,6 @@ ob_start();
</div> </div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php endif; ?>
</div> </div>
@@ -335,11 +249,8 @@ ob_start();
<div> <div>
<p class="font-semibold text-sm text-gray-900"><?= htmlspecialchars($domain['group_name']) ?></p> <p class="font-semibold text-sm text-gray-900"><?= htmlspecialchars($domain['group_name']) ?></p>
<?php if (!empty($domain['channels'])): ?> <?php if (!empty($domain['channels'])): ?>
<?php
$activeChannels = array_filter($domain['channels'], fn($ch) => $ch['is_active']);
?>
<p class="text-xs text-gray-600"> <p class="text-xs text-gray-600">
<?= count($activeChannels) ?> / <?= count($domain['channels']) ?> channels active <?= $domain['activeChannelCount'] ?? 0 ?> / <?= count($domain['channels']) ?> channels active
</p> </p>
<?php endif; ?> <?php endif; ?>
</div> </div>

View File

@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Installation Complete</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { DEFAULT: '#4A90E2', dark: '#357ABD' }
}
}
}
}
</script>
<style>
body { background-color: #f8f9fa; }
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<!-- Success Icon -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-20 h-20 bg-green-100 rounded-full mb-4">
<i class="fas fa-check-circle text-green-600 text-5xl"></i>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Installation Complete!</h1>
<p class="text-gray-600">Domain Monitor is ready to use</p>
</div>
<!-- Important Notice -->
<div class="bg-amber-50 border-2 border-amber-400 rounded-lg p-6 mb-6">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-amber-600 text-2xl mr-4"></i>
<div class="flex-1">
<h3 class="text-lg font-semibold text-amber-900 mb-2">Save Your Credentials!</h3>
<p class="text-sm text-amber-800 mb-4">This password will not be shown again. Save it to a secure password manager.</p>
<div class="bg-white rounded-lg border border-amber-300 p-4">
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600">Username:</span>
<span class="text-sm font-mono font-bold text-gray-900">admin</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-600">Password:</span>
<span class="text-sm font-mono font-bold text-gray-900 select-all"><?= htmlspecialchars($adminPassword ?? '********') ?></span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Success Checklist -->
<div class="bg-gray-50 rounded-lg border border-gray-200 p-6 mb-6">
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4">Installation Summary</h3>
<div class="space-y-3">
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-3"></i>
<span class="text-sm text-gray-700">Database tables created</span>
</div>
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-3"></i>
<span class="text-sm text-gray-700">Admin account configured</span>
</div>
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-3"></i>
<span class="text-sm text-gray-700">Encryption key generated</span>
</div>
<div class="flex items-center">
<i class="fas fa-check-circle text-green-500 mr-3"></i>
<span class="text-sm text-gray-700">All migrations applied</span>
</div>
</div>
</div>
<!-- Next Steps -->
<div class="bg-blue-50 rounded-lg border border-blue-200 p-4 mb-6">
<h3 class="text-sm font-semibold text-blue-900 mb-3">
<i class="fas fa-lightbulb mr-2"></i>Next Steps
</h3>
<ol class="text-sm text-blue-800 space-y-1 ml-5 list-decimal">
<li>Log in with your admin credentials</li>
<li>Configure email settings (Settings → Email)</li>
<li>Import TLD registry data (TLD Registry → Import TLDs)</li>
<li>Add your first domain</li>
<li>Set up notification groups</li>
<li>Configure cron job for automated monitoring</li>
</ol>
</div>
<a href="/login" class="block w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium text-center transition-colors">
<i class="fas fa-sign-in-alt mr-2"></i>
Go to Login
</a>
</div>
<div class="text-center mt-6">
<p class="text-gray-500 text-xs">© <?= date('Y') ?> Domain Monitor</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>System Update</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { DEFAULT: '#4A90E2', dark: '#357ABD' }
}
}
}
}
</script>
<style>
body { background-color: #f8f9fa; }
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-lg mb-4">
<i class="fas fa-arrow-up text-white text-3xl"></i>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">System Update</h1>
<p class="text-gray-600">New database migrations are available</p>
</div>
<!-- Warning -->
<div class="bg-amber-50 border border-amber-300 rounded-lg p-4 mb-6">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-amber-600 text-xl mr-3"></i>
<div>
<h3 class="font-semibold text-amber-900 mb-1">Backup Recommended</h3>
<p class="text-sm text-amber-800">Please backup your database before running updates.</p>
</div>
</div>
</div>
<!-- Pending Migrations -->
<div class="mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-3">Pending Migrations</h2>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
<ul class="space-y-2">
<?php foreach ($migrations as $migration): ?>
<li class="flex items-center text-sm">
<i class="fas fa-circle text-xs text-gray-400 mr-3"></i>
<span class="font-mono text-gray-700"><?= htmlspecialchars($migration) ?></span>
</li>
<?php endforeach; ?>
</ul>
<div class="mt-3 pt-3 border-t border-gray-300">
<p class="text-sm font-semibold text-gray-900">
<i class="fas fa-database mr-2"></i>
Total: <?= count($migrations) ?> migration(s)
</p>
</div>
</div>
</div>
<!-- Error Alert -->
<?php if (isset($_SESSION['error'])): ?>
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
</div>
</div>
<?php unset($_SESSION['error']); endif; ?>
<!-- Actions -->
<form method="POST" action="/install/update" class="space-y-3">
<button type="submit" class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors">
<i class="fas fa-download mr-2"></i>
Run Update Now
</button>
<a href="/" class="block w-full text-center px-4 py-2.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-times mr-2"></i>
Cancel
</a>
</form>
</div>
<div class="text-center mt-6">
<p class="text-gray-500 text-xs">© <?= date('Y') ?> Domain Monitor</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Install Domain Monitor</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { DEFAULT: '#4A90E2', dark: '#357ABD' }
}
}
}
}
</script>
<style>
body { background-color: #f8f9fa; }
</style>
</head>
<body class="min-h-screen flex items-center justify-center p-4">
<div class="max-w-2xl w-full">
<!-- Installer Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-8">
<!-- Logo and Title -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-primary rounded-lg mb-4">
<i class="fas fa-globe text-white text-3xl"></i>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Domain Monitor Installer</h1>
<p class="text-gray-600">Welcome! Let's set up your monitoring system</p>
</div>
<!-- Installation Steps -->
<div class="bg-gray-50 rounded-lg border border-gray-200 p-6 mb-6">
<h2 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-4">Installation Steps</h2>
<div class="space-y-3">
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center text-sm font-semibold">1</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-gray-900">Database Setup</h3>
<p class="text-sm text-gray-600">Create tables and structure</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center text-sm font-semibold">2</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-gray-900">Admin Account</h3>
<p class="text-sm text-gray-600">Set your credentials below</p>
</div>
</div>
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center text-sm font-semibold">3</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-gray-900">Start Monitoring</h3>
<p class="text-sm text-gray-600">Begin tracking your domains</p>
</div>
</div>
</div>
</div>
<!-- Error Alert -->
<?php if (isset($_SESSION['error'])): ?>
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
<div class="flex items-center">
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
</div>
</div>
<?php unset($_SESSION['error']); endif; ?>
<!-- Installation Form -->
<form method="POST" action="/install/run" class="space-y-5">
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Administrator Account</h3>
<div class="space-y-4">
<div>
<label for="admin_email" class="block text-sm font-medium text-gray-700 mb-2">
Email Address <span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-gray-400 text-sm"></i>
</div>
<input type="email" id="admin_email" name="admin_email" required
class="w-full pl-10 pr-3 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="admin@example.com">
</div>
</div>
<div>
<label for="admin_password" class="block text-sm font-medium text-gray-700 mb-2">
Password <span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-lock text-gray-400 text-sm"></i>
</div>
<input type="password" id="admin_password" name="admin_password" required minlength="8"
class="w-full pl-10 pr-10 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="Enter secure password">
<button type="button" onclick="togglePassword()"
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
<i class="fas fa-eye text-sm" id="toggleIcon"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mt-4">
<p class="text-xs text-blue-800">
<i class="fas fa-info-circle mr-1"></i>
<strong>Note:</strong> These credentials will be used to access the admin panel. Save them securely!
</p>
</div>
</div>
<button type="submit" class="w-full bg-primary hover:bg-primary-dark text-white py-2.5 rounded-lg font-medium transition-colors">
<i class="fas fa-rocket mr-2"></i>
Start Installation
</button>
</form>
</div>
<!-- Footer -->
<div class="text-center mt-6">
<p class="text-gray-500 text-xs">© <?= date('Y') ?> Domain Monitor</p>
</div>
</div>
<script>
function togglePassword() {
const input = document.getElementById('admin_password');
const icon = document.getElementById('toggleIcon');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('fa-eye', 'fa-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
}
</script>
</body>
</html>

View File

@@ -4,61 +4,30 @@
* Contains: HTML structure, meta tags, CSS/JS includes, global stats * Contains: HTML structure, meta tags, CSS/JS includes, global stats
*/ */
// Fetch notifications for top nav (available on all pages)
if (isset($_SESSION['user_id'])) {
$notificationData = \App\Helpers\LayoutHelper::getNotifications($_SESSION['user_id']);
$recentNotifications = $notificationData['items'];
$unreadNotifications = $notificationData['unread_count'];
} else {
$recentNotifications = [];
$unreadNotifications = 0;
}
// Fetch global stats for sidebar (available on all pages) // Fetch global stats for sidebar (available on all pages)
if (!isset($globalStats)) { if (!isset($globalStats)) {
try { $globalStats = \App\Helpers\LayoutHelper::getGlobalStats();
$pdo = \Core\Database::getConnection();
// Get total domains
$totalStmt = $pdo->query("SELECT COUNT(*) as count FROM domains");
$totalResult = $totalStmt->fetch(\PDO::FETCH_ASSOC);
$total = $totalResult['count'] ?? 0;
// Get active domains
$activeStmt = $pdo->query("SELECT COUNT(*) as count FROM domains WHERE is_active = 1");
$activeResult = $activeStmt->fetch(\PDO::FETCH_ASSOC);
$active = $activeResult['count'] ?? 0;
// Get expiring soon - use the first notification threshold from settings
$settingModel = new \App\Models\Setting();
$notificationDays = $settingModel->getNotificationDays();
$expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30; // Use the largest notification day
$expiringSoonStmt = $pdo->prepare("SELECT COUNT(*) as count FROM domains WHERE is_active = 1 AND expiration_date IS NOT NULL AND expiration_date <= DATE_ADD(NOW(), INTERVAL ? DAY) AND expiration_date >= NOW()");
$expiringSoonStmt->execute([$expiringThreshold]);
$expiringSoonResult = $expiringSoonStmt->fetch(\PDO::FETCH_ASSOC);
$expiringSoon = $expiringSoonResult['count'] ?? 0;
$globalStats = [
'total' => $total,
'active' => $active,
'expiring_soon' => $expiringSoon,
'expiring_threshold' => $expiringThreshold
];
} catch (\Exception $e) {
$globalStats = [
'total' => 0,
'active' => 0,
'expiring_soon' => 0,
'expiring_threshold' => 30
];
}
} }
// Get application settings from database // Get application settings from database
if (!isset($appName)) { if (!isset($appName)) {
try { $appSettings = \App\Helpers\LayoutHelper::getAppSettings();
$settingModel = new \App\Models\Setting(); $appName = $appSettings['app_name'];
$appSettings = $settingModel->getAppSettings();
$appName = htmlspecialchars($appSettings['app_name']);
$appTimezone = $appSettings['app_timezone']; $appTimezone = $appSettings['app_timezone'];
$appVersion = $appSettings['app_version'];
// Set PHP timezone // Set PHP timezone
date_default_timezone_set($appTimezone); date_default_timezone_set($appTimezone);
} catch (\Exception $e) {
$appName = 'Domain Monitor';
date_default_timezone_set('UTC');
}
} }
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
@@ -179,16 +148,44 @@ if (!isset($appName)) {
// Toggle user dropdown // Toggle user dropdown
function toggleDropdown() { function toggleDropdown() {
document.getElementById('userDropdown').classList.toggle('show'); const dropdown = document.getElementById('userDropdown');
const notifDropdown = document.getElementById('notificationsDropdown');
// Close notifications dropdown if open
if (notifDropdown && notifDropdown.classList.contains('show')) {
notifDropdown.classList.remove('show');
} }
// Close dropdown when clicking outside dropdown.classList.toggle('show');
document.addEventListener('click', function(event) { }
const dropdown = document.getElementById('userDropdown');
const isClickInside = event.target.closest('[onclick="toggleDropdown()"]') || event.target.closest('#userDropdown');
if (!isClickInside && dropdown && dropdown.classList.contains('show')) { // Toggle notifications dropdown
dropdown.classList.remove('show'); function toggleNotifications() {
const dropdown = document.getElementById('notificationsDropdown');
const userDropdown = document.getElementById('userDropdown');
// Close user dropdown if open
if (userDropdown && userDropdown.classList.contains('show')) {
userDropdown.classList.remove('show');
}
dropdown.classList.toggle('show');
}
// Close dropdowns when clicking outside
document.addEventListener('click', function(event) {
const userDropdown = document.getElementById('userDropdown');
const notifDropdown = document.getElementById('notificationsDropdown');
const isUserDropdownClick = event.target.closest('[onclick="toggleDropdown()"]') || event.target.closest('#userDropdown');
const isNotifDropdownClick = event.target.closest('[onclick="toggleNotifications()"]') || event.target.closest('#notificationsDropdown');
if (!isUserDropdownClick && userDropdown && userDropdown.classList.contains('show')) {
userDropdown.classList.remove('show');
}
if (!isNotifDropdownClick && notifDropdown && notifDropdown.classList.contains('show')) {
notifDropdown.classList.remove('show');
} }
}); });

View File

@@ -33,6 +33,9 @@
<a href="/tld-registry" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/tld-registry') !== false ? 'bg-primary text-white' : '' ?>"> <a href="/tld-registry" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/tld-registry') !== false ? 'bg-primary text-white' : '' ?>">
<i class="fas fa-database text-xs mr-3 w-4"></i> <i class="fas fa-database text-xs mr-3 w-4"></i>
<span class="text-sm">TLD Registry</span> <span class="text-sm">TLD Registry</span>
<?php if (isset($_SESSION['role']) && $_SESSION['role'] !== 'admin'): ?>
<span class="ml-auto text-xs bg-gray-700 px-1.5 py-0.5 rounded text-gray-400">View</span>
<?php endif; ?>
</a> </a>
</div> </div>
@@ -47,7 +50,8 @@
</div> </div>
</div> </div>
<!-- System Section --> <!-- System Section (Admin Only) -->
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<div class="mt-4 pt-3 border-t border-gray-800"> <div class="mt-4 pt-3 border-t border-gray-800">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider px-3 mb-1">System</p> <p class="text-xs font-semibold text-gray-500 uppercase tracking-wider px-3 mb-1">System</p>
<div class="space-y-0.5"> <div class="space-y-0.5">
@@ -55,8 +59,13 @@
<i class="fas fa-cog text-xs mr-3 w-4"></i> <i class="fas fa-cog text-xs mr-3 w-4"></i>
<span class="text-sm">Settings</span> <span class="text-sm">Settings</span>
</a> </a>
<a href="/users" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/users') !== false ? 'bg-primary text-white' : '' ?>">
<i class="fas fa-users text-xs mr-3 w-4"></i>
<span class="text-sm">Users</span>
</a>
</div> </div>
</div> </div>
<?php endif; ?>
</nav> </nav>
<!-- Quick Stats Cards - Pinned to Bottom --> <!-- Quick Stats Cards - Pinned to Bottom -->
@@ -105,7 +114,7 @@
<div class="px-4 py-3 border-t border-gray-800"> <div class="px-4 py-3 border-t border-gray-800">
<div class="text-center"> <div class="text-center">
<p class="text-xs text-gray-500">© <?= date('Y') ?> Domain Monitor</p> <p class="text-xs text-gray-500">© <?= date('Y') ?> Domain Monitor</p>
<p class="text-xs text-gray-600 mt-0.5">v1.0.0</p> <p class="text-xs text-gray-600 mt-0.5">v<?= $appVersion ?></p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
<!-- Top Navigation Bar --> <!-- Top Navigation Bar -->
<!-- Notification data ($recentNotifications, $unreadNotifications) loaded in base.php -->
<nav class="bg-white border-b border-gray-200 fixed top-0 left-0 md:left-64 right-0 z-20"> <nav class="bg-white border-b border-gray-200 fixed top-0 left-0 md:left-64 right-0 z-20">
<div class="px-4 sm:px-6 lg:px-8"> <div class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16"> <div class="flex items-center justify-between h-16">
@@ -50,25 +51,72 @@
<!-- Right: Actions & User --> <!-- Right: Actions & User -->
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<!-- Quick Add Domain --> <!-- Quick Add Domain -->
<a href="/domains/create" title="Add Domain" class="flex items-center justify-center w-9 h-9 bg-primary hover:bg-primary-dark text-white rounded-lg transition-colors duration-150"> <a href="/domains/create" title="Add Domain" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150">
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
</a> </a>
<!-- Notifications --> <!-- Notifications -->
<button title="Notifications" class="relative flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150"> <div class="relative">
<button onclick="toggleNotifications()" title="Notifications" class="relative flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150">
<i class="fas fa-bell"></i> <i class="fas fa-bell"></i>
<?php if (($globalStats['expiring_soon'] ?? 0) > 0): ?> <?php if ($unreadNotifications > 0): ?>
<span class="absolute top-1 right-1 flex h-2 w-2"> <span class="absolute top-1 right-1 flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span> <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span> <span class="relative inline-flex rounded-full h-2 w-2 bg-orange-500"></span>
</span> </span>
<?php endif; ?> <?php endif; ?>
</button> </button>
<!-- Settings --> <!-- Notifications Dropdown -->
<button title="Settings" class="flex items-center justify-center w-9 h-9 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors duration-150"> <div id="notificationsDropdown" class="dropdown-menu absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border border-gray-200 max-h-[32rem] overflow-hidden">
<i class="fas fa-cog"></i> <!-- Header -->
</button> <div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900">Notifications</h3>
<?php if ($unreadNotifications > 0): ?>
<span class="px-2 py-0.5 bg-orange-100 text-orange-700 text-xs font-semibold rounded"><?= $unreadNotifications ?> new</span>
<?php endif; ?>
</div>
</div>
<!-- Notifications List (Scrollable) -->
<div class="max-h-96 overflow-y-auto">
<?php if (!empty($recentNotifications)): ?>
<?php foreach ($recentNotifications as $notif): ?>
<div class="px-4 py-3 hover:bg-gray-50 border-b border-gray-100 bg-blue-50 transition-colors cursor-pointer">
<div class="flex items-start space-x-3">
<div class="w-8 h-8 bg-<?= $notif['color'] ?>-100 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-<?= $notif['icon'] ?> text-<?= $notif['color'] ?>-600 text-sm"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($notif['title']) ?></p>
<span class="w-2 h-2 bg-blue-500 rounded-full"></span>
</div>
<p class="text-xs text-gray-600 mt-0.5"><?= htmlspecialchars($notif['message']) ?></p>
<p class="text-xs text-gray-400 mt-1"><?= $notif['time_ago'] ?></p>
</div>
</div>
</div>
<?php endforeach; ?>
<?php else: ?>
<div class="px-4 py-8 text-center">
<i class="fas fa-bell-slash text-gray-300 text-3xl mb-2"></i>
<p class="text-sm text-gray-600">No new notifications</p>
<p class="text-xs text-gray-400 mt-0.5">You're all caught up!</p>
</div>
<?php endif; ?>
</div>
<!-- Footer - View All Button -->
<div class="px-4 py-3 border-t border-gray-200 bg-gray-50">
<a href="/notifications" class="block text-center text-sm font-medium text-primary hover:text-primary-dark">
View All Notifications
<i class="fas fa-arrow-right ml-1 text-xs"></i>
</a>
</div>
</div>
</div>
<!-- Divider --> <!-- Divider -->
<div class="hidden md:block h-8 w-px bg-gray-300"></div> <div class="hidden md:block h-8 w-px bg-gray-300"></div>
@@ -81,7 +129,9 @@
</div> </div>
<div class="hidden lg:block text-left"> <div class="hidden lg:block text-left">
<p class="text-sm font-medium text-gray-700"><?= htmlspecialchars($_SESSION['username'] ?? 'User') ?></p> <p class="text-sm font-medium text-gray-700"><?= htmlspecialchars($_SESSION['username'] ?? 'User') ?></p>
<p class="text-xs text-gray-500">Administrator</p> <p class="text-xs text-gray-500">
<?= ucfirst($_SESSION['role'] ?? 'user') ?>
</p>
</div> </div>
<i class="fas fa-chevron-down text-gray-400 text-xs hidden md:block"></i> <i class="fas fa-chevron-down text-gray-400 text-xs hidden md:block"></i>
</button> </button>
@@ -90,32 +140,38 @@
<div id="userDropdown" class="dropdown-menu absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg py-2 border border-gray-200"> <div id="userDropdown" class="dropdown-menu absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg py-2 border border-gray-200">
<div class="px-4 py-3 border-b border-gray-200"> <div class="px-4 py-3 border-b border-gray-200">
<p class="text-sm font-medium text-gray-900"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p> <p class="text-sm font-medium text-gray-900"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p>
<p class="text-xs text-gray-500 mt-1"><?= htmlspecialchars($_SESSION['email'] ?? 'admin@example.com') ?></p> <p class="text-xs text-gray-500 mt-1"><?= htmlspecialchars($_SESSION['email'] ?? 'user@example.com') ?></p>
<span class="inline-block mt-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded"> <span class="inline-block mt-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded">
<i class="fas fa-circle text-xs mr-1"></i>Online <i class="fas fa-circle text-xs mr-1"></i>Online
</span> </span>
</div> </div>
<a href="#" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150"> <a href="/profile#profile" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
<i class="fas fa-user-circle w-5 text-gray-400 mr-3"></i> <i class="fas fa-user-circle w-5 text-gray-400 mr-3"></i>
My Profile My Profile
</a> </a>
<a href="#" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150"> <a href="/profile#security" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
<i class="fas fa-cog w-5 text-gray-400 mr-3"></i> <i class="fas fa-cog w-5 text-gray-400 mr-3"></i>
Account Settings Account Settings
</a> </a>
<a href="#" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150"> <a href="/notifications" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
<i class="fas fa-bell w-5 text-gray-400 mr-3"></i> <i class="fas fa-bell w-5 text-gray-400 mr-3"></i>
Notifications Notifications
<?php if ($unreadNotifications > 0): ?>
<span class="ml-auto px-2 py-0.5 bg-orange-500 text-white text-xs font-bold rounded-full">
<?= $unreadNotifications ?>
</span>
<?php endif; ?>
</a> </a>
<div class="border-t border-gray-200 my-1"></div> <div class="border-t border-gray-200 my-1"></div>
<a href="#" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150"> <a href="https://github.com/Hosteroid/domain-monitor" target="_blank" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
<i class="fas fa-question-circle w-5 text-gray-400 mr-3"></i> <i class="fab fa-github w-5 text-gray-400 mr-3"></i>
Help & Support Help & Support
<i class="fas fa-external-link-alt ml-auto text-xs text-gray-400"></i>
</a> </a>
<div class="border-t border-gray-200 my-1"></div> <div class="border-t border-gray-200 my-1"></div>
@@ -130,4 +186,3 @@
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -0,0 +1,287 @@
<?php
$title = 'Notifications';
$pageTitle = 'Notifications';
$pageDescription = 'View and manage your notifications';
$pageIcon = 'fas fa-bell';
ob_start();
// Data is passed from the controller
$filterType = $filters['type'] ?? '';
$filterStatus = $filters['status'] ?? '';
$filterDateRange = $filters['date_range'] ?? '';
$page = $pagination['current_page'];
$totalPages = $pagination['total_pages'];
$perPage = $pagination['per_page'];
$totalNotifications = $pagination['total'];
$offset = $pagination['showing_from'] - 1;
?>
<!-- Action Buttons -->
<div class="mb-4 flex flex-wrap gap-2 justify-between items-center">
<div class="flex gap-2">
<!-- Placeholder for future bulk selection actions -->
</div>
<div class="flex gap-2">
<button onclick="markAllAsRead()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-check-double mr-2"></i>
Mark All Read
</button>
<button onclick="clearAll()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash-alt mr-2"></i>
Clear All
</button>
</div>
</div>
<!-- Filters & Search -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<form method="GET" action="/notifications" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<!-- Status Filter -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
<select name="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">All Notifications</option>
<option value="unread" <?= $filterStatus === 'unread' ? 'selected' : '' ?>>Unread Only</option>
<option value="read" <?= $filterStatus === 'read' ? 'selected' : '' ?>>Read Only</option>
</select>
</div>
<!-- Type Filter -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Type</label>
<select name="type" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">All Types</option>
<optgroup label="Domain">
<option value="domain_expiring" <?= $filterType === 'domain_expiring' ? 'selected' : '' ?>>Domain Expiring</option>
<option value="domain_expired" <?= $filterType === 'domain_expired' ? 'selected' : '' ?>>Domain Expired</option>
<option value="domain_updated" <?= $filterType === 'domain_updated' ? 'selected' : '' ?>>Domain Updated</option>
<option value="whois_failed" <?= $filterType === 'whois_failed' ? 'selected' : '' ?>>WHOIS Failed</option>
</optgroup>
<optgroup label="System">
<option value="session_new" <?= $filterType === 'session_new' ? 'selected' : '' ?>>New Login</option>
<option value="system_welcome" <?= $filterType === 'system_welcome' ? 'selected' : '' ?>>Welcome</option>
<option value="system_upgrade" <?= $filterType === 'system_upgrade' ? 'selected' : '' ?>>System Upgrade</option>
</optgroup>
</select>
</div>
<!-- Date Range -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Date Range</label>
<select name="date_range" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">All Time</option>
<option value="today" <?= $filterDateRange === 'today' ? 'selected' : '' ?>>Today</option>
<option value="week" <?= $filterDateRange === 'week' ? 'selected' : '' ?>>This Week</option>
<option value="month" <?= $filterDateRange === 'month' ? 'selected' : '' ?>>This Month</option>
</select>
</div>
<!-- Apply/Reset Buttons -->
<div class="flex items-end space-x-2">
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i>
Apply Filters
</button>
<a href="/notifications" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
<i class="fas fa-times mr-2"></i>
Clear
</a>
</div>
</div>
</form>
</div>
<!-- Pagination Info & Per Page Selector -->
<div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600">
Showing <span class="font-semibold text-gray-900"><?= $offset + 1 ?></span> to
<span class="font-semibold text-gray-900"><?= min($offset + $perPage, $totalNotifications) ?></span> of
<span class="font-semibold text-gray-900"><?= $totalNotifications ?></span> notification(s)
<?php if ($unreadCount > 0): ?>
<span class="text-gray-400">•</span>
<span class="font-semibold text-blue-600"><?= $unreadCount ?></span> unread
<?php endif; ?>
</div>
<form method="GET" action="/notifications" class="flex items-center gap-2">
<!-- Preserve current filters -->
<input type="hidden" name="status" value="<?= htmlspecialchars($filterStatus) ?>">
<input type="hidden" name="type" value="<?= htmlspecialchars($filterType) ?>">
<label for="per_page" class="text-sm text-gray-600">Show:</label>
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="10" <?= $perPage == 10 ? 'selected' : '' ?>>10</option>
<option value="25" <?= $perPage == 25 ? 'selected' : '' ?>>25</option>
<option value="50" <?= $perPage == 50 ? 'selected' : '' ?>>50</option>
<option value="100" <?= $perPage == 100 ? 'selected' : '' ?>>100</option>
</select>
</form>
</div>
<!-- Notifications List -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (!empty($notifications)): ?>
<div class="divide-y divide-gray-100">
<?php foreach ($notifications as $notification): ?>
<?php
$bgClass = $notification['is_read'] ? '' : 'bg-blue-50';
$iconBgClass = "bg-{$notification['color']}-100";
$iconTextClass = "text-{$notification['color']}-600";
?>
<div class="px-4 py-3 hover:bg-gray-50 transition-colors <?= $bgClass ?>">
<div class="flex items-center gap-3">
<!-- Icon -->
<div class="w-8 h-8 <?= $iconBgClass ?> rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas fa-<?= $notification['icon'] ?> <?= $iconTextClass ?> text-xs"></i>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-gray-900"><?= htmlspecialchars($notification['title']) ?></h3>
<?php if (!$notification['is_read']): ?>
<span class="flex h-1.5 w-1.5">
<span class="animate-ping absolute inline-flex h-1.5 w-1.5 rounded-full bg-blue-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-1.5 w-1.5 bg-blue-500"></span>
</span>
<?php endif; ?>
<span class="text-xs text-gray-400 ml-auto">
<i class="fas fa-clock mr-1"></i>
<?= $notification['time_ago'] ?>
</span>
</div>
<p class="text-xs text-gray-600 mt-0.5"><?= htmlspecialchars($notification['message']) ?></p>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 ml-2">
<?php if (!$notification['is_read']): ?>
<a href="/notifications/<?= $notification['id'] ?>/mark-read" class="w-7 h-7 flex items-center justify-center text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors" title="Mark as read">
<i class="fas fa-check text-xs"></i>
</a>
<?php endif; ?>
<a href="/notifications/<?= $notification['id'] ?>/delete" onclick="return confirm('Delete this notification?')" class="w-7 h-7 flex items-center justify-center text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors" title="Delete">
<i class="fas fa-times text-xs"></i>
</a>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php else: ?>
<!-- Empty State -->
<div class="p-12 text-center">
<i class="fas fa-bell-slash text-gray-300 text-4xl mb-3"></i>
<p class="text-sm text-gray-600">No notifications found</p>
<p class="text-xs text-gray-400 mt-1">Try adjusting your filters</p>
</div>
<?php endif; ?>
</div>
<!-- Pagination Controls -->
<?php if ($totalPages > 1): ?>
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Page Info -->
<div class="text-sm text-gray-600">
Page <span class="font-semibold text-gray-900"><?= $page ?></span> of
<span class="font-semibold text-gray-900"><?= $totalPages ?></span>
</div>
<!-- Pagination Buttons -->
<div class="flex items-center gap-1">
<?php
// Helper function to build pagination URL
function paginationUrl($page, $status, $type) {
$params = $_GET;
$params['page'] = $page;
if ($status) $params['status'] = $status;
if ($type) $params['type'] = $type;
return '/notifications?' . http_build_query($params);
}
?>
<!-- First Page -->
<?php if ($page > 1): ?>
<a href="<?= paginationUrl(1, $filterStatus, $filterType) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-left"></i>
</a>
<?php endif; ?>
<!-- Previous Page -->
<?php if ($page > 1): ?>
<a href="<?= paginationUrl($page - 1, $filterStatus, $filterType) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-left"></i> Previous
</a>
<?php endif; ?>
<!-- Page Numbers -->
<?php
$range = 2; // Show 2 pages on each side of current page
$start = max(1, $page - $range);
$end = min($totalPages, $page + $range);
// Show first page + ellipsis if needed
if ($start > 1) {
echo '<a href="' . paginationUrl(1, $filterStatus, $filterType) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
if ($start > 2) {
echo '<span class="px-2 text-gray-500">...</span>';
}
}
// Page numbers
for ($i = $start; $i <= $end; $i++) {
if ($i == $page) {
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
} else {
echo '<a href="' . paginationUrl($i, $filterStatus, $filterType) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
}
}
// Show last page + ellipsis if needed
if ($end < $totalPages) {
if ($end < $totalPages - 1) {
echo '<span class="px-2 text-gray-500">...</span>';
}
echo '<a href="' . paginationUrl($totalPages, $filterStatus, $filterType) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
}
?>
<!-- Next Page -->
<?php if ($page < $totalPages): ?>
<a href="<?= paginationUrl($page + 1, $filterStatus, $filterType) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
Next <i class="fas fa-angle-right"></i>
</a>
<?php endif; ?>
<!-- Last Page -->
<?php if ($page < $totalPages): ?>
<a href="<?= paginationUrl($totalPages, $filterStatus, $filterType) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-right"></i>
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<script>
function markAllAsRead() {
if (confirm('Mark all notifications as read?')) {
window.location.href = '/notifications/mark-all-read';
}
}
function clearAll() {
if (confirm('Clear all notifications? This action cannot be undone.')) {
window.location.href = '/notifications/clear-all';
}
}
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layout/base.php';
?>

481
app/Views/profile/index.php Normal file
View File

@@ -0,0 +1,481 @@
<?php
$title = 'My Profile';
$pageTitle = 'My Profile';
$pageDescription = 'Manage your account settings and preferences';
$pageIcon = 'fas fa-user-circle';
ob_start();
?>
<!-- Main Profile Layout -->
<div class="grid grid-cols-12 gap-6">
<!-- Sidebar Navigation -->
<div class="col-span-12 lg:col-span-3">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden sticky top-6">
<!-- User Info Section -->
<div class="p-6 border-b border-gray-200 bg-gray-50">
<div class="flex flex-col items-center text-center">
<div class="w-20 h-20 rounded-full bg-primary flex items-center justify-center text-white text-2xl font-bold">
<?= strtoupper(substr($user['username'] ?? 'U', 0, 1)) ?>
</div>
<h3 class="mt-4 text-base font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? $user['username']) ?></h3>
<p class="text-sm text-gray-500 mt-1">@<?= htmlspecialchars($user['username'] ?? '') ?></p>
<!-- Role Badge -->
<span class="inline-flex items-center mt-3 px-2.5 py-1 bg-<?= $user['role'] === 'admin' ? 'indigo' : 'blue' ?>-100 text-<?= $user['role'] === 'admin' ? 'indigo' : 'blue' ?>-800 text-xs font-semibold rounded">
<i class="fas fa-<?= $user['role'] === 'admin' ? 'crown' : 'user' ?> mr-1.5"></i>
<?= ucfirst($user['role'] ?? 'user') ?>
</span>
<!-- Stats -->
<div class="grid grid-cols-2 gap-3 mt-4 w-full">
<div class="bg-white rounded-lg p-2 border border-gray-200">
<div class="text-xs text-gray-500">Member Since</div>
<div class="text-xs font-semibold text-gray-900 mt-0.5">
<?= date('M Y', strtotime($user['created_at'] ?? 'now')) ?>
</div>
</div>
<div class="bg-white rounded-lg p-2 border border-gray-200">
<div class="text-xs text-gray-500">Status</div>
<div class="text-xs font-semibold text-green-600 mt-0.5">
<i class="fas fa-circle text-xs"></i> Active
</div>
</div>
</div>
</div>
</div>
<!-- Navigation Links -->
<nav class="p-3">
<button onclick="showSection('profile')" id="nav-profile" class="nav-item active w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors mb-1">
<i class="fas fa-user-circle w-5 mr-3 text-sm"></i>
<span>Profile Information</span>
</button>
<button onclick="showSection('security')" id="nav-security" class="nav-item w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors mb-1">
<i class="fas fa-shield-alt w-5 mr-3 text-sm"></i>
<span>Security</span>
</button>
<button onclick="showSection('sessions')" id="nav-sessions" class="nav-item w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors mb-1">
<i class="fas fa-laptop w-5 mr-3 text-sm"></i>
<span>Active Sessions</span>
</button>
<?php if ($user['role'] !== 'admin'): ?>
<hr class="my-3 border-gray-200">
<button onclick="showSection('danger')" id="nav-danger" class="nav-item w-full flex items-center px-4 py-2.5 text-sm font-medium rounded-lg transition-colors text-red-600 hover:bg-red-50">
<i class="fas fa-exclamation-triangle w-5 mr-3 text-sm"></i>
<span>Danger Zone</span>
</button>
<?php endif; ?>
</nav>
</div>
</div>
<!-- Main Content Area -->
<div class="col-span-12 lg:col-span-9">
<!-- Profile Information Section -->
<div id="section-profile" class="content-section">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">Profile Information</h3>
<p class="text-sm text-gray-600 mt-1">Update your personal details and account information</p>
</div>
<form method="POST" action="/profile/update" class="p-6">
<div class="space-y-5">
<!-- Full Name -->
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-2">
Full Name
</label>
<input type="text" id="full_name" name="full_name"
value="<?= htmlspecialchars($user['full_name'] ?? '') ?>"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input type="email" id="email" name="email"
value="<?= htmlspecialchars($user['email'] ?? '') ?>"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
<?php if (!empty($user['email_verified'])): ?>
<p class="text-xs text-green-600 mt-1.5">
<i class="fas fa-check-circle mr-1"></i>
Email verified
</p>
<?php else: ?>
<div class="mt-3 bg-amber-50 border border-amber-200 rounded-lg p-3">
<div class="flex items-start justify-between">
<div class="flex items-start">
<i class="fas fa-exclamation-triangle text-amber-600 mt-0.5 mr-2"></i>
<div>
<p class="text-xs font-semibold text-amber-900">Email Not Verified</p>
<p class="text-xs text-amber-700 mt-0.5">Verify your email to unlock all features</p>
</div>
</div>
<a href="/profile/resend-verification" class="ml-3 inline-flex items-center px-3 py-1.5 bg-amber-600 hover:bg-amber-700 text-white text-xs rounded-lg transition-colors font-medium whitespace-nowrap">
<i class="fas fa-paper-plane mr-1.5"></i>
Resend
</a>
</div>
</div>
<?php endif; ?>
</div>
<!-- Username (Read-only) -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input type="text" id="username" name="username"
value="<?= htmlspecialchars($user['username'] ?? '') ?>"
readonly
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed">
<p class="text-xs text-gray-500 mt-1">Username cannot be changed</p>
</div>
<!-- Account Details Grid -->
<div class="pt-4 border-t border-gray-200">
<h4 class="text-sm font-semibold text-gray-700 mb-3">Account Information</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200">
<label class="block text-xs font-medium text-gray-500 mb-1">Member Since</label>
<p class="text-sm font-semibold text-gray-900">
<?= date('F j, Y', strtotime($user['created_at'] ?? 'now')) ?>
</p>
</div>
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200">
<label class="block text-xs font-medium text-gray-500 mb-1">Last Login</label>
<p class="text-sm font-semibold text-gray-900">
<?= $user['last_login'] ? date('M j, Y g:i A', strtotime($user['last_login'])) : 'Never' ?>
</p>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200 space-x-2">
<button type="button" onclick="location.reload()" class="px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
Cancel
</button>
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-save mr-2"></i>
Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Security Section -->
<div id="section-security" class="content-section hidden">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">Security Settings</h3>
<p class="text-sm text-gray-600 mt-1">Manage your password and security preferences</p>
</div>
<form method="POST" action="/profile/change-password" class="p-6">
<div class="space-y-4">
<!-- Current Password -->
<div>
<label for="current_password" class="block text-sm font-medium text-gray-700 mb-2">
Current Password
</label>
<input type="password" id="current_password" name="current_password" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="Enter your current password">
</div>
<!-- New Password -->
<div>
<label for="new_password" class="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input type="password" id="new_password" name="new_password" required minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="Enter a strong password">
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<!-- Confirm New Password -->
<div>
<label for="new_password_confirm" class="block text-sm font-medium text-gray-700 mb-2">
Confirm New Password
</label>
<input type="password" id="new_password_confirm" name="new_password_confirm" required minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
placeholder="Re-enter your new password">
</div>
<!-- Password Tips -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p class="text-xs text-gray-600">
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
Use at least 8 characters with a mix of letters, numbers, and symbols for better security.
</p>
</div>
</div>
<div class="flex items-center justify-end pt-6 mt-6 border-t border-gray-200">
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-key mr-2"></i>
Update Password
</button>
</div>
</form>
</div>
</div>
<!-- Active Sessions Section -->
<div id="section-sessions" class="content-section hidden">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-gray-900">Active Sessions</h3>
<p class="text-sm text-gray-600 mt-1">Manage devices and sessions where you're logged in (<?= count($sessions ?? []) ?> active)</p>
</div>
<?php if (count($sessions ?? []) > 1): ?>
<form method="POST" action="/profile/logout-other-sessions" onsubmit="return confirm('Logout all other sessions?')" class="inline">
<button type="submit" class="inline-flex items-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-sign-out-alt mr-1.5"></i>
Logout Others
</button>
</form>
<?php endif; ?>
</div>
</div>
<div class="p-6">
<?php if (!empty($sessions)): ?>
<div class="space-y-3">
<?php foreach ($sessions as $session): ?>
<?php
// Display data prepared by SessionHelper in controller
$deviceIcon = $session['deviceIcon'];
$browserInfo = $session['browserInfo'];
$timeAgo = $session['timeAgo'];
$sessionAge = $session['sessionAge'];
$isCurrent = $session['is_current'] ?? false;
$bgClass = $isCurrent ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200';
?>
<div class="flex items-start justify-between p-4 <?= $bgClass ?> border rounded-lg">
<div class="flex items-start space-x-3 flex-1">
<!-- Device Icon -->
<div class="w-10 h-10 bg-<?= $isCurrent ? 'green' : 'gray' ?>-100 rounded-lg flex items-center justify-center flex-shrink-0">
<i class="fas <?= $deviceIcon ?> text-<?= $isCurrent ? 'green' : 'gray' ?>-600"></i>
</div>
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="flex items-center flex-wrap gap-2">
<?php if (!empty($session['country_code']) && $session['country_code'] !== 'xx'): ?>
<span class="fi fi-<?= strtolower($session['country_code']) ?> text-base"></span>
<?php endif; ?>
<h4 class="text-sm font-semibold text-gray-900">
<?= htmlspecialchars($session['city'] ?? 'Unknown') ?>, <?= htmlspecialchars($session['country'] ?? 'Unknown') ?>
</h4>
<?php if ($isCurrent): ?>
<span class="px-2 py-0.5 bg-green-500 text-white text-xs font-semibold rounded">
Current
</span>
<?php endif; ?>
<?php if (!empty($session['has_remember_token'])): ?>
<span class="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs font-semibold rounded" title="Remember me enabled">
<i class="fas fa-cookie-bite"></i>
</span>
<?php endif; ?>
</div>
<!-- Browser & OS -->
<p class="text-xs text-gray-600 mt-1">
<i class="fas fa-globe mr-1"></i>
<?= htmlspecialchars($browserInfo) ?>
<?php if (!empty($session['user_agent'])): ?>
- <?= htmlspecialchars(substr($session['user_agent'], 0, 60)) ?><?= strlen($session['user_agent']) > 60 ? '...' : '' ?>
<?php endif; ?>
</p>
<!-- IP & ISP -->
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-500 mt-1">
<span>
<i class="fas fa-map-marker-alt mr-1"></i>
<?= htmlspecialchars($session['ip_address']) ?>
</span>
<?php if (!empty($session['isp'])): ?>
<span>
<i class="fas fa-network-wired mr-1"></i>
<?= htmlspecialchars($session['isp']) ?>
</span>
<?php endif; ?>
</div>
<!-- Session Age & Last Activity -->
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400 mt-1">
<span title="Session started: <?= date('M j, Y H:i', strtotime($session['created_at'])) ?>">
<i class="fas fa-hourglass-start mr-1"></i>
<?= $sessionAge ?>
</span>
<span>
<i class="fas fa-clock mr-1"></i>
Active <?= $timeAgo ?>
</span>
</div>
</div>
</div>
<!-- Delete Button (only for non-current sessions) -->
<?php if (!$isCurrent): ?>
<form method="POST" action="/profile/logout-session/<?= htmlspecialchars($session['id']) ?>" onsubmit="return confirm('Terminate this session?\n\nThat device will be logged out immediately.')" class="ml-3">
<button type="submit" class="flex items-center justify-center w-8 h-8 bg-red-100 text-red-600 rounded-lg hover:bg-red-600 hover:text-white transition-colors" title="Terminate session">
<i class="fas fa-times text-sm"></i>
</button>
</form>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<!-- Info Box -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mt-4">
<p class="text-xs text-gray-600">
<i class="fas fa-info-circle text-blue-500 mr-1"></i>
If you see any suspicious sessions or don't recognize a device, logout other sessions immediately and change your password.
</p>
</div>
<?php else: ?>
<div class="text-center py-8">
<i class="fas fa-laptop text-gray-300 text-4xl mb-3"></i>
<p class="text-sm text-gray-600">No active sessions found</p>
</div>
<?php endif; ?>
</div>
</div>
</div>
<!-- Danger Zone Section -->
<?php if ($user['role'] !== 'admin'): ?>
<div id="section-danger" class="content-section hidden">
<div class="bg-white rounded-lg border border-red-200 overflow-hidden">
<div class="px-6 py-4 border-b border-red-200 bg-red-50">
<h3 class="text-lg font-semibold text-red-900">Danger Zone</h3>
<p class="text-sm text-red-700 mt-1">Irreversible and destructive actions</p>
</div>
<div class="p-6">
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<h4 class="text-sm font-bold text-red-900">Delete Account Permanently</h4>
<p class="text-sm text-red-700 mt-2">
Once you delete your account, there is no going back. This will permanently delete all your profile information and account settings.
</p>
<p class="text-xs text-red-800 font-semibold mt-3 bg-red-100 inline-block px-2 py-1 rounded">
This action cannot be undone
</p>
</div>
<button onclick="confirmDelete()" class="ml-4 inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium whitespace-nowrap">
<i class="fas fa-trash-alt mr-2"></i>
Delete Account
</button>
</div>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
</div>
<style>
/* Navigation Styles */
.nav-item {
color: #6b7280;
text-align: left;
}
.nav-item:hover {
background-color: #f3f4f6;
color: #1f2937;
}
.nav-item.active {
background-color: #EFF6FF;
color: #4A90E2;
font-weight: 600;
}
/* Content Section Animations */
.content-section {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
<script>
function showSection(section) {
// Hide all sections
document.querySelectorAll('.content-section').forEach(el => {
el.classList.add('hidden');
});
// Remove active class from all nav items
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.remove('active');
});
// Show selected section
document.getElementById('section-' + section).classList.remove('hidden');
// Add active class to selected nav item
document.getElementById('nav-' + section).classList.add('active');
// Update URL hash
window.location.hash = section;
// Scroll to top smoothly
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// On page load, check URL hash and show that section
document.addEventListener('DOMContentLoaded', function() {
const hash = window.location.hash.substring(1); // Remove #
const validSections = ['profile', 'security', 'sessions'<?php if ($user['role'] !== 'admin'): ?>, 'danger'<?php endif; ?>];
if (hash && validSections.includes(hash)) {
showSection(hash);
} else {
// Default to profile section
showSection('profile');
}
});
function confirmDelete() {
if (confirm('Are you absolutely sure you want to delete your account?\n\nThis action is PERMANENT and cannot be undone!')) {
if (confirm('FINAL WARNING: This will permanently delete all your data.\n\nClick OK to proceed.')) {
window.location.href = '/profile/delete';
}
}
}
</script>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layout/base.php';
?>

View File

@@ -74,13 +74,9 @@ ob_start();
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($existingDomains as $domain): ?> <?php foreach ($existingDomains as $domain): ?>
<?php <?php
$daysLeft = !empty($domain['expiration_date']) ? floor((strtotime($domain['expiration_date']) - time()) / 86400) : null; // Display data prepared by DomainHelper in controller
$expiryClass = ''; $daysLeft = $domain['daysLeft'];
if ($daysLeft !== null) { $expiryClass = $domain['expiryClass'];
if ($daysLeft < 0) $expiryClass = 'text-red-600 font-semibold';
elseif ($daysLeft <= 30) $expiryClass = 'text-orange-600 font-semibold';
elseif ($daysLeft <= 90) $expiryClass = 'text-yellow-600';
}
?> ?>
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-6 py-4"> <td class="px-6 py-4">

View File

@@ -118,6 +118,41 @@ foreach ($notificationPresets as $key => $preset) {
</select> </select>
<p class="text-xs text-gray-500 mt-1">Application timezone for dates and times</p> <p class="text-xs text-gray-500 mt-1">Application timezone for dates and times</p>
</div> </div>
<!-- User Registration Settings -->
<div class="border-t border-gray-200 pt-4 mt-6">
<h4 class="text-base font-semibold text-gray-900 mb-4">User Registration</h4>
<div class="space-y-3">
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" id="registration_enabled" name="registration_enabled" value="1"
<?= !empty($settings['registration_enabled']) ? 'checked' : '' ?>
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
</div>
<div class="ml-3">
<label for="registration_enabled" class="text-sm font-medium text-gray-700">
Enable User Registration
</label>
<p class="text-xs text-gray-500 mt-1">Allow new users to create accounts via registration form</p>
</div>
</div>
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" id="require_email_verification" name="require_email_verification" value="1"
<?= !empty($settings['require_email_verification']) ? 'checked' : '' ?>
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
</div>
<div class="ml-3">
<label for="require_email_verification" class="text-sm font-medium text-gray-700">
Require Email Verification
</label>
<p class="text-xs text-gray-500 mt-1">Users must verify their email address before accessing the system</p>
</div>
</div>
</div>
</div>
</div> </div>
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200"> <div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">

View File

@@ -30,6 +30,7 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="mb-4"> <div class="mb-4">
<div class="flex flex-wrap gap-2 justify-between items-center"> <div class="flex flex-wrap gap-2 justify-between items-center">
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<form method="POST" action="/tld-registry/start-progressive-import" class="inline"> <form method="POST" action="/tld-registry/start-progressive-import" class="inline">
<input type="hidden" name="import_type" value="complete_workflow"> <input type="hidden" name="import_type" value="complete_workflow">
@@ -45,14 +46,23 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
Check Updates Check Updates
</button> </button>
</form> </form>
</div>
<div class="flex gap-2">
<a href="/tld-registry/import-logs" class="inline-flex items-center px-4 py-2.5 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium"> <a href="/tld-registry/import-logs" class="inline-flex items-center px-4 py-2.5 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
<i class="fas fa-history mr-2"></i> <i class="fas fa-history mr-2"></i>
Import Logs Import Logs
</a> </a>
</div> </div>
<?php else: ?>
<div>
<p class="text-sm text-gray-600">
<i class="fas fa-info-circle mr-1"></i>
View-only mode. Contact admin to import or modify TLD data.
</p>
</div>
<?php endif; ?>
<div class="flex gap-2">
<!-- Search and filters will stay visible for all users -->
</div>
</div> </div>
</div> </div>
@@ -188,8 +198,8 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
</form> </form>
</div> </div>
<!-- Bulk Actions --> <!-- Bulk Actions (Admin Only) -->
<?php if (!empty($tlds)): ?> <?php if (!empty($tlds) && isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-4"> <div class="bg-white rounded-lg border border-gray-200 p-4 mb-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
@@ -216,9 +226,11 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"> <th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<input type="checkbox" id="select-all" class="rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllCheckboxes(this)"> <input type="checkbox" id="select-all" class="rounded border-gray-300 text-primary focus:ring-primary" onchange="toggleAllCheckboxes(this)">
</th> </th>
<?php endif; ?>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"> <th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('tld', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center"> <a href="<?= sortUrl('tld', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
TLD <?= sortIcon('tld', $currentFilters['sort'], $currentFilters['order']) ?> TLD <?= sortIcon('tld', $currentFilters['sort'], $currentFilters['order']) ?>
@@ -250,9 +262,11 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($tlds as $tld): ?> <?php foreach ($tlds as $tld): ?>
<tr class="hover:bg-gray-50 transition-colors duration-150"> <tr class="hover:bg-gray-50 transition-colors duration-150">
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<input type="checkbox" name="tld_ids[]" value="<?= $tld['id'] ?>" class="tld-checkbox rounded border-gray-300 text-primary focus:ring-primary"> <input type="checkbox" name="tld_ids[]" value="<?= $tld['id'] ?>" class="tld-checkbox rounded border-gray-300 text-primary focus:ring-primary">
</td> </td>
<?php endif; ?>
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center"> <div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
@@ -320,12 +334,14 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
<a href="/tld-registry/<?= $tld['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View"> <a href="/tld-registry/<?= $tld['id'] ?>" class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirm('Refresh TLD data from IANA?')"> <a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="text-green-600 hover:text-green-800" title="Refresh" onclick="return confirm('Refresh TLD data from IANA?')">
<i class="fas fa-sync-alt"></i> <i class="fas fa-sync-alt"></i>
</a> </a>
<a href="/tld-registry/<?= $tld['id'] ?>/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirm('Toggle TLD status?')"> <a href="/tld-registry/<?= $tld['id'] ?>/toggle-active" class="text-orange-600 hover:text-orange-800" title="Toggle Status" onclick="return confirm('Toggle TLD status?')">
<i class="fas fa-power-off"></i> <i class="fas fa-power-off"></i>
</a> </a>
<?php endif; ?>
</div> </div>
</td> </td>
</tr> </tr>
@@ -390,12 +406,14 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
</div> </div>
<div class="flex space-x-2 mt-3"> <div class="flex space-x-2 mt-3">
<a href="/tld-registry/<?= $tld['id'] ?>" class="flex-1 px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors"> <a href="/tld-registry/<?= $tld['id'] ?>" class="<?= (isset($_SESSION['role']) && $_SESSION['role'] === 'admin') ? 'flex-1' : 'w-full' ?> px-3 py-1.5 bg-blue-50 text-blue-600 rounded text-center text-sm hover:bg-blue-100 transition-colors">
<i class="fas fa-eye mr-1"></i> View <i class="fas fa-eye mr-1"></i> View
</a> </a>
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="flex-1 px-3 py-1.5 bg-green-50 text-green-600 rounded text-center text-sm hover:bg-green-100 transition-colors" onclick="return confirm('Refresh TLD data?')"> <a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="flex-1 px-3 py-1.5 bg-green-50 text-green-600 rounded text-center text-sm hover:bg-green-100 transition-colors" onclick="return confirm('Refresh TLD data?')">
<i class="fas fa-sync-alt mr-1"></i> Refresh <i class="fas fa-sync-alt mr-1"></i> Refresh
</a> </a>
<?php endif; ?>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>

View File

@@ -19,6 +19,7 @@ ob_start();
</span> </span>
</div> </div>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Refresh TLD data from IANA?')"> <a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" onclick="return confirm('Refresh TLD data from IANA?')">
<i class="fas fa-sync-alt mr-1.5"></i> <i class="fas fa-sync-alt mr-1.5"></i>
Refresh Refresh
@@ -27,6 +28,7 @@ ob_start();
<i class="fas fa-power-off mr-1.5"></i> <i class="fas fa-power-off mr-1.5"></i>
Toggle Toggle
</a> </a>
<?php endif; ?>
<a href="/tld-registry" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-gray-700 text-xs rounded-lg hover:bg-gray-50 transition-colors font-medium min-w-[80px] h-[32px]"> <a href="/tld-registry" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 text-gray-700 text-xs rounded-lg hover:bg-gray-50 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-arrow-left mr-1.5"></i> <i class="fas fa-arrow-left mr-1.5"></i>
Back Back
@@ -189,6 +191,7 @@ ob_start();
</h3> </h3>
</div> </div>
<div class="p-4 space-y-2"> <div class="p-4 space-y-2">
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
<a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="flex items-center p-3 border border-gray-200 hover:border-green-500 hover:bg-green-50 rounded-lg transition-all duration-200 group" onclick="return confirm('Refresh TLD data from IANA?')"> <a href="/tld-registry/<?= $tld['id'] ?>/refresh" class="flex items-center p-3 border border-gray-200 hover:border-green-500 hover:bg-green-50 rounded-lg transition-all duration-200 group" onclick="return confirm('Refresh TLD data from IANA?')">
<div class="w-9 h-9 bg-green-50 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 transition-colors duration-200"> <div class="w-9 h-9 bg-green-50 group-hover:bg-green-500 rounded-lg flex items-center justify-center group-hover:text-white text-green-600 transition-colors duration-200">
<i class="fas fa-sync-alt text-sm"></i> <i class="fas fa-sync-alt text-sm"></i>
@@ -201,6 +204,7 @@ ob_start();
</div> </div>
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-orange-700">Toggle Status</span> <span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-orange-700">Toggle Status</span>
</a> </a>
<?php endif; ?>
<?php if ($tld['registry_url']): ?> <?php if ($tld['registry_url']): ?>
<a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="flex items-center p-3 border border-gray-200 hover:border-blue-500 hover:bg-blue-50 rounded-lg transition-all duration-200 group"> <a href="<?= htmlspecialchars($tld['registry_url']) ?>" target="_blank" class="flex items-center p-3 border border-gray-200 hover:border-blue-500 hover:bg-blue-50 rounded-lg transition-all duration-200 group">
<div class="w-9 h-9 bg-blue-50 group-hover:bg-blue-500 rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 transition-colors duration-200"> <div class="w-9 h-9 bg-blue-50 group-hover:bg-blue-500 rounded-lg flex items-center justify-center group-hover:text-white text-blue-600 transition-colors duration-200">

100
app/Views/users/create.php Normal file
View File

@@ -0,0 +1,100 @@
<?php
$title = 'Create User';
$pageTitle = 'Create User';
$pageDescription = 'Add a new user to the system';
$pageIcon = 'fas fa-user-plus';
ob_start();
?>
<form method="POST" action="/users/store" class="max-w-2xl">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">User Information</h3>
</div>
<div class="p-6 space-y-4">
<!-- Full Name -->
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-2">
Full Name <span class="text-red-500">*</span>
</label>
<input type="text" id="full_name" name="full_name" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<!-- Username -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">
Username <span class="text-red-500">*</span>
</label>
<input type="text" id="username" name="username" required pattern="[a-zA-Z0-9_]+"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
<p class="text-xs text-gray-500 mt-1">Letters, numbers, and underscores only</p>
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
Email Address <span class="text-red-500">*</span>
</label>
<input type="email" id="email" name="email" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<!-- Role -->
<div>
<label for="role" class="block text-sm font-medium text-gray-700 mb-2">
Role <span class="text-red-500">*</span>
</label>
<select id="role" name="role" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<p class="text-xs text-gray-500 mt-1">Admins have full system access</p>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
Password <span class="text-red-500">*</span>
</label>
<input type="password" id="password" name="password" required minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
<p class="text-xs text-gray-500 mt-1">Minimum 8 characters</p>
</div>
<!-- Confirm Password -->
<div>
<label for="password_confirm" class="block text-sm font-medium text-gray-700 mb-2">
Confirm Password <span class="text-red-500">*</span>
</label>
<input type="password" id="password_confirm" name="password_confirm" required minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p class="text-xs text-blue-800">
<i class="fas fa-info-circle mr-1"></i>
<strong>Note:</strong> Admin-created users are automatically verified and can log in immediately.
</p>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
<a href="/users" class="text-gray-600 hover:text-gray-800 text-sm font-medium">
<i class="fas fa-arrow-left mr-1"></i> Cancel
</a>
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-save mr-2"></i>
Create User
</button>
</div>
</div>
</form>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layout/base.php';
?>

130
app/Views/users/edit.php Normal file
View File

@@ -0,0 +1,130 @@
<?php
$title = 'Edit User';
$pageTitle = 'Edit User';
$pageDescription = 'Update user information and permissions';
$pageIcon = 'fas fa-user-edit';
ob_start();
?>
<form method="POST" action="/users/update" class="max-w-2xl">
<input type="hidden" name="id" value="<?= $user['id'] ?>">
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
<h3 class="text-lg font-semibold text-gray-900">User Information</h3>
</div>
<div class="p-6 space-y-4">
<!-- Full Name -->
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-2">
Full Name <span class="text-red-500">*</span>
</label>
<input type="text" id="full_name" name="full_name" required
value="<?= htmlspecialchars($user['full_name'] ?? '') ?>"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<!-- Username (Read-only) -->
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-2">
Username
</label>
<input type="text" id="username" value="<?= htmlspecialchars($user['username']) ?>" readonly
class="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed">
<p class="text-xs text-gray-500 mt-1">Username cannot be changed</p>
</div>
<!-- Email -->
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
Email Address <span class="text-red-500">*</span>
</label>
<input type="email" id="email" name="email" required
value="<?= htmlspecialchars($user['email']) ?>"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
</div>
<!-- Role -->
<div>
<label for="role" class="block text-sm font-medium text-gray-700 mb-2">
Role <span class="text-red-500">*</span>
</label>
<select id="role" name="role" required
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
<option value="user" <?= $user['role'] === 'user' ? 'selected' : '' ?>>User</option>
<option value="admin" <?= $user['role'] === 'admin' ? 'selected' : '' ?>>Admin</option>
</select>
</div>
<!-- Status -->
<div class="flex items-start">
<div class="flex items-center h-5">
<input type="checkbox" id="is_active" name="is_active" value="1"
<?= $user['is_active'] ? 'checked' : '' ?>
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
</div>
<div class="ml-3">
<label for="is_active" class="text-sm font-medium text-gray-700">
Active
</label>
<p class="text-xs text-gray-500">Inactive users cannot log in</p>
</div>
</div>
<!-- Password (Optional) -->
<div class="border-t border-gray-200 pt-4 mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Change Password (Optional)</h4>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
New Password
</label>
<input type="password" id="password" name="password" minlength="8"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary">
<p class="text-xs text-gray-500 mt-1">Leave blank to keep current password. Minimum 8 characters if changing.</p>
</div>
</div>
<!-- Account Info -->
<div class="bg-gray-50 border border-gray-200 rounded-lg p-3 mt-4">
<div class="grid grid-cols-2 gap-3 text-xs">
<div>
<span class="text-gray-600">Email Verified:</span>
<span class="font-semibold <?= $user['email_verified'] ? 'text-green-600' : 'text-red-600' ?>">
<?= $user['email_verified'] ? 'Yes' : 'No' ?>
</span>
</div>
<div>
<span class="text-gray-600">Member Since:</span>
<span class="font-semibold text-gray-900">
<?= date('M d, Y', strtotime($user['created_at'])) ?>
</span>
</div>
<div>
<span class="text-gray-600">Last Login:</span>
<span class="font-semibold text-gray-900">
<?= $user['last_login'] ? date('M d, Y H:i', strtotime($user['last_login'])) : 'Never' ?>
</span>
</div>
</div>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-between">
<a href="/users" class="text-gray-600 hover:text-gray-800 text-sm font-medium">
<i class="fas fa-arrow-left mr-1"></i> Cancel
</a>
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-save mr-2"></i>
Update User
</button>
</div>
</div>
</form>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layout/base.php';
?>

355
app/Views/users/index.php Normal file
View File

@@ -0,0 +1,355 @@
<?php
$title = 'User Management';
$pageTitle = 'User Management';
$pageDescription = 'Manage system users and permissions';
$pageIcon = 'fas fa-users';
ob_start();
// Helper function to generate sort URL
function sortUrl($column, $currentSort, $currentOrder) {
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
$params = $_GET;
$params['sort'] = $column;
$params['order'] = $newOrder;
return '/users?' . http_build_query($params);
}
// Helper function for sort icon
function sortIcon($column, $currentSort, $currentOrder) {
if ($currentSort !== $column) {
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
}
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
}
// Get current filters
$currentFilters = $filters ?? ['search' => '', 'role' => '', 'status' => '', 'sort' => 'username', 'order' => 'asc'];
// Mock pagination for now (will need to be implemented in controller)
$pagination = $pagination ?? [
'current_page' => 1,
'total_pages' => 1,
'per_page' => 25,
'total' => count($users),
'showing_from' => 1,
'showing_to' => count($users)
];
?>
<!-- Action Buttons -->
<div class="mb-4 flex flex-wrap gap-2 justify-between items-center">
<div class="flex gap-2">
<!-- Placeholder for future bulk actions -->
</div>
<div class="flex gap-2">
<a href="/users/create" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-user-plus mr-2"></i>
Add User
</a>
</div>
</div>
<!-- Filters & Search -->
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
<form method="GET" action="/users" id="filter-form">
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<!-- Search -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Search</label>
<div class="relative">
<input type="text" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>" placeholder="Search users..." class="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 text-xs"></i>
</div>
</div>
<!-- Role Filter -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Role</label>
<select name="role" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">All Roles</option>
<option value="admin" <?= $currentFilters['role'] === 'admin' ? 'selected' : '' ?>>Admin</option>
<option value="user" <?= $currentFilters['role'] === 'user' ? 'selected' : '' ?>>User</option>
</select>
</div>
<!-- Status Filter -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
<select name="status" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="">All Statuses</option>
<option value="active" <?= $currentFilters['status'] === 'active' ? 'selected' : '' ?>>Active</option>
<option value="inactive" <?= $currentFilters['status'] === 'inactive' ? 'selected' : '' ?>>Inactive</option>
</select>
</div>
<!-- Apply/Reset Buttons -->
<div class="flex items-end space-x-2">
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
<i class="fas fa-filter mr-2"></i>
Apply Filters
</button>
<a href="/users" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
<i class="fas fa-times mr-2"></i>
Clear
</a>
</div>
</div>
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
</form>
</div>
<!-- Pagination Info & Per Page Selector -->
<div class="mb-4 flex justify-between items-center">
<div class="text-sm text-gray-600">
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> user(s)
</div>
<form method="GET" action="/users" class="flex items-center gap-2">
<!-- Preserve current filters -->
<input type="hidden" name="search" value="<?= htmlspecialchars($currentFilters['search']) ?>">
<input type="hidden" name="role" value="<?= htmlspecialchars($currentFilters['role']) ?>">
<input type="hidden" name="status" value="<?= htmlspecialchars($currentFilters['status']) ?>">
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
<label for="per_page" class="text-sm text-gray-600">Show:</label>
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-primary focus:border-primary">
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
</select>
</form>
</div>
<!-- Users Table -->
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<?php if (!empty($users)): ?>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('full_name', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
User <?= sortIcon('full_name', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('username', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Username <?= sortIcon('username', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('role', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Role <?= sortIcon('role', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('is_active', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Status <?= sortIcon('is_active', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('email_verified', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Email Verified <?= sortIcon('email_verified', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
<a href="<?= sortUrl('last_login', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
Last Login <?= sortIcon('last_login', $currentFilters['sort'], $currentFilters['order']) ?>
</a>
</th>
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<?php foreach ($users as $user): ?>
<tr class="hover:bg-gray-50 transition-colors duration-150">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
<span class="text-primary font-semibold text-sm">
<?= strtoupper(substr($user['username'], 0, 1)) ?>
</span>
</div>
<div class="ml-4">
<div class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($user['full_name'] ?? 'N/A') ?></div>
<div class="text-xs text-gray-500"><?= htmlspecialchars($user['email']) ?></div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900"><?= htmlspecialchars($user['username']) ?></div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
<?= $user['role'] === 'admin' ? 'bg-purple-100 text-purple-700 border-purple-200' : 'bg-blue-100 text-blue-700 border-blue-200' ?>">
<i class="fas fa-<?= $user['role'] === 'admin' ? 'crown' : 'user' ?> mr-1"></i>
<?= ucfirst($user['role']) ?>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
<?= $user['is_active'] ? 'bg-green-100 text-green-700 border-green-200' : 'bg-red-100 text-red-700 border-red-200' ?>">
<i class="fas fa-<?= $user['is_active'] ? 'check-circle' : 'times-circle' ?> mr-1"></i>
<?= $user['is_active'] ? 'Active' : 'Inactive' ?>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<?php if ($user['email_verified']): ?>
<i class="fas fa-check-circle text-green-500 mr-2"></i>
<span class="text-sm text-gray-900">Verified</span>
<?php else: ?>
<i class="fas fa-times-circle text-red-500 mr-2"></i>
<span class="text-sm text-gray-500">Not Verified</span>
<?php endif; ?>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<?php if ($user['last_login']): ?>
<div class="flex items-center">
<i class="far fa-clock mr-2"></i>
<?= date('M d, H:i', strtotime($user['last_login'])) ?>
</div>
<?php else: ?>
<span class="text-gray-400">Never</span>
<?php endif; ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end space-x-2">
<a href="/users/edit?id=<?= $user['id'] ?>" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
<?php if ($user['id'] != $_SESSION['user_id']): ?>
<a href="/users/toggle-status?id=<?= $user['id'] ?>"
class="text-orange-600 hover:text-orange-800"
title="<?= $user['is_active'] ? 'Deactivate' : 'Activate' ?>">
<i class="fas fa-<?= $user['is_active'] ? 'user-slash' : 'user-check' ?>"></i>
</a>
<a href="/users/delete?id=<?= $user['id'] ?>"
class="text-red-600 hover:text-red-800"
title="Delete"
onclick="return confirm('Are you sure you want to delete this user?')">
<i class="fas fa-trash"></i>
</a>
<?php else: ?>
<span class="text-gray-400" title="Cannot modify your own account">
<i class="fas fa-lock"></i>
</span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<!-- Empty State -->
<div class="p-12 text-center">
<i class="fas fa-users text-gray-300 text-6xl mb-4"></i>
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Users Yet</h3>
<p class="text-sm text-gray-500 mb-4">Start by adding your first user</p>
<a href="/users/create" class="inline-flex items-center px-5 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-user-plus mr-2"></i>
Add Your First User
</a>
</div>
<?php endif; ?>
</div>
<!-- Pagination Controls -->
<?php if ($pagination['total_pages'] > 1): ?>
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<!-- Page Info -->
<div class="text-sm text-gray-600">
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
</div>
<!-- Pagination Buttons -->
<div class="flex items-center gap-1">
<?php
// Helper function to build pagination URL
function paginationUrl($page, $filters, $perPage) {
$params = $filters;
$params['page'] = $page;
$params['per_page'] = $perPage;
return '/users?' . http_build_query($params);
}
$currentPage = $pagination['current_page'];
$totalPages = $pagination['total_pages'];
?>
<!-- First Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl(1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-left"></i>
</a>
<?php endif; ?>
<!-- Previous Page -->
<?php if ($currentPage > 1): ?>
<a href="<?= paginationUrl($currentPage - 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-left"></i> Previous
</a>
<?php endif; ?>
<!-- Page Numbers -->
<?php
$range = 2;
$start = max(1, $currentPage - $range);
$end = min($totalPages, $currentPage + $range);
if ($start > 1) {
echo '<a href="' . paginationUrl(1, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">1</a>';
if ($start > 2) {
echo '<span class="px-2 text-gray-500">...</span>';
}
}
for ($i = $start; $i <= $end; $i++) {
if ($i == $currentPage) {
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
} else {
echo '<a href="' . paginationUrl($i, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $i . '</a>';
}
}
if ($end < $totalPages) {
if ($end < $totalPages - 1) {
echo '<span class="px-2 text-gray-500">...</span>';
}
echo '<a href="' . paginationUrl($totalPages, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">' . $totalPages . '</a>';
}
?>
<!-- Next Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($currentPage + 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
Next <i class="fas fa-angle-right"></i>
</a>
<?php endif; ?>
<!-- Last Page -->
<?php if ($currentPage < $totalPages): ?>
<a href="<?= paginationUrl($totalPages, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<i class="fas fa-angle-double-right"></i>
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<?php
$content = ob_get_clean();
require __DIR__ . '/../layout/base.php';
?>

View File

@@ -44,10 +44,24 @@ class Auth
// Get current path // Get current path
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
// Don't redirect if already on login page or logout // Public paths that don't require authentication
if ($currentPath === '/login' || $currentPath === '/logout') { $publicPaths = [
'/login',
'/logout',
'/register',
'/forgot-password',
'/reset-password',
'/verify-email',
'/resend-verification',
'/install'
];
// Don't redirect if on a public path
foreach ($publicPaths as $path) {
if (strpos($currentPath, $path) === 0) {
return; return;
} }
}
if (!self::check()) { if (!self::check()) {
$_SESSION['error'] = 'Please login to continue'; $_SESSION['error'] = 'Please login to continue';

View File

@@ -0,0 +1,200 @@
<?php
namespace Core;
use SessionHandlerInterface;
use PDO;
/**
* Database Session Handler
*
* Stores PHP sessions in database with geolocation tracking.
* Provides true session management where deleting a session actually logs out the user.
*/
class DatabaseSessionHandler implements SessionHandlerInterface
{
private PDO $db;
private int $lifetime;
public function __construct(int $lifetime = 1440)
{
$this->db = Database::getConnection();
$this->lifetime = $lifetime;
}
/**
* Open session
*/
public function open(string $path, string $name): bool
{
return true;
}
/**
* Close session
*/
public function close(): bool
{
return true;
}
/**
* Read session data
*/
public function read(string $id): string|false
{
try {
$stmt = $this->db->prepare(
"SELECT payload FROM sessions WHERE id = ? AND last_activity > ?"
);
$stmt->execute([$id, time() - ($this->lifetime * 60)]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result) {
// Update last activity
$this->updateActivity($id);
return $result['payload'];
}
return '';
} catch (\Exception $e) {
error_log("Session read failed: " . $e->getMessage());
return '';
}
}
/**
* Write session data
*/
public function write(string $id, string $data): bool
{
try {
// Extract user_id from session data
$sessionData = $this->unserializeSession($data);
$userId = $sessionData['user_id'] ?? null;
// Get IP and user agent
$ipAddress = $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1';
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
// Check if session exists
$stmt = $this->db->prepare("SELECT id, country FROM sessions WHERE id = ?");
$stmt->execute([$id]);
$existing = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existing) {
// Update existing session
$stmt = $this->db->prepare(
"UPDATE sessions SET payload = ?, last_activity = ?, user_id = ? WHERE id = ?"
);
return $stmt->execute([$data, time(), $userId, $id]);
} else {
// New session - get geolocation data
$geoData = \App\Models\SessionManager::getGeolocationData($ipAddress);
// Insert new session
$stmt = $this->db->prepare(
"INSERT INTO sessions (id, user_id, ip_address, user_agent, country, country_code, region, city, isp, timezone, payload, last_activity, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
$currentTime = time();
return $stmt->execute([
$id,
$userId,
$ipAddress,
$userAgent,
$geoData['country'],
$geoData['country_code'],
$geoData['region'],
$geoData['city'],
$geoData['isp'],
$geoData['timezone'],
$data,
$currentTime,
$currentTime // created_at = same as last_activity initially
]);
}
} catch (\Exception $e) {
error_log("Session write failed: " . $e->getMessage());
return false;
}
}
/**
* Destroy session
*/
public function destroy(string $id): bool
{
try {
$stmt = $this->db->prepare("DELETE FROM sessions WHERE id = ?");
return $stmt->execute([$id]);
} catch (\Exception $e) {
error_log("Session destroy failed: " . $e->getMessage());
return false;
}
}
/**
* Garbage collection (cleanup old sessions)
*/
public function gc(int $max_lifetime): int|false
{
try {
$stmt = $this->db->prepare(
"DELETE FROM sessions WHERE last_activity < ?"
);
$stmt->execute([time() - ($this->lifetime * 60)]);
return $stmt->rowCount();
} catch (\Exception $e) {
error_log("Session GC failed: " . $e->getMessage());
return 0;
}
}
/**
* Update session activity timestamp
*/
private function updateActivity(string $id): void
{
try {
$stmt = $this->db->prepare(
"UPDATE sessions SET last_activity = ? WHERE id = ?"
);
$stmt->execute([time(), $id]);
} catch (\Exception $e) {
// Silent fail
}
}
/**
* Unserialize session data to extract variables
*/
private function unserializeSession(string $data): array
{
$result = [];
$offset = 0;
while ($offset < strlen($data)) {
// Parse key
if (!preg_match('/(\w+)\|/', substr($data, $offset), $match)) {
break;
}
$key = $match[1];
$offset += strlen($match[0]);
// Parse value
$value = @unserialize(substr($data, $offset));
if ($value === false && substr($data, $offset, 5) !== 'b:0;') {
break;
}
$result[$key] = $value;
$offset += strlen(serialize($value));
}
return $result;
}
}

58
core/SessionValidator.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
namespace Core;
/**
* Session Validator Middleware
*
* Validates that the current session exists in database.
* If session was deleted (logged out remotely), forces re-login.
*/
class SessionValidator
{
/**
* Validate current session against database
* If session doesn't exist in DB, destroy it and force login
*/
public static function validate(): void
{
// Skip if not logged in
if (!isset($_SESSION['user_id'])) {
return;
}
try {
$sessionId = session_id();
$pdo = Database::getConnection();
// Check if this session exists in database
$stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE id = ?");
$stmt->execute([$sessionId]);
$result = $stmt->fetch(\PDO::FETCH_ASSOC);
// If session not found in DB, it was deleted remotely
if (!$result) {
// Session was deleted - logout this user
session_destroy();
session_start();
$_SESSION['error'] = 'Your session was terminated remotely. Please login again.';
header('Location: /login');
exit;
}
// If session exists but user_id doesn't match, something is wrong
if ($result['user_id'] != $_SESSION['user_id']) {
session_destroy();
session_start();
$_SESSION['error'] = 'Session validation failed. Please login again.';
header('Location: /login');
exit;
}
} catch (\Exception $e) {
// If sessions table doesn't exist, allow normal operation (graceful fallback)
error_log("Session validation failed: " . $e->getMessage());
}
}
}

View File

@@ -1,155 +0,0 @@
#!/usr/bin/env php
<?php
require_once __DIR__ . '/../vendor/autoload.php';
use Dotenv\Dotenv;
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
try {
// Check if encryption key is set, if not generate and save it
if (empty($_ENV['APP_ENCRYPTION_KEY'])) {
echo "🔑 Generating encryption key...\n";
// Generate a secure 32-byte (256-bit) key
$encryptionKey = base64_encode(random_bytes(32));
// Path to .env file
$envFile = __DIR__ . '/../.env';
if (!file_exists($envFile)) {
echo "✗ Error: .env file not found. Please create it first.\n";
exit(1);
}
// Read current .env content
$envContent = file_get_contents($envFile);
// Check if APP_ENCRYPTION_KEY line exists
if (strpos($envContent, 'APP_ENCRYPTION_KEY=') !== false) {
// Replace empty value with generated key
$envContent = preg_replace(
'/APP_ENCRYPTION_KEY=.*$/m',
"APP_ENCRYPTION_KEY=$encryptionKey",
$envContent
);
} else {
// Append the key to the file
$envContent .= "\nAPP_ENCRYPTION_KEY=$encryptionKey\n";
}
// Write updated content back to .env
if (!file_put_contents($envFile, $envContent)) {
echo "✗ Error: Could not write to .env file.\n";
exit(1);
}
// Reload environment variables
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();
echo "✓ Encryption key generated and saved to .env\n";
echo " Key: $encryptionKey\n";
echo " ⚠️ Keep this key secret and backup securely!\n\n";
}
$host = $_ENV['DB_HOST'];
$port = $_ENV['DB_PORT'];
$database = $_ENV['DB_DATABASE'];
$username = $_ENV['DB_USERNAME'];
$password = $_ENV['DB_PASSWORD'];
// Connect to database
$dsn = "mysql:host=$host;port=$port;dbname=$database;charset=utf8mb4";
$pdo = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
echo "Connected to database successfully!\n\n";
// Generate random admin password
$adminPassword = bin2hex(random_bytes(8)); // 16 character random password
$adminPasswordHash = password_hash($adminPassword, PASSWORD_BCRYPT);
// Get all migration files
$migrationFiles = [
__DIR__ . '/migrations/001_create_tables.sql',
__DIR__ . '/migrations/002_create_users_table.sql',
__DIR__ . '/migrations/003_add_whois_fields.sql',
__DIR__ . '/migrations/004_create_tld_registry_table.sql',
__DIR__ . '/migrations/005_update_tld_import_logs.sql',
__DIR__ . '/migrations/006_add_complete_workflow_import_type.sql',
__DIR__ . '/migrations/007_add_app_and_email_settings.sql',
__DIR__ . '/migrations/008_add_notes_to_domains.sql',
];
foreach ($migrationFiles as $migrationFile) {
if (!file_exists($migrationFile)) {
echo "⚠ Migration file not found: " . basename($migrationFile) . "\n";
continue;
}
echo "Running migration: " . basename($migrationFile) . "\n";
$sql = file_get_contents($migrationFile);
// Replace password placeholder in users migration
if (basename($migrationFile) === '002_create_users_table.sql') {
$sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $adminPasswordHash, $sql);
}
// Split by semicolon and execute each statement
$statements = array_filter(array_map('trim', explode(';', $sql)));
foreach ($statements as $statement) {
if (!empty($statement)) {
try {
$pdo->exec($statement);
} catch (PDOException $e) {
// Check if it's a "column already exists" error for migrations 003, 005, and 008
if (strpos($e->getMessage(), 'Duplicate column name') !== false &&
(basename($migrationFile) === '003_add_whois_fields.sql' ||
basename($migrationFile) === '005_update_tld_import_logs.sql' ||
basename($migrationFile) === '008_add_notes_to_domains.sql')) {
echo " ⚠ Column already exists, skipping: " . $e->getMessage() . "\n";
continue;
}
// Check if it's an enum modification error for migrations 005 and 006
if (strpos($e->getMessage(), 'Duplicate entry') !== false &&
(basename($migrationFile) === '005_update_tld_import_logs.sql' ||
basename($migrationFile) === '006_add_complete_workflow_import_type.sql')) {
echo " ⚠ Enum already updated, skipping: " . $e->getMessage() . "\n";
continue;
}
// Re-throw other errors
throw $e;
}
}
}
echo "" . basename($migrationFile) . " completed\n";
}
echo "\n✓ All migrations completed successfully!\n";
echo "✓ All tables created.\n";
echo "\n🔑 Admin credentials (SAVE THESE!):\n";
echo " ═══════════════════════════════════════\n";
echo " Username: admin\n";
echo " Password: $adminPassword\n";
echo " ═══════════════════════════════════════\n";
echo " ⚠️ This password will not be shown again!\n";
echo " 💾 Save it to a secure password manager.\n\n";
echo "🌐 TLD Registry System:\n";
echo " • Import RDAP data: php cron/import_tld_registry.php --rdap-only\n";
echo " • Import WHOIS data: php cron/import_tld_registry.php --whois-only\n";
echo " • Check for updates: php cron/import_tld_registry.php --check-updates\n";
echo " • Full import: php cron/import_tld_registry.php\n\n";
} catch (PDOException $e) {
echo "✗ Migration failed: " . $e->getMessage() . "\n";
exit(1);
}

View File

@@ -0,0 +1,270 @@
-- Domain Monitor v1.1.0 - Complete Initial Schema
-- This consolidated migration includes all features for fresh installations
-- =====================================================
-- CORE TABLES
-- =====================================================
-- Domains table
CREATE TABLE IF NOT EXISTS domains (
id INT AUTO_INCREMENT PRIMARY KEY,
domain_name VARCHAR(255) NOT NULL UNIQUE,
notification_group_id INT NULL,
registrar VARCHAR(255),
registrar_url VARCHAR(255),
expiration_date DATE,
updated_date DATE,
abuse_email VARCHAR(255),
last_checked TIMESTAMP NULL,
status ENUM('active', 'expiring_soon', 'expired', 'error', 'available') DEFAULT 'active',
whois_data JSON,
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (notification_group_id) REFERENCES notification_groups(id) ON DELETE SET NULL,
INDEX idx_notification_group_id (notification_group_id),
INDEX idx_domain_name (domain_name),
INDEX idx_expiration_date (expiration_date),
INDEX idx_status (status),
INDEX idx_is_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Notification groups table
CREATE TABLE IF NOT EXISTS notification_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Notification channels table
CREATE TABLE IF NOT EXISTS notification_channels (
id INT AUTO_INCREMENT PRIMARY KEY,
notification_group_id INT NOT NULL,
channel_type ENUM('email', 'telegram', 'discord', 'slack') NOT NULL,
channel_config JSON NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (notification_group_id) REFERENCES notification_groups(id) ON DELETE CASCADE,
INDEX idx_group_id (notification_group_id),
INDEX idx_channel_type (channel_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Notification logs table
CREATE TABLE IF NOT EXISTS notification_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
domain_id INT NOT NULL,
notification_type VARCHAR(50) NOT NULL,
channel_type VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status ENUM('sent', 'failed') DEFAULT 'sent',
error_message TEXT,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE,
INDEX idx_domain_id (domain_id),
INDEX idx_sent_at (sent_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =====================================================
-- USER MANAGEMENT & AUTHENTICATION
-- =====================================================
-- Users table
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
email VARCHAR(255),
email_verified BOOLEAN DEFAULT FALSE,
email_verification_token VARCHAR(255) NULL,
email_verification_sent_at TIMESTAMP NULL,
full_name VARCHAR(255),
role VARCHAR(50) DEFAULT 'user',
is_active BOOLEAN DEFAULT TRUE,
last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_email (email),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default admin user (password will be set during installation)
INSERT INTO users (username, password, email, full_name, is_active, role, email_verified) VALUES
('admin', '{{ADMIN_PASSWORD_HASH}}', 'admin@domainmonitor.local', 'Administrator', 1, 'admin', 1)
ON DUPLICATE KEY UPDATE username=username;
-- Password reset tokens table
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Sessions table (database-backed sessions)
CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id INT DEFAULT NULL,
ip_address VARCHAR(45) NOT NULL,
user_agent TEXT,
country VARCHAR(100) DEFAULT NULL,
country_code VARCHAR(2) DEFAULT NULL,
region VARCHAR(100) DEFAULT NULL,
city VARCHAR(100) DEFAULT NULL,
isp VARCHAR(255) DEFAULT NULL,
timezone VARCHAR(50) DEFAULT NULL,
payload MEDIUMTEXT NOT NULL,
last_activity INT UNSIGNED NOT NULL,
created_at INT UNSIGNED NOT NULL,
INDEX idx_user_id (user_id),
INDEX idx_last_activity (last_activity),
INDEX idx_created_at (created_at),
CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Remember me tokens table
CREATE TABLE IF NOT EXISTS remember_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
session_id VARCHAR(128) DEFAULT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user_id (user_id),
INDEX idx_session_id (session_id),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- User notifications table (in-app notifications)
CREATE TABLE IF NOT EXISTS user_notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
domain_id INT NULL,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE SET NULL,
INDEX idx_user_id (user_id),
INDEX idx_is_read (is_read),
INDEX idx_created_at (created_at),
INDEX idx_type (type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =====================================================
-- TLD REGISTRY SYSTEM
-- =====================================================
-- TLD registry table
CREATE TABLE IF NOT EXISTS tld_registry (
id INT AUTO_INCREMENT PRIMARY KEY,
tld VARCHAR(63) NOT NULL UNIQUE,
rdap_servers JSON,
whois_server VARCHAR(255),
registry_url VARCHAR(500),
iana_publication_date TIMESTAMP NULL,
iana_last_updated TIMESTAMP NULL,
record_last_updated TIMESTAMP NULL,
registration_date DATE NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_tld (tld),
INDEX idx_is_active (is_active),
INDEX idx_iana_publication_date (iana_publication_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- TLD import logs table
CREATE TABLE IF NOT EXISTS tld_import_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
import_type ENUM('tld_list', 'rdap', 'whois', 'manual', 'complete_workflow', 'check_updates') NOT NULL,
total_tlds INT DEFAULT 0,
new_tlds INT DEFAULT 0,
updated_tlds INT DEFAULT 0,
failed_tlds INT DEFAULT 0,
iana_publication_date TIMESTAMP NULL,
version VARCHAR(50) NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
status ENUM('running', 'completed', 'failed') DEFAULT 'running',
error_message TEXT,
details JSON,
INDEX idx_started_at (started_at),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- =====================================================
-- SYSTEM SETTINGS
-- =====================================================
-- Settings table
CREATE TABLE IF NOT EXISTS settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(255) NOT NULL UNIQUE,
setting_value TEXT,
`type` VARCHAR(50) DEFAULT 'string',
`description` TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default settings
INSERT INTO settings (setting_key, setting_value, `type`, `description`) VALUES
-- Application settings
('app_name', 'Domain Monitor', 'string', 'Application name'),
('app_url', 'http://localhost:8000', 'string', 'Application URL'),
('app_timezone', 'UTC', 'string', 'Application timezone'),
('app_version', '1.1.0', 'string', 'Application version number'),
-- Email settings
('mail_host', 'smtp.mailtrap.io', 'string', 'SMTP server host'),
('mail_port', '2525', 'string', 'SMTP server port'),
('mail_username', '', 'string', 'SMTP username'),
('mail_password', '', 'encrypted', 'SMTP password (encrypted)'),
('mail_encryption', 'tls', 'string', 'SMTP encryption (tls/ssl)'),
('mail_from_address', 'noreply@domainmonitor.com', 'string', 'From email address'),
('mail_from_name', 'Domain Monitor', 'string', 'From name'),
-- Monitoring settings
('notification_days_before', '60,30,21,14,7,5,3,2,1', 'string', 'Notification days before expiration'),
('check_interval_hours', '24', 'string', 'Domain check interval in hours'),
('last_check_run', NULL, 'datetime', 'Last time cron job ran'),
-- Authentication settings
('registration_enabled', '0', 'boolean', 'Enable user registration'),
('require_email_verification', '1', 'boolean', 'Require email verification for new users')
ON DUPLICATE KEY UPDATE setting_key=setting_key;
-- =====================================================
-- MIGRATION TRACKING
-- =====================================================
-- Migrations tracking table
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 COLLATE=utf8mb4_unicode_ci;
-- Mark this consolidated migration as executed
INSERT INTO migrations (migration) VALUES ('000_initial_schema_v1.1.0.sql')
ON DUPLICATE KEY UPDATE migration=migration;

View File

@@ -14,8 +14,8 @@ CREATE TABLE IF NOT EXISTS users (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert default admin user -- Insert default admin user
-- Password is randomly generated during migration and displayed in output -- Password is randomly generated during installation and displayed in output
-- Hash placeholder will be replaced by migrate.php -- Hash placeholder will be replaced by web installer
INSERT INTO users (username, password, email, full_name, is_active) VALUES INSERT INTO users (username, password, email, full_name, is_active) VALUES
('admin', '{{ADMIN_PASSWORD_HASH}}', 'admin@domainmonitor.local', 'Administrator', 1) ('admin', '{{ADMIN_PASSWORD_HASH}}', 'admin@domainmonitor.local', 'Administrator', 1)
ON DUPLICATE KEY UPDATE username=username; ON DUPLICATE KEY UPDATE username=username;

View File

@@ -0,0 +1,49 @@
-- Add authentication features
-- Email verification and password reset tokens
-- Add email verification fields to users table
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN DEFAULT FALSE AFTER email,
ADD COLUMN email_verification_token VARCHAR(255) NULL AFTER email_verified,
ADD COLUMN email_verification_sent_at TIMESTAMP NULL AFTER email_verification_token;
-- Create password reset tokens table
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
used BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Create remember me tokens table
CREATE TABLE IF NOT EXISTS remember_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Add role field to users for future multi-user support
ALTER TABLE users
ADD COLUMN role VARCHAR(50) DEFAULT 'user' AFTER full_name;
-- Update existing admin user to have admin role
UPDATE users SET role = 'admin' WHERE username = 'admin';
-- Add settings for registration
INSERT INTO settings (setting_key, setting_value) VALUES
('registration_enabled', '0'),
('require_email_verification', '1')
ON DUPLICATE KEY UPDATE setting_key=setting_key;

View File

@@ -0,0 +1,5 @@
-- Add application version to settings
INSERT INTO settings (setting_key, setting_value) VALUES
('app_version', '1.1.0')
ON DUPLICATE KEY UPDATE setting_key=setting_key;

View File

@@ -0,0 +1,21 @@
-- Create new sessions table compatible with PHP session handler
CREATE TABLE `sessions` (
`id` VARCHAR(128) NOT NULL PRIMARY KEY,
`user_id` INT DEFAULT NULL,
`ip_address` VARCHAR(45) NOT NULL,
`user_agent` TEXT,
`country` VARCHAR(100) DEFAULT NULL,
`country_code` VARCHAR(2) DEFAULT NULL,
`region` VARCHAR(100) DEFAULT NULL,
`city` VARCHAR(100) DEFAULT NULL,
`isp` VARCHAR(255) DEFAULT NULL,
`timezone` VARCHAR(50) DEFAULT NULL,
`payload` MEDIUMTEXT NOT NULL,
`last_activity` INT UNSIGNED NOT NULL,
`created_at` INT UNSIGNED NOT NULL,
INDEX `idx_user_id` (`user_id`),
INDEX `idx_last_activity` (`last_activity`),
INDEX `idx_created_at` (`created_at`),
CONSTRAINT `fk_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,7 @@
-- Link remember tokens to sessions
-- This ensures deleting a session also invalidates the remember token
-- Add session_id column to remember_tokens
ALTER TABLE `remember_tokens`
ADD COLUMN `session_id` VARCHAR(128) DEFAULT NULL AFTER `user_id`,
ADD INDEX `idx_session_id` (`session_id`);

View File

@@ -0,0 +1,19 @@
-- Create user_notifications table for in-app notifications
CREATE TABLE IF NOT EXISTS `user_notifications` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`user_id` INT NOT NULL,
`type` VARCHAR(50) NOT NULL,
`title` VARCHAR(255) NOT NULL,
`message` TEXT NOT NULL,
`domain_id` INT NULL,
`is_read` BOOLEAN DEFAULT FALSE,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`read_at` TIMESTAMP NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
FOREIGN KEY (`domain_id`) REFERENCES `domains`(`id`) ON DELETE SET NULL,
INDEX `idx_user_id` (`user_id`),
INDEX `idx_is_read` (`is_read`),
INDEX `idx_created_at` (`created_at`),
INDEX `idx_type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,36 @@
# Database Migrations
## Fresh Installation (v1.1.0+)
For new installations, use the consolidated schema:
- **`000_initial_schema_v1.1.0.sql`** - Complete database schema for v1.1.0
**Install via:** Web installer at `/install`
## Incremental Migrations (v1.0.0 → v1.1.0)
If upgrading from v1.0.0, these incremental migrations will be applied:
- `001_create_tables.sql` - Core tables (domains, groups, channels, logs)
- `002_create_users_table.sql` - Users table
- `003_add_whois_fields.sql` - WHOIS data fields
- `004_create_tld_registry_table.sql` - TLD registry
- `005_update_tld_import_logs.sql` - Import logs updates
- `006_add_complete_workflow_import_type.sql` - Workflow import type
- `007_add_app_and_email_settings.sql` - Application settings
- `008_add_notes_to_domains.sql` - Domain notes field
- `009_add_authentication_features.sql` - Authentication system
- `010_add_app_version_setting.sql` - Version setting
**Upgrade via:** Web updater at `/install/update`
## Migration System
The installer automatically:
- Detects if this is a fresh install or upgrade
- Uses consolidated schema for fresh installs
- Uses incremental migrations for upgrades
- Tracks executed migrations in `migrations` table
- Prevents re-running completed migrations

View File

@@ -12,9 +12,54 @@ define('PATH_ROOT', __DIR__ . '/../');
$dotenv = Dotenv::createImmutable(__DIR__ . '/..'); $dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load(); $dotenv->load();
// Configure database session handler
try {
// Only use database sessions if sessions table exists
$pdo = new PDO(
"mysql:host={$_ENV['DB_HOST']};dbname={$_ENV['DB_DATABASE']}",
$_ENV['DB_USERNAME'],
$_ENV['DB_PASSWORD']
);
// Check if sessions table exists
$stmt = $pdo->query("SHOW TABLES LIKE 'sessions'");
if ($stmt->rowCount() > 0) {
// Use database session handler
$sessionLifetime = (int)($_ENV['SESSION_LIFETIME'] ?? 1440);
$handler = new Core\DatabaseSessionHandler($sessionLifetime);
session_set_save_handler($handler, true);
}
} catch (\Exception $e) {
// Fall back to default file-based sessions
error_log("Database session handler not available, using file sessions: " . $e->getMessage());
}
// Start session // Start session
session_start(); session_start();
// Validate session exists in database (for database-backed sessions)
// This ensures deleted sessions are immediately invalidated
Core\SessionValidator::validate();
// Check if system is installed (using flag file - no DB queries!)
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$isInstallerPath = strpos($currentPath, '/install') === 0;
$installedFlagFile = __DIR__ . '/../.installed';
if (!$isInstallerPath) {
// Check if .installed flag file exists
if (!file_exists($installedFlagFile)) {
header('Location: /install');
exit;
}
}
// Check remember me token if user is not logged in
if (!isset($_SESSION['user_id']) && isset($_COOKIE['remember_token']) && !$isInstallerPath) {
$authController = new \App\Controllers\AuthController();
$authController->checkRememberToken();
}
// Initialize application // Initialize application
$app = new Application(); $app = new Application();

View File

@@ -10,13 +10,33 @@ use App\Controllers\DebugController;
use App\Controllers\SearchController; use App\Controllers\SearchController;
use App\Controllers\TldRegistryController; use App\Controllers\TldRegistryController;
use App\Controllers\SettingsController; use App\Controllers\SettingsController;
use App\Controllers\ProfileController;
use App\Controllers\UserController;
use App\Controllers\InstallerController;
use App\Controllers\NotificationController;
$router = Application::$router; $router = Application::$router;
// Installer routes (public - before auth)
$router->get('/install', [InstallerController::class, 'index']);
$router->get('/install/check-database', [InstallerController::class, 'checkDatabase']);
$router->post('/install/run', [InstallerController::class, 'install']);
$router->get('/install/complete', [InstallerController::class, 'complete']);
$router->get('/install/update', [InstallerController::class, 'showUpdate']);
$router->post('/install/update', [InstallerController::class, 'runUpdate']);
// Authentication routes (public) // Authentication routes (public)
$router->get('/login', [AuthController::class, 'showLogin']); $router->get('/login', [AuthController::class, 'showLogin']);
$router->post('/login', [AuthController::class, 'login']); $router->post('/login', [AuthController::class, 'login']);
$router->get('/logout', [AuthController::class, 'logout']); $router->get('/logout', [AuthController::class, 'logout']);
$router->get('/register', [AuthController::class, 'showRegister']);
$router->post('/register', [AuthController::class, 'register']);
$router->get('/verify-email', [AuthController::class, 'showVerifyEmail']);
$router->get('/resend-verification', [AuthController::class, 'resendVerification']);
$router->get('/forgot-password', [AuthController::class, 'showForgotPassword']);
$router->post('/forgot-password', [AuthController::class, 'forgotPassword']);
$router->get('/reset-password', [AuthController::class, 'showResetPassword']);
$router->post('/reset-password', [AuthController::class, 'resetPassword']);
// Debug route (public - remove in production!) // Debug route (public - remove in production!)
$router->get('/debug/whois', [DebugController::class, 'whois']); $router->get('/debug/whois', [DebugController::class, 'whois']);
@@ -87,3 +107,30 @@ $router->post('/settings/test-email', [SettingsController::class, 'testEmail']);
$router->post('/settings/test-cron', [SettingsController::class, 'testCron']); $router->post('/settings/test-cron', [SettingsController::class, 'testCron']);
$router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']); $router->post('/settings/clear-logs', [SettingsController::class, 'clearLogs']);
// Profile
$router->get('/profile', [ProfileController::class, 'index']);
$router->post('/profile/update', [ProfileController::class, 'update']);
$router->post('/profile/change-password', [ProfileController::class, 'changePassword']);
$router->get('/profile/delete', [ProfileController::class, 'delete']);
$router->get('/profile/resend-verification', [ProfileController::class, 'resendVerification']);
$router->post('/profile/logout-other-sessions', [ProfileController::class, 'logoutOtherSessions']);
$router->post('/profile/logout-session/{sessionId}', [ProfileController::class, 'logoutSession']);
// Notifications
$router->get('/notifications', [NotificationController::class, 'index']);
$router->get('/notifications/{id}/mark-read', [NotificationController::class, 'markAsRead']);
$router->get('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead']);
$router->get('/notifications/{id}/delete', [NotificationController::class, 'delete']);
$router->get('/notifications/clear-all', [NotificationController::class, 'clearAll']);
$router->get('/api/notifications/unread-count', [NotificationController::class, 'getUnreadCount']);
$router->get('/api/notifications/recent', [NotificationController::class, 'getRecent']);
// User Management (Admin Only)
$router->get('/users', [UserController::class, 'index']);
$router->get('/users/create', [UserController::class, 'create']);
$router->post('/users/store', [UserController::class, 'store']);
$router->get('/users/edit', [UserController::class, 'edit']);
$router->post('/users/update', [UserController::class, 'update']);
$router->get('/users/delete', [UserController::class, 'delete']);
$router->get('/users/toggle-status', [UserController::class, 'toggleStatus']);