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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
.installed
|
||||||
|
|
||||||
# Composer
|
# Composer
|
||||||
/vendor/
|
/vendor/
|
||||||
|
|||||||
211
CHANGELOG.md
211
CHANGELOG.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
162
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']
|
||||||
]);
|
]);
|
||||||
|
|||||||
423
app/Controllers/InstallerController.php
Normal file
423
app/Controllers/InstallerController.php
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Core\Controller;
|
||||||
|
|
||||||
|
class InstallerController extends Controller
|
||||||
|
{
|
||||||
|
private $db = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if system is already installed
|
||||||
|
*/
|
||||||
|
private function isInstalled(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = \Core\Database::getConnection();
|
||||||
|
$stmt = $pdo->query("SELECT COUNT(*) FROM users");
|
||||||
|
return $stmt->fetchColumn() > 0;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check pending migrations
|
||||||
|
*/
|
||||||
|
private function getPendingMigrations(): array
|
||||||
|
{
|
||||||
|
// For fresh installs - use consolidated schema
|
||||||
|
$freshInstallMigration = ['000_initial_schema_v1.1.0.sql'];
|
||||||
|
|
||||||
|
// For incremental updates from v1.0.0
|
||||||
|
$incrementalMigrations = [
|
||||||
|
'001_create_tables.sql',
|
||||||
|
'002_create_users_table.sql',
|
||||||
|
'003_add_whois_fields.sql',
|
||||||
|
'004_create_tld_registry_table.sql',
|
||||||
|
'005_update_tld_import_logs.sql',
|
||||||
|
'006_add_complete_workflow_import_type.sql',
|
||||||
|
'007_add_app_and_email_settings.sql',
|
||||||
|
'008_add_notes_to_domains.sql',
|
||||||
|
'009_add_authentication_features.sql',
|
||||||
|
'010_add_app_version_setting.sql',
|
||||||
|
'011_create_sessions_table.sql',
|
||||||
|
'012_link_remember_tokens_to_sessions.sql',
|
||||||
|
'013_create_user_notifications_table.sql',
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = \Core\Database::getConnection();
|
||||||
|
|
||||||
|
// Check if this is a v1.0.0 install (has tables but no migrations tracking)
|
||||||
|
$hasUsers = false;
|
||||||
|
$hasDomains = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->query("SELECT COUNT(*) FROM users");
|
||||||
|
$hasUsers = $stmt->fetchColumn() > 0;
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT COUNT(*) FROM domains");
|
||||||
|
$hasDomains = true; // Table exists
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Tables don't exist - fresh install
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create migrations table if it doesn't exist
|
||||||
|
$pdo->exec("
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
migration VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_migration (migration)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
");
|
||||||
|
|
||||||
|
// Get executed migrations
|
||||||
|
$stmt = $pdo->query("SELECT migration FROM migrations");
|
||||||
|
$executed = $stmt->fetchAll(\PDO::FETCH_COLUMN);
|
||||||
|
|
||||||
|
// If no migrations executed but has data - v1.0.0 upgrade
|
||||||
|
if (empty($executed) && ($hasUsers || $hasDomains)) {
|
||||||
|
// Mark 001-008 as executed (v1.0.0 migrations)
|
||||||
|
$v1Migrations = [
|
||||||
|
'001_create_tables.sql',
|
||||||
|
'002_create_users_table.sql',
|
||||||
|
'003_add_whois_fields.sql',
|
||||||
|
'004_create_tld_registry_table.sql',
|
||||||
|
'005_update_tld_import_logs.sql',
|
||||||
|
'006_add_complete_workflow_import_type.sql',
|
||||||
|
'007_add_app_and_email_settings.sql',
|
||||||
|
'008_add_notes_to_domains.sql'
|
||||||
|
];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("INSERT IGNORE INTO migrations (migration) VALUES (?)");
|
||||||
|
foreach ($v1Migrations as $migration) {
|
||||||
|
$stmt->execute([$migration]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return only new migrations for v1.1.0
|
||||||
|
return [
|
||||||
|
'009_add_authentication_features.sql',
|
||||||
|
'010_add_app_version_setting.sql',
|
||||||
|
'011_create_sessions_table.sql',
|
||||||
|
'012_link_remember_tokens_to_sessions.sql',
|
||||||
|
'013_create_user_notifications_table.sql'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no migrations executed and no data - fresh install (use consolidated)
|
||||||
|
if (empty($executed)) {
|
||||||
|
return $freshInstallMigration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If has executed migrations - check for pending incremental ones
|
||||||
|
return array_diff($incrementalMigrations, $executed);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// If critical error - assume fresh install
|
||||||
|
return $freshInstallMigration;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show installer welcome page
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
if ($this->isInstalled()) {
|
||||||
|
$pending = $this->getPendingMigrations();
|
||||||
|
if (empty($pending)) {
|
||||||
|
$_SESSION['info'] = 'System is already installed and up to date';
|
||||||
|
$this->redirect('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Has pending migrations - show updater
|
||||||
|
$this->redirect('/install/update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view('installer/welcome', [
|
||||||
|
'title' => 'Install Domain Monitor'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check database connection
|
||||||
|
*/
|
||||||
|
public function checkDatabase()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = \Core\Database::getConnection();
|
||||||
|
$pdo->query("SELECT 1");
|
||||||
|
|
||||||
|
$this->view('installer/database-check', [
|
||||||
|
'title' => 'Database Connection',
|
||||||
|
'success' => true
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->view('installer/database-check', [
|
||||||
|
'title' => 'Database Connection',
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run installation
|
||||||
|
*/
|
||||||
|
public function install()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/install');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminPassword = trim($_POST['admin_password'] ?? '');
|
||||||
|
$adminEmail = trim($_POST['admin_email'] ?? '');
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (empty($adminPassword) || strlen($adminPassword) < 8) {
|
||||||
|
$_SESSION['error'] = 'Admin password must be at least 8 characters';
|
||||||
|
$this->redirect('/install');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($adminEmail) || !filter_var($adminEmail, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$_SESSION['error'] = 'Please enter a valid admin email';
|
||||||
|
$this->redirect('/install');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = \Core\Database::getConnection();
|
||||||
|
|
||||||
|
// Run all migrations
|
||||||
|
$migrations = $this->getPendingMigrations();
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($migrations as $migration) {
|
||||||
|
$file = __DIR__ . '/../../database/migrations/' . $migration;
|
||||||
|
if (!file_exists($file)) continue;
|
||||||
|
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
|
||||||
|
// Replace password placeholder for user migration
|
||||||
|
if ($migration === '002_create_users_table.sql') {
|
||||||
|
$passwordHash = password_hash($adminPassword, PASSWORD_BCRYPT);
|
||||||
|
$sql = str_replace('{{ADMIN_PASSWORD_HASH}}', $passwordHash, $sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute SQL
|
||||||
|
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
if (!empty($statement)) {
|
||||||
|
try {
|
||||||
|
$pdo->exec($statement);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// Ignore duplicate/already exists errors
|
||||||
|
if (strpos($e->getMessage(), 'Duplicate') === false &&
|
||||||
|
strpos($e->getMessage(), 'already exists') === false) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as executed
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||||
|
$stmt->execute([$migration]);
|
||||||
|
|
||||||
|
$results[] = $migration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update admin email and ensure admin role and verified status
|
||||||
|
$stmt = $pdo->prepare("UPDATE users SET email = ?, role = 'admin', email_verified = 1 WHERE username = 'admin'");
|
||||||
|
$stmt->execute([$adminEmail]);
|
||||||
|
|
||||||
|
// Generate encryption key if not exists
|
||||||
|
if (empty($_ENV['APP_ENCRYPTION_KEY'])) {
|
||||||
|
$this->generateEncryptionKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .installed flag file
|
||||||
|
$installedFile = __DIR__ . '/../../.installed';
|
||||||
|
file_put_contents($installedFile, date('Y-m-d H:i:s'));
|
||||||
|
|
||||||
|
// Create welcome notification for admin
|
||||||
|
try {
|
||||||
|
// Get the admin user ID
|
||||||
|
$stmt = $pdo->query("SELECT id FROM users WHERE username = 'admin' LIMIT 1");
|
||||||
|
$adminUser = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($adminUser) {
|
||||||
|
$notificationService = new \App\Services\NotificationService();
|
||||||
|
$notificationService->notifyWelcome($adminUser['id'], 'admin');
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Don't fail install if notification fails
|
||||||
|
error_log("Failed to create welcome notification: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to complete page
|
||||||
|
$_SESSION['install_complete'] = true;
|
||||||
|
$_SESSION['admin_password'] = $adminPassword;
|
||||||
|
$this->redirect('/install/complete');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Installation failed: ' . $e->getMessage();
|
||||||
|
$this->redirect('/install');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show update page
|
||||||
|
*/
|
||||||
|
public function showUpdate()
|
||||||
|
{
|
||||||
|
$pending = $this->getPendingMigrations();
|
||||||
|
|
||||||
|
if (empty($pending)) {
|
||||||
|
$_SESSION['info'] = 'No updates available';
|
||||||
|
$this->redirect('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view('installer/update', [
|
||||||
|
'title' => 'System Update',
|
||||||
|
'migrations' => $pending
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run update
|
||||||
|
*/
|
||||||
|
public function runUpdate()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/install/update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = \Core\Database::getConnection();
|
||||||
|
$migrations = $this->getPendingMigrations();
|
||||||
|
$executed = [];
|
||||||
|
|
||||||
|
foreach ($migrations as $migration) {
|
||||||
|
$file = __DIR__ . '/../../database/migrations/' . $migration;
|
||||||
|
if (!file_exists($file)) continue;
|
||||||
|
|
||||||
|
$sql = file_get_contents($file);
|
||||||
|
|
||||||
|
// Execute SQL
|
||||||
|
$statements = array_filter(array_map('trim', explode(';', $sql)));
|
||||||
|
foreach ($statements as $statement) {
|
||||||
|
if (!empty($statement)) {
|
||||||
|
try {
|
||||||
|
$pdo->exec($statement);
|
||||||
|
} catch (\PDOException $e) {
|
||||||
|
// Ignore duplicate/already exists errors
|
||||||
|
if (strpos($e->getMessage(), 'Duplicate') === false &&
|
||||||
|
strpos($e->getMessage(), 'already exists') === false) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as executed
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||||
|
$stmt->execute([$migration]);
|
||||||
|
|
||||||
|
$executed[] = $migration;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create .installed flag file if doesn't exist (for v1.0.0 upgrades)
|
||||||
|
$installedFile = __DIR__ . '/../../.installed';
|
||||||
|
if (!file_exists($installedFile)) {
|
||||||
|
file_put_contents($installedFile, date('Y-m-d H:i:s'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify admins about upgrade (if migrations were executed)
|
||||||
|
if (!empty($executed)) {
|
||||||
|
try {
|
||||||
|
$settingModel = new \App\Models\Setting();
|
||||||
|
$currentVersion = $settingModel->getAppVersion();
|
||||||
|
|
||||||
|
// Determine from/to versions based on migrations
|
||||||
|
$fromVersion = '1.0.0';
|
||||||
|
$toVersion = '1.1.0';
|
||||||
|
|
||||||
|
// Detect version based on which migrations were run
|
||||||
|
if (in_array('011_create_sessions_table.sql', $executed) ||
|
||||||
|
in_array('012_link_remember_tokens_to_sessions.sql', $executed) ||
|
||||||
|
in_array('013_create_user_notifications_table.sql', $executed)) {
|
||||||
|
$toVersion = '1.1.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
$notificationService = new \App\Services\NotificationService();
|
||||||
|
$notificationService->notifyAdminsUpgrade($fromVersion, $toVersion, count($executed));
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Don't fail upgrade if notification fails
|
||||||
|
error_log("Failed to create upgrade notification: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['success'] = count($executed) . ' migration(s) executed successfully';
|
||||||
|
$this->redirect('/');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Update failed: ' . $e->getMessage();
|
||||||
|
$this->redirect('/install/update');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show installation complete page
|
||||||
|
*/
|
||||||
|
public function complete()
|
||||||
|
{
|
||||||
|
if (!isset($_SESSION['install_complete'])) {
|
||||||
|
$this->redirect('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminPassword = $_SESSION['admin_password'] ?? null;
|
||||||
|
unset($_SESSION['admin_password']);
|
||||||
|
unset($_SESSION['install_complete']);
|
||||||
|
|
||||||
|
$this->view('installer/complete', [
|
||||||
|
'title' => 'Installation Complete',
|
||||||
|
'adminPassword' => $adminPassword
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate encryption key
|
||||||
|
*/
|
||||||
|
private function generateEncryptionKey()
|
||||||
|
{
|
||||||
|
$encryptionKey = base64_encode(random_bytes(32));
|
||||||
|
$envFile = __DIR__ . '/../../.env';
|
||||||
|
|
||||||
|
if (file_exists($envFile)) {
|
||||||
|
$envContent = file_get_contents($envFile);
|
||||||
|
|
||||||
|
if (strpos($envContent, 'APP_ENCRYPTION_KEY=') !== false) {
|
||||||
|
$envContent = preg_replace(
|
||||||
|
'/APP_ENCRYPTION_KEY=.*$/m',
|
||||||
|
"APP_ENCRYPTION_KEY=$encryptionKey",
|
||||||
|
$envContent
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$envContent .= "\nAPP_ENCRYPTION_KEY=$encryptionKey\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($envFile, $envContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
227
app/Controllers/NotificationController.php
Normal file
227
app/Controllers/NotificationController.php
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Core\Controller;
|
||||||
|
use Core\Auth;
|
||||||
|
use App\Models\Notification;
|
||||||
|
|
||||||
|
class NotificationController extends Controller
|
||||||
|
{
|
||||||
|
private Notification $notificationModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->notificationModel = new Notification();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show all notifications page
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
|
||||||
|
// Get filter parameters
|
||||||
|
$statusFilter = $_GET['status'] ?? '';
|
||||||
|
$typeFilter = $_GET['type'] ?? '';
|
||||||
|
$dateRange = $_GET['date_range'] ?? '';
|
||||||
|
$perPage = (int)($_GET['per_page'] ?? 10);
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
|
||||||
|
// Build filters array
|
||||||
|
$filters = [
|
||||||
|
'status' => $statusFilter,
|
||||||
|
'type' => $typeFilter,
|
||||||
|
'date_range' => $dateRange
|
||||||
|
];
|
||||||
|
|
||||||
|
// Count total records
|
||||||
|
$totalRecords = $this->notificationModel->countForUser($userId, $filters);
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
$totalPages = ceil($totalRecords / $perPage);
|
||||||
|
$page = min($page, max(1, $totalPages));
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$showingFrom = $totalRecords > 0 ? $offset + 1 : 0;
|
||||||
|
$showingTo = min($offset + $perPage, $totalRecords);
|
||||||
|
|
||||||
|
// Get notifications
|
||||||
|
$notifications = $this->notificationModel->getForUser($userId, $filters, $perPage, $offset);
|
||||||
|
|
||||||
|
// Get unread count
|
||||||
|
$unreadCount = $this->notificationModel->getUnreadCount($userId);
|
||||||
|
|
||||||
|
// Format notifications for display
|
||||||
|
foreach ($notifications as &$notification) {
|
||||||
|
$notification['time_ago'] = $this->timeAgo($notification['created_at']);
|
||||||
|
$notification['icon'] = $this->getNotificationIcon($notification['type']);
|
||||||
|
$notification['color'] = $this->getNotificationColor($notification['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view('notifications/index', [
|
||||||
|
'title' => 'Notifications',
|
||||||
|
'notifications' => $notifications,
|
||||||
|
'unreadCount' => $unreadCount,
|
||||||
|
'filters' => $filters,
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $page,
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $totalRecords,
|
||||||
|
'showing_from' => $showingFrom,
|
||||||
|
'showing_to' => $showingTo
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notification as read
|
||||||
|
*/
|
||||||
|
public function markAsRead($params = [])
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$notificationId = (int)($params['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($notificationId <= 0) {
|
||||||
|
$_SESSION['error'] = 'Invalid notification';
|
||||||
|
$this->redirect('/notifications');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notificationModel->markAsRead($notificationId, $userId);
|
||||||
|
$_SESSION['success'] = 'Notification marked as read';
|
||||||
|
$this->redirect('/notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
*/
|
||||||
|
public function markAllAsRead()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$this->notificationModel->markAllAsRead($userId);
|
||||||
|
$_SESSION['success'] = 'All notifications marked as read';
|
||||||
|
$this->redirect('/notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete notification
|
||||||
|
*/
|
||||||
|
public function delete($params = [])
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$notificationId = (int)($params['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($notificationId <= 0) {
|
||||||
|
$_SESSION['error'] = 'Invalid notification';
|
||||||
|
$this->redirect('/notifications');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notificationModel->deleteNotification($notificationId, $userId);
|
||||||
|
$_SESSION['success'] = 'Notification deleted';
|
||||||
|
$this->redirect('/notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all notifications
|
||||||
|
*/
|
||||||
|
public function clearAll()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$this->notificationModel->clearAll($userId);
|
||||||
|
$_SESSION['success'] = 'All notifications cleared';
|
||||||
|
$this->redirect('/notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread count (for bell icon badge - AJAX)
|
||||||
|
*/
|
||||||
|
public function getUnreadCount()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$count = $this->notificationModel->getUnreadCount($userId);
|
||||||
|
$this->json(['count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent notifications for dropdown (AJAX)
|
||||||
|
*/
|
||||||
|
public function getRecent()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$notifications = $this->notificationModel->getRecentUnread($userId, 5);
|
||||||
|
|
||||||
|
// Format for display
|
||||||
|
foreach ($notifications as &$notification) {
|
||||||
|
$notification['time_ago'] = $this->timeAgo($notification['created_at']);
|
||||||
|
$notification['icon'] = $this->getNotificationIcon($notification['type']);
|
||||||
|
$notification['color'] = $this->getNotificationColor($notification['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->json([
|
||||||
|
'notifications' => $notifications,
|
||||||
|
'unread_count' => count($notifications)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification icon based on type
|
||||||
|
*/
|
||||||
|
private function getNotificationIcon(string $type): string
|
||||||
|
{
|
||||||
|
return match($type) {
|
||||||
|
'domain_expiring' => 'exclamation-triangle',
|
||||||
|
'domain_expired' => 'times-circle',
|
||||||
|
'domain_updated' => 'sync-alt',
|
||||||
|
'session_new' => 'sign-in-alt',
|
||||||
|
'whois_failed' => 'exclamation-circle',
|
||||||
|
'system_welcome' => 'hand-sparkles',
|
||||||
|
'system_upgrade' => 'arrow-up',
|
||||||
|
default => 'bell'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification color based on type
|
||||||
|
*/
|
||||||
|
private function getNotificationColor(string $type): string
|
||||||
|
{
|
||||||
|
return match($type) {
|
||||||
|
'domain_expiring' => 'orange',
|
||||||
|
'domain_expired' => 'red',
|
||||||
|
'domain_updated' => 'green',
|
||||||
|
'session_new' => 'blue',
|
||||||
|
'whois_failed' => 'gray',
|
||||||
|
'system_welcome' => 'purple',
|
||||||
|
'system_upgrade' => 'indigo',
|
||||||
|
default => 'gray'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert timestamp to "time ago" format
|
||||||
|
*/
|
||||||
|
private function timeAgo(string $datetime): string
|
||||||
|
{
|
||||||
|
$timestamp = strtotime($datetime);
|
||||||
|
$diff = time() - $timestamp;
|
||||||
|
|
||||||
|
if ($diff < 60) {
|
||||||
|
return 'just now';
|
||||||
|
} elseif ($diff < 3600) {
|
||||||
|
$mins = floor($diff / 60);
|
||||||
|
return $mins . ' minute' . ($mins > 1 ? 's' : '') . ' ago';
|
||||||
|
} elseif ($diff < 86400) {
|
||||||
|
$hours = floor($diff / 3600);
|
||||||
|
return $hours . ' hour' . ($hours > 1 ? 's' : '') . ' ago';
|
||||||
|
} elseif ($diff < 604800) {
|
||||||
|
$days = floor($diff / 86400);
|
||||||
|
return $days . ' day' . ($days > 1 ? 's' : '') . ' ago';
|
||||||
|
} else {
|
||||||
|
return date('M d, Y', $timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
328
app/Controllers/ProfileController.php
Normal file
328
app/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Core\Controller;
|
||||||
|
use Core\Auth;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\SessionManager;
|
||||||
|
use App\Models\RememberToken;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
private User $userModel;
|
||||||
|
private SessionManager $sessionModel;
|
||||||
|
private RememberToken $rememberTokenModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->userModel = new User();
|
||||||
|
$this->sessionModel = new SessionManager();
|
||||||
|
$this->rememberTokenModel = new RememberToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show profile page
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$_SESSION['error'] = 'User not found';
|
||||||
|
$this->redirect('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean old sessions when user views their profile (perfect time!)
|
||||||
|
// This happens naturally when users check their sessions
|
||||||
|
try {
|
||||||
|
$this->sessionModel->cleanOldSessions();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Silent fail - don't break the page
|
||||||
|
error_log("Session cleanup failed: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active sessions
|
||||||
|
$sessions = $this->sessionModel->getByUserId($userId);
|
||||||
|
|
||||||
|
// Mark current session and check for remember tokens
|
||||||
|
$currentSessionId = session_id();
|
||||||
|
foreach ($sessions as &$session) {
|
||||||
|
$session['is_current'] = ($session['id'] === $currentSessionId);
|
||||||
|
// Format timestamps for display
|
||||||
|
$session['last_activity'] = date('Y-m-d H:i:s', $session['last_activity']);
|
||||||
|
$session['created_at'] = date('Y-m-d H:i:s', $session['created_at']);
|
||||||
|
|
||||||
|
// Check if this session has a remember token
|
||||||
|
$rememberToken = $this->rememberTokenModel->getBySessionId($session['id']);
|
||||||
|
$session['has_remember_token'] = !empty($rememberToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format sessions for display (adds deviceIcon, browserInfo, timeAgo, sessionAge)
|
||||||
|
$formattedSessions = \App\Helpers\SessionHelper::formatForDisplay($sessions);
|
||||||
|
|
||||||
|
$this->view('profile/index', [
|
||||||
|
'user' => $user,
|
||||||
|
'sessions' => $formattedSessions,
|
||||||
|
'title' => 'My Profile'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update profile
|
||||||
|
*/
|
||||||
|
public function update()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$fullName = trim($_POST['full_name'] ?? '');
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (empty($fullName) || empty($email)) {
|
||||||
|
$_SESSION['error'] = 'Full name and email are required';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$_SESSION['error'] = 'Please enter a valid email address';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is already taken by another user
|
||||||
|
$existingUsers = $this->userModel->where('email', $email);
|
||||||
|
foreach ($existingUsers as $existingUser) {
|
||||||
|
if ($existingUser['id'] != $userId) {
|
||||||
|
$_SESSION['error'] = 'Email address is already in use';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
$this->userModel->update($userId, [
|
||||||
|
'full_name' => $fullName,
|
||||||
|
'email' => $email,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update session
|
||||||
|
$_SESSION['full_name'] = $fullName;
|
||||||
|
$_SESSION['email'] = $email;
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'Profile updated successfully';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change password
|
||||||
|
*/
|
||||||
|
public function changePassword()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = Auth::id();
|
||||||
|
$currentPassword = $_POST['current_password'] ?? '';
|
||||||
|
$newPassword = $_POST['new_password'] ?? '';
|
||||||
|
$newPasswordConfirm = $_POST['new_password_confirm'] ?? '';
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (empty($currentPassword) || empty($newPassword) || empty($newPasswordConfirm)) {
|
||||||
|
$_SESSION['error'] = 'All fields are required';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($newPassword) < 8) {
|
||||||
|
$_SESSION['error'] = 'Password must be at least 8 characters long';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newPassword !== $newPasswordConfirm) {
|
||||||
|
$_SESSION['error'] = 'New passwords do not match';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
if (!$this->userModel->verifyPassword($currentPassword, $user['password'])) {
|
||||||
|
$_SESSION['error'] = 'Current password is incorrect';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update password
|
||||||
|
$this->userModel->changePassword($userId, $newPassword);
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'Password changed successfully';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete account
|
||||||
|
*/
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
// Don't allow admins to delete their own account
|
||||||
|
if ($user['role'] === 'admin') {
|
||||||
|
$_SESSION['error'] = 'Admin accounts cannot be deleted';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user (cascade will handle related records)
|
||||||
|
$this->userModel->delete($userId);
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
session_destroy();
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'Your account has been deleted';
|
||||||
|
$this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend email verification
|
||||||
|
*/
|
||||||
|
public function resendVerification()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if ($user['email_verified']) {
|
||||||
|
$_SESSION['info'] = 'Your email is already verified';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use AuthController logic
|
||||||
|
$authController = new AuthController();
|
||||||
|
|
||||||
|
$_SESSION['pending_verification_email'] = $user['email'];
|
||||||
|
$_SESSION['success'] = 'Verification email sent! Please check your inbox.';
|
||||||
|
|
||||||
|
$this->redirect('/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout other sessions (actually terminates them!)
|
||||||
|
*/
|
||||||
|
public function logoutOtherSessions()
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$currentSessionId = session_id();
|
||||||
|
|
||||||
|
if (!$currentSessionId) {
|
||||||
|
$_SESSION['error'] = 'No active session found';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all other sessions first to delete their remember tokens
|
||||||
|
$allSessions = $this->sessionModel->getByUserId($userId);
|
||||||
|
$deletedTokens = 0;
|
||||||
|
foreach ($allSessions as $session) {
|
||||||
|
if ($session['id'] !== $currentSessionId) {
|
||||||
|
$deletedTokens += $this->rememberTokenModel->deleteBySessionId($session['id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all other sessions (this actually logs them out!)
|
||||||
|
$count = $this->sessionModel->deleteOtherSessions($userId, $currentSessionId);
|
||||||
|
|
||||||
|
// Perfect time to clean all old sessions (user is security-conscious)
|
||||||
|
$this->sessionModel->cleanOldSessions();
|
||||||
|
|
||||||
|
$message = "Terminated {$count} other session(s) - those devices are now logged out";
|
||||||
|
if ($deletedTokens > 0) {
|
||||||
|
$message .= " ({$deletedTokens} remember tokens removed)";
|
||||||
|
}
|
||||||
|
$_SESSION['success'] = $message;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to terminate other sessions';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/profile#sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logout specific session (actually terminates it!)
|
||||||
|
*/
|
||||||
|
public function logoutSession($params = [])
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionId = $params['sessionId'] ?? '';
|
||||||
|
$userId = Auth::id();
|
||||||
|
$currentSessionId = session_id();
|
||||||
|
|
||||||
|
if (empty($sessionId)) {
|
||||||
|
$_SESSION['error'] = 'Invalid session';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get the session to verify ownership
|
||||||
|
$session = $this->sessionModel->getById($sessionId);
|
||||||
|
|
||||||
|
if (!$session) {
|
||||||
|
$_SESSION['error'] = 'Session not found';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify session belongs to current user
|
||||||
|
if ($session['user_id'] != $userId) {
|
||||||
|
$_SESSION['error'] = 'Unauthorized action';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting current session
|
||||||
|
if ($session['id'] === $currentSessionId) {
|
||||||
|
$_SESSION['error'] = 'Cannot delete your current session. Use logout instead.';
|
||||||
|
$this->redirect('/profile');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the session (this actually logs out that device!)
|
||||||
|
$this->sessionModel->deleteById($sessionId);
|
||||||
|
|
||||||
|
// Also delete any remember token associated with this session
|
||||||
|
$deletedTokens = $this->rememberTokenModel->deleteBySessionId($sessionId);
|
||||||
|
|
||||||
|
$message = 'Session terminated - that device is now logged out';
|
||||||
|
if ($deletedTokens > 0) {
|
||||||
|
$message .= ' (remember me disabled)';
|
||||||
|
}
|
||||||
|
$_SESSION['success'] = $message;
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to terminate session';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/profile#sessions');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,9 +58,12 @@ class SearchController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format existing domains for display
|
||||||
|
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($existingDomains);
|
||||||
|
|
||||||
$this->view('search/results', [
|
$this->view('search/results', [
|
||||||
'query' => $query,
|
'query' => $query,
|
||||||
'existingDomains' => $existingDomains,
|
'existingDomains' => $formattedDomains,
|
||||||
'whoisData' => $whoisData,
|
'whoisData' => $whoisData,
|
||||||
'whoisError' => $whoisError,
|
'whoisError' => $whoisError,
|
||||||
'isDomainLike' => $isDomainLike,
|
'isDomainLike' => $isDomainLike,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
352
app/Controllers/UserController.php
Normal file
352
app/Controllers/UserController.php
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use Core\Controller;
|
||||||
|
use Core\Auth;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class UserController extends Controller
|
||||||
|
{
|
||||||
|
private User $userModel;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->userModel = new User();
|
||||||
|
|
||||||
|
// Ensure only admins can access user management
|
||||||
|
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||||
|
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||||
|
$this->redirect('/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all users
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
// Get filter parameters
|
||||||
|
$search = trim($_GET['search'] ?? '');
|
||||||
|
$roleFilter = $_GET['role'] ?? '';
|
||||||
|
$statusFilter = $_GET['status'] ?? '';
|
||||||
|
$sort = $_GET['sort'] ?? 'username';
|
||||||
|
$order = $_GET['order'] ?? 'asc';
|
||||||
|
$perPage = (int)($_GET['per_page'] ?? 25);
|
||||||
|
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||||
|
|
||||||
|
// Build filters array
|
||||||
|
$filters = [
|
||||||
|
'search' => $search,
|
||||||
|
'role' => $roleFilter,
|
||||||
|
'status' => $statusFilter
|
||||||
|
];
|
||||||
|
|
||||||
|
// Count total records
|
||||||
|
$totalRecords = $this->userModel->countFiltered($filters);
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
$totalPages = ceil($totalRecords / $perPage);
|
||||||
|
$page = min($page, max(1, $totalPages)); // Ensure page is within bounds
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$showingFrom = $totalRecords > 0 ? $offset + 1 : 0;
|
||||||
|
$showingTo = min($offset + $perPage, $totalRecords);
|
||||||
|
|
||||||
|
// Get filtered users
|
||||||
|
$users = $this->userModel->getFiltered($filters, $sort, strtoupper($order), $perPage, $offset);
|
||||||
|
|
||||||
|
$this->view('users/index', [
|
||||||
|
'users' => $users,
|
||||||
|
'title' => 'User Management',
|
||||||
|
'filters' => [
|
||||||
|
'search' => $search,
|
||||||
|
'role' => $roleFilter,
|
||||||
|
'status' => $statusFilter,
|
||||||
|
'sort' => $sort,
|
||||||
|
'order' => strtolower($order)
|
||||||
|
],
|
||||||
|
'pagination' => [
|
||||||
|
'current_page' => $page,
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $totalRecords,
|
||||||
|
'showing_from' => $showingFrom,
|
||||||
|
'showing_to' => $showingTo
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show create user form
|
||||||
|
*/
|
||||||
|
public function create()
|
||||||
|
{
|
||||||
|
$this->view('users/create', [
|
||||||
|
'title' => 'Create User'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store new user
|
||||||
|
*/
|
||||||
|
public function store()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
$fullName = trim($_POST['full_name'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
$passwordConfirm = $_POST['password_confirm'] ?? '';
|
||||||
|
$role = $_POST['role'] ?? 'user';
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (empty($username) || empty($email) || empty($fullName) || empty($password)) {
|
||||||
|
$_SESSION['error'] = 'All fields are required';
|
||||||
|
$this->redirect('/users/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$_SESSION['error'] = 'Invalid email address';
|
||||||
|
$this->redirect('/users/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
|
||||||
|
$_SESSION['error'] = 'Username can only contain letters, numbers, and underscores';
|
||||||
|
$this->redirect('/users/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
$_SESSION['error'] = 'Password must be at least 8 characters';
|
||||||
|
$this->redirect('/users/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($password !== $passwordConfirm) {
|
||||||
|
$_SESSION['error'] = 'Passwords do not match';
|
||||||
|
$this->redirect('/users/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if username exists
|
||||||
|
if ($this->userModel->findByUsername($username)) {
|
||||||
|
$_SESSION['error'] = 'Username already exists';
|
||||||
|
$this->redirect('/users/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email exists
|
||||||
|
if (!empty($this->userModel->where('email', $email))) {
|
||||||
|
$_SESSION['error'] = 'Email already exists';
|
||||||
|
$this->redirect('/users/create');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$userId = $this->userModel->createUser($username, $password, $email, $fullName);
|
||||||
|
|
||||||
|
// Update role if not default
|
||||||
|
if ($role !== 'user') {
|
||||||
|
$this->userModel->update($userId, ['role' => $role]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as verified by default (admin created)
|
||||||
|
$this->userModel->update($userId, ['email_verified' => 1]);
|
||||||
|
|
||||||
|
// Create welcome notification
|
||||||
|
try {
|
||||||
|
$notificationService = new \App\Services\NotificationService();
|
||||||
|
$notificationService->notifyWelcome($userId, $username);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Don't fail user creation if notification fails
|
||||||
|
error_log("Failed to create welcome notification: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'User created successfully';
|
||||||
|
$this->redirect('/users');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to create user: ' . $e->getMessage();
|
||||||
|
$this->redirect('/users/create');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show edit user form
|
||||||
|
*/
|
||||||
|
public function edit()
|
||||||
|
{
|
||||||
|
$userId = (int)($_GET['id'] ?? 0);
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$_SESSION['error'] = 'User not found';
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view('users/edit', [
|
||||||
|
'user' => $user,
|
||||||
|
'title' => 'Edit User'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update user
|
||||||
|
*/
|
||||||
|
public function update()
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = (int)($_POST['id'] ?? 0);
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$_SESSION['error'] = 'User not found';
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
$fullName = trim($_POST['full_name'] ?? '');
|
||||||
|
$role = $_POST['role'] ?? 'user';
|
||||||
|
$isActive = isset($_POST['is_active']) ? 1 : 0;
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (empty($email) || empty($fullName)) {
|
||||||
|
$_SESSION['error'] = 'Email and full name are required';
|
||||||
|
$this->redirect('/users/edit?id=' . $userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$_SESSION['error'] = 'Invalid email address';
|
||||||
|
$this->redirect('/users/edit?id=' . $userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email is taken by another user
|
||||||
|
$existingUsers = $this->userModel->where('email', $email);
|
||||||
|
if (!empty($existingUsers) && $existingUsers[0]['id'] != $userId) {
|
||||||
|
$_SESSION['error'] = 'Email already in use by another user';
|
||||||
|
$this->redirect('/users/edit?id=' . $userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updateData = [
|
||||||
|
'email' => $email,
|
||||||
|
'full_name' => $fullName,
|
||||||
|
'role' => $role,
|
||||||
|
'is_active' => $isActive
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->userModel->update($userId, $updateData);
|
||||||
|
|
||||||
|
// Update password if provided
|
||||||
|
if (!empty($password)) {
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
$_SESSION['error'] = 'Password must be at least 8 characters';
|
||||||
|
$this->redirect('/users/edit?id=' . $userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->userModel->changePassword($userId, $password);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'User updated successfully';
|
||||||
|
$this->redirect('/users');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to update user: ' . $e->getMessage();
|
||||||
|
$this->redirect('/users/edit?id=' . $userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete user
|
||||||
|
*/
|
||||||
|
public function delete()
|
||||||
|
{
|
||||||
|
$userId = (int)($_GET['id'] ?? 0);
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$_SESSION['error'] = 'User not found';
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting yourself
|
||||||
|
if ($userId == Auth::id()) {
|
||||||
|
$_SESSION['error'] = 'You cannot delete your own account';
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deleting the last admin
|
||||||
|
if ($user['role'] === 'admin') {
|
||||||
|
$allAdmins = $this->userModel->where('role', 'admin');
|
||||||
|
if (count($allAdmins) <= 1) {
|
||||||
|
$_SESSION['error'] = 'Cannot delete the last admin user';
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->userModel->delete($userId);
|
||||||
|
$_SESSION['success'] = 'User deleted successfully';
|
||||||
|
$this->redirect('/users');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to delete user: ' . $e->getMessage();
|
||||||
|
$this->redirect('/users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle user active status
|
||||||
|
*/
|
||||||
|
public function toggleStatus()
|
||||||
|
{
|
||||||
|
$userId = (int)($_GET['id'] ?? 0);
|
||||||
|
$user = $this->userModel->find($userId);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
$_SESSION['error'] = 'User not found';
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent deactivating yourself
|
||||||
|
if ($userId == Auth::id()) {
|
||||||
|
$_SESSION['error'] = 'You cannot deactivate your own account';
|
||||||
|
$this->redirect('/users');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$newStatus = $user['is_active'] ? 0 : 1;
|
||||||
|
$this->userModel->update($userId, ['is_active' => $newStatus]);
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'User status updated successfully';
|
||||||
|
$this->redirect('/users');
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$_SESSION['error'] = 'Failed to update user status: ' . $e->getMessage();
|
||||||
|
$this->redirect('/users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
196
app/Helpers/DomainHelper.php
Normal file
196
app/Helpers/DomainHelper.php
Normal 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']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
167
app/Helpers/LayoutHelper.php
Normal file
167
app/Helpers/LayoutHelper.php
Normal 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()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
67
app/Helpers/SessionHelper.php
Normal file
67
app/Helpers/SessionHelper.php
Normal 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
180
app/Models/Notification.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
42
app/Models/RememberToken.php
Normal file
42
app/Models/RememberToken.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
188
app/Models/SessionManager.php
Normal file
188
app/Models/SessionManager.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
} catch (\Exception $e) {
|
$stmt = $pdo->query("SELECT id FROM users WHERE role = 'admin'");
|
||||||
error_log("Notification send failed [$channelType]: " . $e->getMessage());
|
$admins = $stmt->fetchAll(\PDO::FETCH_ASSOC);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
foreach ($admins as $admin) {
|
||||||
* Send notification to all active channels in a group
|
$this->notifySystemUpgrade($admin['id'], $fromVersion, $toVersion, $migrationsCount);
|
||||||
*/
|
|
||||||
public function sendToGroup(int $groupId, string $subject, string $message, array $data = []): array
|
|
||||||
{
|
|
||||||
// Get active channels for the group
|
|
||||||
$channelModel = new \App\Models\NotificationChannel();
|
|
||||||
$channels = $channelModel->getByGroupId($groupId);
|
|
||||||
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
foreach ($channels as $channel) {
|
|
||||||
if (!$channel['is_active']) {
|
|
||||||
continue; // Skip inactive channels
|
|
||||||
}
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log("Failed to notify admins about upgrade: " . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$config = json_decode($channel['channel_config'], true);
|
/**
|
||||||
|
* Delete old read notifications (cleanup)
|
||||||
// Add subject to data for channels that support it (like email)
|
*/
|
||||||
$channelData = array_merge(['subject' => $subject], $data);
|
public function cleanOldNotifications(int $daysOld = 30): void
|
||||||
|
{
|
||||||
$success = $this->send(
|
try {
|
||||||
$channel['channel_type'],
|
$pdo = \Core\Database::getConnection();
|
||||||
$config,
|
$stmt = $pdo->prepare(
|
||||||
$message,
|
"DELETE FROM user_notifications
|
||||||
$channelData
|
WHERE is_read = 1
|
||||||
|
AND read_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
|
||||||
);
|
);
|
||||||
|
$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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
56
app/Views/auth/base-auth.php
Normal file
56
app/Views/auth/base-auth.php
Normal 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>
|
||||||
|
|
||||||
79
app/Views/auth/forgot-password.php
Normal file
79
app/Views/auth/forgot-password.php
Normal 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';
|
||||||
|
?>
|
||||||
@@ -1,156 +1,130 @@
|
|||||||
<!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 -->
|
<!-- Logo and Title -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<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-globe text-white text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Welcome Back</h1>
|
||||||
|
<p class="text-sm text-gray-500">Sign in to access your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Font Awesome -->
|
<!-- Error Alert -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
<?php if (isset($_SESSION['error'])): ?>
|
||||||
|
<div class="mb-6 bg-red-50 border border-red-200 p-3 rounded-lg">
|
||||||
<script>
|
<div class="flex items-center">
|
||||||
tailwind.config = {
|
<i class="fas fa-exclamation-circle text-red-500 mr-2"></i>
|
||||||
theme: {
|
<span class="text-sm text-red-700"><?= htmlspecialchars($_SESSION['error']) ?></span>
|
||||||
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 -->
|
|
||||||
<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-globe text-white text-2xl"></i>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Welcome Back</h1>
|
|
||||||
<p class="text-sm text-gray-500">Sign in to access your account</p>
|
|
||||||
</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']); ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<!-- Login Form -->
|
|
||||||
<form method="POST" action="/login" class="space-y-5">
|
|
||||||
<!-- 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-user text-gray-400 text-sm"></i>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
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 username">
|
|
||||||
</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
|
|
||||||
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 your 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 focus:outline-none">
|
|
||||||
<i class="fas fa-eye text-sm" id="toggleIcon"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remember Me -->
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<label class="flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name="remember"
|
|
||||||
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>
|
|
||||||
</label>
|
|
||||||
<a href="#" class="text-sm text-primary hover:text-primary-dark">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</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-sign-in-alt mr-2"></i>
|
|
||||||
Sign In
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php unset($_SESSION['error']); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Login Form -->
|
||||||
<div class="text-center mt-6">
|
<form method="POST" action="/login" class="space-y-5">
|
||||||
<p class="text-gray-500 text-xs">
|
<!-- Username Field -->
|
||||||
© <?= date('Y') ?> Domain Monitor. All rights reserved.
|
<div>
|
||||||
</p>
|
<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-user text-gray-400 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
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 username">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- Password Field -->
|
||||||
function togglePassword() {
|
<div>
|
||||||
const passwordInput = document.getElementById('password');
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1.5">
|
||||||
const toggleIcon = document.getElementById('toggleIcon');
|
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
|
||||||
|
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 your 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 focus:outline-none">
|
||||||
|
<i class="fas fa-eye text-sm" id="toggleIcon"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
if (passwordInput.type === 'password') {
|
<!-- Remember Me -->
|
||||||
passwordInput.type = 'text';
|
<div class="flex items-center justify-between">
|
||||||
toggleIcon.classList.remove('fa-eye');
|
<label class="flex items-center cursor-pointer">
|
||||||
toggleIcon.classList.add('fa-eye-slash');
|
<input
|
||||||
} else {
|
type="checkbox"
|
||||||
passwordInput.type = 'password';
|
name="remember"
|
||||||
toggleIcon.classList.remove('fa-eye-slash');
|
value="1"
|
||||||
toggleIcon.classList.add('fa-eye');
|
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>
|
||||||
|
</label>
|
||||||
|
<a href="/forgot-password" class="text-sm text-primary hover:text-primary-dark">
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
|
</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-sign-in-alt mr-2"></i>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($registrationEnabled ?? false): ?>
|
||||||
|
<!-- Sign Up Link -->
|
||||||
|
<div class="text-center mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
$scripts = <<<'SCRIPT'
|
||||||
|
<script>
|
||||||
|
function togglePassword() {
|
||||||
|
const passwordInput = document.getElementById('password');
|
||||||
|
const toggleIcon = document.getElementById('toggleIcon');
|
||||||
|
|
||||||
|
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');
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
</body>
|
</script>
|
||||||
</html>
|
SCRIPT;
|
||||||
|
require __DIR__ . '/base-auth.php';
|
||||||
|
?>
|
||||||
|
|||||||
217
app/Views/auth/register.php
Normal file
217
app/Views/auth/register.php
Normal 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';
|
||||||
|
?>
|
||||||
147
app/Views/auth/reset-password.php
Normal file
147
app/Views/auth/reset-password.php
Normal 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';
|
||||||
|
?>
|
||||||
79
app/Views/auth/verify-email.php
Normal file
79
app/Views/auth/verify-email.php
Normal 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';
|
||||||
|
?>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) ?>
|
||||||
@@ -310,7 +225,6 @@ ob_start();
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|||||||
109
app/Views/installer/complete.php
Normal file
109
app/Views/installer/complete.php
Normal 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>
|
||||||
96
app/Views/installer/update.php
Normal file
96
app/Views/installer/update.php
Normal 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>
|
||||||
150
app/Views/installer/welcome.php
Normal file
150
app/Views/installer/welcome.php
Normal 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>
|
||||||
@@ -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();
|
$appTimezone = $appSettings['app_timezone'];
|
||||||
$appName = htmlspecialchars($appSettings['app_name']);
|
$appVersion = $appSettings['app_version'];
|
||||||
$appTimezone = $appSettings['app_timezone'];
|
|
||||||
|
|
||||||
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.classList.toggle('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Toggle notifications dropdown
|
||||||
document.addEventListener('click', function(event) {
|
function toggleNotifications() {
|
||||||
const dropdown = document.getElementById('userDropdown');
|
const dropdown = document.getElementById('notificationsDropdown');
|
||||||
const isClickInside = event.target.closest('[onclick="toggleDropdown()"]') || event.target.closest('#userDropdown');
|
const userDropdown = document.getElementById('userDropdown');
|
||||||
|
|
||||||
if (!isClickInside && dropdown && dropdown.classList.contains('show')) {
|
// Close user dropdown if open
|
||||||
dropdown.classList.remove('show');
|
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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<i class="fas fa-bell"></i>
|
<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">
|
||||||
<?php if (($globalStats['expiring_soon'] ?? 0) > 0): ?>
|
<i class="fas fa-bell"></i>
|
||||||
<span class="absolute top-1 right-1 flex h-2 w-2">
|
<?php if ($unreadNotifications > 0): ?>
|
||||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
<span class="absolute top-1 right-1 flex h-2 w-2">
|
||||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-400 opacity-75"></span>
|
||||||
</span>
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-orange-500"></span>
|
||||||
<?php endif; ?>
|
</span>
|
||||||
</button>
|
<?php endif; ?>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|||||||
287
app/Views/notifications/index.php
Normal file
287
app/Views/notifications/index.php
Normal 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
481
app/Views/profile/index.php
Normal 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';
|
||||||
|
?>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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; ?>
|
||||||
|
|||||||
@@ -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
100
app/Views/users/create.php
Normal 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
130
app/Views/users/edit.php
Normal 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
355
app/Views/users/index.php
Normal 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';
|
||||||
|
?>
|
||||||
|
|
||||||
@@ -44,9 +44,23 @@ 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 = [
|
||||||
return;
|
'/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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!self::check()) {
|
if (!self::check()) {
|
||||||
|
|||||||
200
core/DatabaseSessionHandler.php
Normal file
200
core/DatabaseSessionHandler.php
Normal 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
58
core/SessionValidator.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
270
database/migrations/000_initial_schema_v1.1.0.sql
Normal file
270
database/migrations/000_initial_schema_v1.1.0.sql
Normal 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;
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|||||||
49
database/migrations/009_add_authentication_features.sql
Normal file
49
database/migrations/009_add_authentication_features.sql
Normal 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;
|
||||||
|
|
||||||
5
database/migrations/010_add_app_version_setting.sql
Normal file
5
database/migrations/010_add_app_version_setting.sql
Normal 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;
|
||||||
|
|
||||||
21
database/migrations/011_create_sessions_table.sql
Normal file
21
database/migrations/011_create_sessions_table.sql
Normal 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;
|
||||||
|
|
||||||
@@ -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`);
|
||||||
19
database/migrations/013_create_user_notifications_table.sql
Normal file
19
database/migrations/013_create_user_notifications_table.sql
Normal 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;
|
||||||
|
|
||||||
36
database/migrations/README.md
Normal file
36
database/migrations/README.md
Normal 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
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user