From 4389470233a6f12d0d2af15c3b9ffb52116e641d Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 27 Jan 2026 19:18:29 +0100 Subject: [PATCH] init commit --- .gitignore | 25 + AGENT.md | 98 ++++ LICENSE.md | 21 + README.md | 462 ++++++++++++++++++ composer.json | 50 ++ phpunit.xml | 32 ++ src/Bootstrap.php | 156 ++++++ src/Module.php | 428 ++++++++++++++++ .../MigrateFromDektriumController.php | 440 +++++++++++++++++ src/commands/UserController.php | 208 ++++++++ src/contracts/UserInterface.php | 33 ++ src/controllers/AdminController.php | 356 ++++++++++++++ src/controllers/GdprController.php | 181 +++++++ src/controllers/RecoveryController.php | 112 +++++ src/controllers/RegistrationController.php | 172 +++++++ src/controllers/SecurityController.php | 106 ++++ src/controllers/SettingsController.php | 233 +++++++++ src/events/FormEvent.php | 19 + src/events/RegistrationEvent.php | 31 ++ src/events/UserEvent.php | 19 + src/filters/AccessRule.php | 51 ++ src/helpers/Password.php | 96 ++++ src/messages/en/user.php | 193 ++++++++ .../m250115_000001_create_user_table.php | 51 ++ .../m250115_000002_create_profile_table.php | 56 +++ .../m250115_000003_create_token_table.php | 54 ++ ...115_000004_create_social_account_table.php | 55 +++ src/models/LoginForm.php | 124 +++++ src/models/Profile.php | 153 ++++++ src/models/RecoveryForm.php | 70 +++ src/models/RecoveryResetForm.php | 54 ++ src/models/RegistrationForm.php | 73 +++ src/models/SettingsForm.php | 128 +++++ src/models/Token.php | 219 +++++++++ src/models/User.php | 368 ++++++++++++++ src/models/UserSearch.php | 75 +++ src/models/query/ProfileQuery.php | 41 ++ src/models/query/TokenQuery.php | 82 ++++ src/models/query/UserQuery.php | 85 ++++ src/services/MailerService.php | 155 ++++++ src/services/RecoveryService.php | 117 +++++ src/services/RegistrationService.php | 181 +++++++ src/services/TokenService.php | 106 ++++ src/services/UserService.php | 230 +++++++++ src/views/_alert.php | 20 + src/views/admin/_account.php | 31 ++ src/views/admin/_assignments.php | 21 + src/views/admin/_form.php | 57 +++ src/views/admin/_info.php | 52 ++ src/views/admin/_menu.php | 13 + src/views/admin/_profile.php | 36 ++ src/views/admin/_user.php | 11 + src/views/admin/create.php | 27 + src/views/admin/index.php | 130 +++++ src/views/admin/update.php | 63 +++ src/views/gdpr/delete.php | 60 +++ src/views/gdpr/index.php | 54 ++ src/views/mail/confirmation-text.php | 20 + src/views/mail/confirmation.php | 37 ++ src/views/mail/generated_password-text.php | 19 + src/views/mail/generated_password.php | 27 + src/views/mail/layouts/html.php | 34 ++ src/views/mail/recovery-text.php | 20 + src/views/mail/recovery.php | 37 ++ src/views/mail/welcome-text.php | 27 + src/views/mail/welcome.php | 43 ++ src/views/message.php | 12 + src/views/recovery/request.php | 51 ++ src/views/recovery/reset.php | 46 ++ src/views/registration/register.php | 58 +++ src/views/registration/resend.php | 51 ++ src/views/security/login.php | 63 +++ src/views/settings/_menu.php | 31 ++ src/views/settings/account.php | 62 +++ src/views/settings/profile.php | 93 ++++ src/widgets/Login.php | 50 ++ 76 files changed, 7355 insertions(+) create mode 100644 .gitignore create mode 100644 AGENT.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 src/Bootstrap.php create mode 100644 src/Module.php create mode 100644 src/commands/MigrateFromDektriumController.php create mode 100644 src/commands/UserController.php create mode 100644 src/contracts/UserInterface.php create mode 100644 src/controllers/AdminController.php create mode 100644 src/controllers/GdprController.php create mode 100644 src/controllers/RecoveryController.php create mode 100644 src/controllers/RegistrationController.php create mode 100644 src/controllers/SecurityController.php create mode 100644 src/controllers/SettingsController.php create mode 100644 src/events/FormEvent.php create mode 100644 src/events/RegistrationEvent.php create mode 100644 src/events/UserEvent.php create mode 100644 src/filters/AccessRule.php create mode 100644 src/helpers/Password.php create mode 100644 src/messages/en/user.php create mode 100644 src/migrations/m250115_000001_create_user_table.php create mode 100644 src/migrations/m250115_000002_create_profile_table.php create mode 100644 src/migrations/m250115_000003_create_token_table.php create mode 100644 src/migrations/m250115_000004_create_social_account_table.php create mode 100644 src/models/LoginForm.php create mode 100644 src/models/Profile.php create mode 100644 src/models/RecoveryForm.php create mode 100644 src/models/RecoveryResetForm.php create mode 100644 src/models/RegistrationForm.php create mode 100644 src/models/SettingsForm.php create mode 100644 src/models/Token.php create mode 100644 src/models/User.php create mode 100644 src/models/UserSearch.php create mode 100644 src/models/query/ProfileQuery.php create mode 100644 src/models/query/TokenQuery.php create mode 100644 src/models/query/UserQuery.php create mode 100644 src/services/MailerService.php create mode 100644 src/services/RecoveryService.php create mode 100644 src/services/RegistrationService.php create mode 100644 src/services/TokenService.php create mode 100644 src/services/UserService.php create mode 100644 src/views/_alert.php create mode 100644 src/views/admin/_account.php create mode 100644 src/views/admin/_assignments.php create mode 100644 src/views/admin/_form.php create mode 100644 src/views/admin/_info.php create mode 100644 src/views/admin/_menu.php create mode 100644 src/views/admin/_profile.php create mode 100644 src/views/admin/_user.php create mode 100644 src/views/admin/create.php create mode 100644 src/views/admin/index.php create mode 100644 src/views/admin/update.php create mode 100644 src/views/gdpr/delete.php create mode 100644 src/views/gdpr/index.php create mode 100644 src/views/mail/confirmation-text.php create mode 100644 src/views/mail/confirmation.php create mode 100644 src/views/mail/generated_password-text.php create mode 100644 src/views/mail/generated_password.php create mode 100644 src/views/mail/layouts/html.php create mode 100644 src/views/mail/recovery-text.php create mode 100644 src/views/mail/recovery.php create mode 100644 src/views/mail/welcome-text.php create mode 100644 src/views/mail/welcome.php create mode 100644 src/views/message.php create mode 100644 src/views/recovery/request.php create mode 100644 src/views/recovery/reset.php create mode 100644 src/views/registration/register.php create mode 100644 src/views/registration/resend.php create mode 100644 src/views/security/login.php create mode 100644 src/views/settings/_menu.php create mode 100644 src/views/settings/account.php create mode 100644 src/views/settings/profile.php create mode 100644 src/widgets/Login.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5bf66b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +build +composer.lock +docs +vendor + +# cache directories +Thumbs.db +*.DS_Store +*.empty + +#phpstorm project files +.idea + +#netbeans project files +nbproject + +#eclipse, zend studio, aptana or other eclipse like project files +.buildpath +.project +.settings + + +# mac deployment helpers +switch +index \ No newline at end of file diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..3e49c85 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,98 @@ +# yii2-user Project Guidelines + +## Project Overview +- **Package**: cgsmith/yii2-user v1.0.0 +- **Type**: Yii2 user management extension module +- **PHP Version**: 8.2+ (strict requirement) +- **Yii2 Version**: 2.0.45+ +- **Namespace**: `cgsmith\user\*` +- **Architecture**: Service-oriented with dependency injection + +## Code Style +- `declare(strict_types=1);` required on all PHP files +- PSR-4 autoloading +- Constructor property promotion for DI +- PHPDoc comments on classes and public methods +- Typed properties and return types throughout +- PHPStan for static analysis + +## Directory Structure +``` +src/ +├── commands/ # Console commands (UserController, MigrateFromDektriumController) +├── contracts/ # Interfaces (UserInterface) +├── controllers/ # Web controllers (Security, Registration, Settings, Admin, Recovery, Gdpr) +├── events/ # Event classes (FormEvent, UserEvent, RegistrationEvent) +├── filters/ # Access control (AccessRule) +├── helpers/ # Utilities (Password) +├── messages/ # i18n translations +├── migrations/ # Database migrations +├── models/ # ActiveRecord + Form models +│ └── query/ # Custom query builders +├── services/ # Business logic (User, Registration, Recovery, Token, Mailer) +├── views/ # View templates +├── Bootstrap.php # Module bootstrap +└── Module.php # Main module class +``` + +## Key Patterns + +### Service Layer +Business logic lives in services, not controllers: +- `UserService` - User CRUD, block/unblock +- `RegistrationService` - Registration workflow with transactions +- `RecoveryService` - Password recovery flow +- `TokenService` - Token generation/validation +- `MailerService` - Email sending abstraction + +### Custom Query Builders +Fluent interface methods in `models/query/`: +- `active()`, `confirmed()`, `unconfirmed()`, `blocked()`, `pending()` +- `canLogin()`, `byEmail()`, `byUsername()` + +### Event System +Controllers trigger events for extensibility: +- `EVENT_BEFORE_LOGIN`, `EVENT_AFTER_LOGIN` +- `EVENT_BEFORE_REGISTER`, `EVENT_AFTER_REGISTER` +- `EVENT_BEFORE_CONFIRM`, `EVENT_AFTER_CONFIRM` + +### Model Customization +Override models via `modelMap` configuration in Module. + +## Database Tables +- `user` - User accounts with status, IP tracking, timestamps +- `user_profile` - Extended user info (bio, avatar, gravatar) +- `user_token` - Confirmation and recovery tokens +- `user_social_account` - Reserved for future OAuth + +## Console Commands +```bash +php yii user/create [password] +php yii user/delete +php yii user/confirm +php yii user/password [password] +php yii user/block +php yii user/unblock +php yii migrate-from-dektrium/migrate +``` + +## Testing +- PHPUnit 10.0+ configured +- PHPStan 1.10+ for static analysis +- Run tests: `./vendor/bin/phpunit` +- Run static analysis: `./vendor/bin/phpstan analyse` + +## Important Entry Points +- `Module.php` - Configuration and model factory +- `Bootstrap.php` - DI container bindings and route registration +- `controllers/SecurityController.php` - Login/logout +- `controllers/RegistrationController.php` - User registration +- `models/User.php` - Core user model implementing IdentityInterface + +## When Making Changes +1. Maintain strict typing with proper return types +2. Use service layer for business logic, keep controllers thin +3. Trigger events for extensibility +4. Use custom query methods for database queries +5. Support model map configuration for overridable models +6. Add PHPDoc for new classes/public methods \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e8ba0aa --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Chris Smith + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea9d1c5 --- /dev/null +++ b/README.md @@ -0,0 +1,462 @@ +# cgsmith/yii2-user + +A modern, actively maintained user management module for Yii2. Built as a spiritual successor to dektrium/yii2-user and +2amigos/yii2-usuario, designed for PHP 8.2+ with strict typing and modern practices. + +## Requirements + +- PHP 8.2 or higher +- Yii2 2.0.45 or higher + +## Installation + +### Via Composer (when published) + +```bash +composer require cgsmith/yii2-user +``` + +### Local Development + +Add to your `composer.json`: + +```json +{ + "autoload": { + "psr-4": { + "cgsmith\\user\\": "common/modules/user/src/" + } + } +} +``` + +Then run: + +```bash +composer dump-autoload +``` + +## Configuration + +### Web Application + +```php +return [ + 'bootstrap' => ['log', 'cgsmith\user\Bootstrap'], + 'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'enableRegistration' => true, + 'enableConfirmation' => true, + 'enablePasswordRecovery' => true, + 'admins' => ['admin@example.com'], + 'mailer' => [ + 'sender' => ['noreply@example.com' => 'My Application'], + ], + ], + ], +]; +``` + +### Console Application + +```php +return [ + 'bootstrap' => ['log', 'cgsmith\user\Bootstrap'], + 'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + ], + ], +]; +``` + +## Feature Comparison + +| Feature | dektrium/yii2-user | 2amigos/yii2-usuario | cgsmith/yii2-user | +|------------------|--------------------|----------------------|-------------------| +| **Status** | Abandoned (2018) | Abandoned (2022) | Active | +| **PHP Version** | 5.6+ | 7.1+ | 8.2+ | +| **Strict Types** | No | Partial | Yes | +| **Yii2 Version** | 2.0.6+ | 2.0.13+ | 2.0.45+ | + +### Core Features + +| Feature | dektrium | usuario | cgsmith | +|--------------------|:--------:|:-------:|:-------:| +| User Registration | ✅ | ✅ | ✅ | +| Email Confirmation | ✅ | ✅ | ✅ | +| Password Recovery | ✅ | ✅ | ✅ | +| Account Settings | ✅ | ✅ | ✅ | +| Profile Management | ✅ | ✅ | ✅ | +| Admin Panel | ✅ | ✅ | ✅ | +| User Blocking | ✅ | ✅ | ✅ | +| RBAC Integration | ✅ | ✅ | ✅ | +| Model Overriding | ✅ | ✅ | ✅ | +| Controller Events | ✅ | ✅ | ✅ | +| i18n Support | ✅ | ✅ | ✅ | + +### Security Features + +| Feature | dektrium | usuario | cgsmith | +|--------------------------|:--------:|:-------:|:-------:| +| Secure Password Hashing | ✅ | ✅ | ✅ | +| Configurable bcrypt Cost | ✅ | ✅ | ✅ | +| Token-based Confirmation | ✅ | ✅ | ✅ | +| Token Expiration | ✅ | ✅ | ✅ | +| IP Logging | ✅ | ✅ | ✅ | +| Last Login Tracking | ✅ | ✅ | ✅ | +| Email Change Strategies | ✅ | ✅ | ✅ | +| CSRF Protection | ✅ | ✅ | ✅ | + +### Advanced Features + +| Feature | dektrium | usuario | cgsmith | +|-------------------------|:--------:|:-------:|:-------:| +| Social Authentication | ✅ | ✅ | 🔄 v2 | +| Two-Factor Auth (2FA) | ❌ | ❌ | 🔄 v2 | +| GDPR Compliance | ❌ | ✅ | 🔄 v2 | +| Data Export | ❌ | ✅ | 🔄 v2 | +| Account Deletion | ❌ | ✅ | 🔄 v2 | +| User Impersonation | ✅ | ✅ | ✅ | +| Gravatar Support | ✅ | ✅ | ✅ | +| Avatar Upload | ❌ | ❌ | ✅ | +| Migration from dektrium | N/A | ✅ | ✅ | +| Migration from usuario | N/A | N/A | ✅ | + +### Architecture + +| Feature | dektrium | usuario | cgsmith | +|----------------------|:--------:|:-------:|:-------:| +| Service Layer | ❌ | Partial | ✅ | +| Dependency Injection | ❌ | Partial | ✅ | +| Interface Contracts | ❌ | ❌ | ✅ | +| Custom Query Classes | ❌ | ❌ | ✅ | +| Event-driven Design | ✅ | ✅ | ✅ | + +## Configuration Options + +| Option | Type | Default | Description | +|---------------------------|--------|-----------------------------------------|-------------------------------------| +| `enableRegistration` | bool | `true` | Enable/disable user registration | +| `enableConfirmation` | bool | `true` | Require email confirmation | +| `enableUnconfirmedLogin` | bool | `false` | Allow login without confirmation | +| `enablePasswordRecovery` | bool | `true` | Enable password recovery | +| `enableGdpr` | bool | `false` | Enable GDPR features (v2) | +| `enableImpersonation` | bool | `true` | Enable admin impersonation | +| `enableGeneratedPassword` | bool | `false` | Auto-generate passwords | +| `enableGravatar` | bool | `true` | Enable Gravatar support | +| `enableAvatarUpload` | bool | `true` | Enable local avatar uploads | +| `emailChangeStrategy` | int | `1` | Email change strategy (0-2) | +| `rememberFor` | int | `1209600` | Remember me duration (seconds) | +| `confirmWithin` | int | `86400` | Confirmation token expiry (seconds) | +| `recoverWithin` | int | `21600` | Recovery token expiry (seconds) | +| `minPasswordLength` | int | `8` | Minimum password length | +| `maxPasswordLength` | int | `72` | Maximum password length | +| `cost` | int | `12` | bcrypt cost parameter | +| `admins` | array | `[]` | Admin email addresses | +| `adminPermission` | string | `null` | RBAC permission for admin access | +| `impersonatePermission` | string | `null` | RBAC permission for impersonation | +| `urlPrefix` | string | `'user'` | URL prefix for module routes | +| `avatarPath` | string | `'@webroot/uploads/avatars'` | Avatar storage path | +| `avatarUrl` | string | `'@web/uploads/avatars'` | Avatar URL path | +| `maxAvatarSize` | int | `2097152` | Max avatar file size (bytes) | +| `avatarExtensions` | array | `['jpg', 'jpeg', 'png', 'gif', 'webp']` | Allowed avatar extensions | + +## Console Commands + +```bash +# Create a new user +php yii user/create admin@example.com password + +# Confirm a user +php yii user/confirm admin@example.com + +# Delete a user +php yii user/delete admin@example.com + +# Migrate from dektrium/yii2-user +php yii migrate-from-dektrium/migrate +``` + +## Model Overriding + +```php +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'modelMap' => [ + 'User' => 'app\models\User', + 'Profile' => 'app\models\Profile', + 'RegistrationForm' => 'app\models\RegistrationForm', + ], + ], +], +``` + +## Event Handling + +```php +'modules' => [ + 'user' => [ + 'class' => 'cgsmith\user\Module', + 'controllerMap' => [ + 'registration' => [ + 'class' => 'cgsmith\user\controllers\RegistrationController', + 'on afterRegister' => ['app\handlers\UserHandler', 'onRegister'], + ], + ], + ], +], +``` + +Available events: + +- `RegistrationController::EVENT_BEFORE_REGISTER` +- `RegistrationController::EVENT_AFTER_REGISTER` +- `RegistrationController::EVENT_BEFORE_CONFIRM` +- `RegistrationController::EVENT_AFTER_CONFIRM` +- `SecurityController::EVENT_BEFORE_LOGIN` +- `SecurityController::EVENT_AFTER_LOGIN` +- `SecurityController::EVENT_BEFORE_LOGOUT` +- `SecurityController::EVENT_AFTER_LOGOUT` +- `RecoveryController::EVENT_BEFORE_REQUEST` +- `RecoveryController::EVENT_AFTER_REQUEST` +- `RecoveryController::EVENT_BEFORE_RESET` +- `RecoveryController::EVENT_AFTER_RESET` + +## View Customization + +Override views by setting up theme path mapping: + +```php +'components' => [ + 'view' => [ + 'theme' => [ + 'pathMap' => [ + '@cgsmith/user/views' => '@app/views/user', + ], + ], + ], +], +``` + +## GDPR Features (Coming in v2) + +GDPR compliance features are planned for v2. When complete, users will be able to: + +- Export all their personal data as JSON +- Request account deletion with soft-delete support +- View what data is stored about them +- Manage consent preferences + +See the [v2 Roadmap](#v2-roadmap) for more details. + +## Migration from dektrium/yii2-user + +1. Install cgsmith/yii2-user +2. Update your configuration to use the new module +3. Run the migration command: + +```bash +php yii migrate-from-dektrium/migrate +``` + +This will: + +- Migrate existing user data +- Convert token formats +- Preserve all user relationships +- Backup original tables as `user_dektrium_backup`, `profile_dektrium_backup`, `token_dektrium_backup` + +### Custom Field Migration + +**Important:** If you added custom columns to the original dektrium user table (e.g., `developer_id`, `company_id`, `department`, etc.), these fields will **not** be automatically migrated to the new user table. You must create a separate migration to: + +1. Add the custom column(s) to the new `user` table +2. Copy the data from the backup table using email matching + +Example migration for a custom `developer_id` field: + +```php +addColumn('{{%user}}', 'developer_id', $this->integer()->null()->defaultValue(0)); + $this->createIndex('idx-user-developer_id', '{{%user}}', 'developer_id'); + + // Copy data from backup table based on email match + if ($this->db->schema->getTableSchema('{{%user_dektrium_backup}}') !== null) { + $this->execute(" + UPDATE {{%user}} u + INNER JOIN {{%user_dektrium_backup}} b ON u.email = b.email + SET u.developer_id = b.developer_id + WHERE b.developer_id IS NOT NULL AND b.developer_id != 0 + "); + } + + return true; + } + + public function safeDown() + { + $this->dropIndex('idx-user-developer_id', '{{%user}}'); + $this->dropColumn('{{%user}}', 'developer_id'); + return true; + } +} +``` + +After migration, update your custom User model to include the new field: + +```php +class User extends \cgsmith\user\models\User +{ + // Your custom field will be accessible as $user->developer_id +} +``` + +## v2 Roadmap + +The following features are planned for version 2.0: + +### Authentication & Security + +- [ ] **Two-Factor Authentication (2FA)** + - TOTP (Google Authenticator, Authy) + - SMS verification + - Backup codes + - Per-user 2FA enforcement +- [ ] **Passwordless Authentication** + - Magic link login + - WebAuthn/FIDO2 support +- [ ] **Enhanced Session Management** + - View active sessions + - Remote session termination + - Device fingerprinting + +### Social Authentication + +- [ ] **OAuth2 Provider Integration** + - Google + - GitHub + - Facebook + - Apple + - Microsoft + - Custom providers via configuration +- [ ] **Account Linking** + - Link multiple social accounts + - Unlink social accounts + - Primary account selection + +### Security Hardening + +- [ ] **Brute Force Protection** + - Rate limiting per IP/user + - Progressive delays + - CAPTCHA integration (reCAPTCHA v3, hCaptcha) +- [ ] **Password Policies** + - Password strength meter + - Common password blocklist + - Password history (prevent reuse) + - Password expiration +- [ ] **Security Audit Log** + - Login attempts + - Password changes + - Security setting changes + - Admin actions + +### User Experience + +- [ ] **Registration Improvements** + - Multi-step registration wizard + - Progressive profiling + - Username suggestions +- [ ] **Email Templates** + - HTML email templates + - Template customization via admin + - Email preview +- [ ] **Admin Dashboard** + - User statistics + - Registration trends + - Login analytics + - Security alerts + +### API & Integration + +- [ ] **REST API** + - Token-based authentication + - OAuth2 server implementation + - API rate limiting + - Swagger/OpenAPI documentation +- [ ] **Webhooks** + - User registered + - User verified + - Password changed + - Account deleted + +### Infrastructure + +- [ ] **Queue Support** + - Asynchronous email sending + - Background token cleanup +- [ ] **Cache Integration** + - Session caching + - User data caching + - Token validation caching +- [ ] **Multi-tenancy Support** + - Tenant-based user isolation + - Custom domains per tenant + +### Migration Tools + +- [ ] **Smart Migration from dektrium/yii2-user** + - Auto-detect custom columns added to user table + - Interactive migration wizard for custom fields + - Schema diff reporting before migration + - Automatic migration script generation for custom fields + - Support for foreign key relationship preservation + - Rollback support with data integrity checks + +- [ ] **Smart Migration from 2amigos/yii2-usuario** + - Auto-detect custom columns added to user table + - Interactive migration wizard for custom fields + - Schema diff reporting before migration + - Automatic migration script generation for custom fields + - Support for foreign key relationship preservation + - Rollback support with data integrity checks + +### Compliance + +- [ ] **Enhanced GDPR** + - Right to be forgotten workflow + - Data retention policies + - Consent management + - Cookie consent integration +- [ ] **Accessibility** + - WCAG 2.1 AA compliance + - Screen reader support + - Keyboard navigation + +## Contributing + +Contributions are welcome! Please read our contributing guidelines before submitting pull requests. + +## License + +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. + +## Credits + +This module draws inspiration from: + +- [dektrium/yii2-user](https://github.com/dektrium/yii2-user) - The original Yii2 user module +- [2amigos/yii2-usuario](https://github.com/2amigos/yii2-usuario) - A maintained fork with improvements + +Special thanks to the original authors and contributors of these projects. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3703417 --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "cgsmith/yii2-user", + "description": "Modern user management module for Yii2", + "type": "yii2-extension", + "keywords": ["yii2", "user", "authentication", "authorization", "rbac", "gdpr"], + "license": "MIT", + "authors": [ + { + "name": "Chris Smith", + "email": "cgsmith105@gmail.com" + } + ], + "support": { + "issues": "https://github.com/cgsmith/yii2-user/issues", + "source": "https://github.com/cgsmith/yii2-user" + }, + "require": { + "php": ">=8.2", + "yiisoft/yii2": "~2.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "phpstan/phpstan": "^1.10" + }, + "autoload": { + "psr-4": { + "cgsmith\\user\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "cgsmith\\user\\tests\\": "tests/" + } + }, + "extra": { + "bootstrap": "cgsmith\\user\\Bootstrap" + }, + "repositories": [ + { + "type": "composer", + "url": "https://asset-packagist.org" + } + ], + "config": { + "sort-packages": true, + "allow-plugins": { + "yiisoft/yii2-composer": true + } + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..38c3e9f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,32 @@ + + + + + tests/unit + + + tests/functional + + + + + + src + + + + + + + + \ No newline at end of file diff --git a/src/Bootstrap.php b/src/Bootstrap.php new file mode 100644 index 0000000..bb27e94 --- /dev/null +++ b/src/Bootstrap.php @@ -0,0 +1,156 @@ +getModule('user'); + + if ($module === null) { + return; + } + + if ($app instanceof ConsoleApplication) { + $this->bootstrapConsole($app, $module); + } elseif ($app instanceof WebApplication) { + $this->bootstrapWeb($app, $module); + } + + $this->registerContainerBindings($module); + } + + /** + * Bootstrap for web application. + */ + protected function bootstrapWeb(WebApplication $app, Module $module): void + { + $prefix = $module->urlPrefix; + $moduleId = $module->id; + + $rules = []; + foreach ($this->getUrlRules($module) as $pattern => $route) { + $rules["{$prefix}/{$pattern}"] = "{$moduleId}/{$route}"; + } + + $app->urlManager->addRules($rules, false); + } + + /** + * Bootstrap for console application. + */ + protected function bootstrapConsole(ConsoleApplication $app, Module $module): void + { + if (!isset($app->controllerMap['user'])) { + $app->controllerMap['user'] = [ + 'class' => 'cgsmith\user\commands\UserController', + 'module' => $module, + ]; + } + + if (!isset($app->controllerMap['migrate-from-dektrium'])) { + $app->controllerMap['migrate-from-dektrium'] = [ + 'class' => 'cgsmith\user\commands\MigrateFromDektriumController', + 'module' => $module, + ]; + } + } + + /** + * Register container bindings for dependency injection. + */ + protected function registerContainerBindings(Module $module): void + { + $container = Yii::$container; + + // Bind services + $container->setSingleton('cgsmith\user\services\UserService', function () use ($module) { + return new \cgsmith\user\services\UserService($module); + }); + + $container->setSingleton('cgsmith\user\services\RegistrationService', function () use ($module) { + return new \cgsmith\user\services\RegistrationService($module); + }); + + $container->setSingleton('cgsmith\user\services\RecoveryService', function () use ($module) { + return new \cgsmith\user\services\RecoveryService($module); + }); + + $container->setSingleton('cgsmith\user\services\TokenService', function () use ($module) { + return new \cgsmith\user\services\TokenService($module); + }); + + $container->setSingleton('cgsmith\user\services\MailerService', function () use ($module) { + return new \cgsmith\user\services\MailerService($module); + }); + + // Bind module for injection + $container->setSingleton(Module::class, function () use ($module) { + return $module; + }); + } + + /** + * Get URL rules for the module. + */ + protected function getUrlRules(Module $module): array + { + $rules = [ + // Security + 'login' => 'security/login', + 'logout' => 'security/logout', + + // Registration + 'register' => 'registration/register', + 'confirm//' => 'registration/confirm', + 'resend' => 'registration/resend', + + // Password Recovery + 'recovery' => 'recovery/request', + 'recovery//' => 'recovery/reset', + + // Settings + 'settings' => 'settings/account', + 'settings/account' => 'settings/account', + 'settings/profile' => 'settings/profile', + + // Admin + 'admin' => 'admin/index', + 'admin/index' => 'admin/index', + 'admin/create' => 'admin/create', + 'admin/update/' => 'admin/update', + 'admin/delete/' => 'admin/delete', + 'admin/block/' => 'admin/block', + 'admin/unblock/' => 'admin/unblock', + 'admin/confirm/' => 'admin/confirm', + 'admin/impersonate/' => 'admin/impersonate', + ]; + + // GDPR routes + if ($module->enableGdpr) { + $rules['gdpr'] = 'gdpr/index'; + $rules['gdpr/export'] = 'gdpr/export'; + $rules['gdpr/delete'] = 'gdpr/delete'; + } + + return $rules; + } +} diff --git a/src/Module.php b/src/Module.php new file mode 100644 index 0000000..0da691c --- /dev/null +++ b/src/Module.php @@ -0,0 +1,428 @@ + 'cgsmith\user\models\User', + 'Profile' => 'cgsmith\user\models\Profile', + 'Token' => 'cgsmith\user\models\Token', + 'LoginForm' => 'cgsmith\user\models\LoginForm', + 'RegistrationForm' => 'cgsmith\user\models\RegistrationForm', + 'RecoveryForm' => 'cgsmith\user\models\RecoveryForm', + 'RecoveryResetForm' => 'cgsmith\user\models\RecoveryResetForm', + 'SettingsForm' => 'cgsmith\user\models\SettingsForm', + 'UserSearch' => 'cgsmith\user\models\UserSearch', + ]; + + /** + * {@inheritdoc} + */ + public function init(): void + { + parent::init(); + + $this->registerTranslations(); + + if (Yii::$app instanceof ConsoleApplication) { + $this->controllerNamespace = 'cgsmith\user\commands'; + } + } + + /** + * {@inheritdoc} + */ + public function bootstrap($app): void + { + if ($app instanceof WebApplication) { + $this->bootstrapWeb($app); + } elseif ($app instanceof ConsoleApplication) { + $this->bootstrapConsole($app); + } + + $this->registerContainerBindings(); + } + + /** + * Bootstrap for web application. + */ + protected function bootstrapWeb(WebApplication $app): void + { + $prefix = $this->urlPrefix; + $moduleId = $this->id; + + $rules = []; + foreach ($this->getUrlRules() as $pattern => $route) { + $rules["{$prefix}/{$pattern}"] = "{$moduleId}/{$route}"; + } + + $app->urlManager->addRules($rules, false); + + $this->configureUserComponent($app); + } + + /** + * Configure the user component's identityClass if not already set. + */ + protected function configureUserComponent(WebApplication $app): void + { + $identityClass = $this->identityClass ?? $this->getModelClass('User'); + + if ($app->has('user', true)) { + $user = $app->get('user'); + if ($user->identityClass === null) { + $user->identityClass = $identityClass; + } + } else { + $app->set('user', [ + 'class' => 'yii\web\User', + 'identityClass' => $identityClass, + 'enableAutoLogin' => true, + 'loginUrl' => ['/' . $this->urlPrefix . '/login'], + ]); + } + } + + /** + * Bootstrap for console application. + */ + protected function bootstrapConsole(ConsoleApplication $app): void + { + if (!isset($app->controllerMap['user'])) { + $app->controllerMap['user'] = [ + 'class' => 'cgsmith\user\commands\UserController', + 'module' => $this, + ]; + } + + if (!isset($app->controllerMap['migrate-from-dektrium'])) { + $app->controllerMap['migrate-from-dektrium'] = [ + 'class' => 'cgsmith\user\commands\MigrateFromDektriumController', + 'module' => $this, + ]; + } + } + + /** + * Register container bindings for dependency injection. + */ + protected function registerContainerBindings(): void + { + $container = Yii::$container; + + $container->setSingleton('cgsmith\user\services\UserService', function () { + return new \cgsmith\user\services\UserService($this); + }); + + $container->setSingleton('cgsmith\user\services\RegistrationService', function () { + return new \cgsmith\user\services\RegistrationService($this); + }); + + $container->setSingleton('cgsmith\user\services\RecoveryService', function () { + return new \cgsmith\user\services\RecoveryService($this); + }); + + $container->setSingleton('cgsmith\user\services\TokenService', function () { + return new \cgsmith\user\services\TokenService($this); + }); + + $container->setSingleton('cgsmith\user\services\MailerService', function () { + return new \cgsmith\user\services\MailerService($this); + }); + + $container->setSingleton(Module::class, function () { + return $this; + }); + } + + /** + * Get URL rules for the module. + */ + protected function getUrlRules(): array + { + $rules = [ + 'login' => 'security/login', + 'logout' => 'security/logout', + 'register' => 'registration/register', + 'confirm//' => 'registration/confirm', + 'resend' => 'registration/resend', + 'recovery' => 'recovery/request', + 'recovery//' => 'recovery/reset', + 'settings' => 'settings/account', + 'settings/account' => 'settings/account', + 'settings/profile' => 'settings/profile', + 'admin' => 'admin/index', + 'admin/index' => 'admin/index', + 'admin/create' => 'admin/create', + 'admin/update/' => 'admin/update', + 'admin/delete/' => 'admin/delete', + 'admin/block/' => 'admin/block', + 'admin/unblock/' => 'admin/unblock', + 'admin/confirm/' => 'admin/confirm', + 'admin/impersonate/' => 'admin/impersonate', + ]; + + if ($this->enableGdpr) { + $rules['gdpr'] = 'gdpr/index'; + $rules['gdpr/export'] = 'gdpr/export'; + $rules['gdpr/delete'] = 'gdpr/delete'; + } + + return $rules; + } + + /** + * Get version string. + */ + public function getVersion(): string + { + return self::VERSION; + } + + /** + * Get model class from the model map. + */ + public function getModelClass(string $name): string + { + $map = array_merge($this->defaultModelMap, $this->modelMap); + + if (!isset($map[$name])) { + throw new \InvalidArgumentException("Unknown model: {$name}"); + } + + return $map[$name]; + } + + /** + * Create a model instance. + * + * @template T of object + * @param string $name Model name from the model map + * @param array $config Configuration array + * @return T + */ + public function createModel(string $name, array $config = []): object + { + $class = $this->getModelClass($name); + $config['class'] = $class; + + return Yii::createObject($config); + } + + /** + * Get the mailer sender configuration. + */ + public function getMailerSender(): array + { + return $this->mailer['sender'] ?? [Yii::$app->params['adminEmail'] ?? 'noreply@example.com' => Yii::$app->name]; + } + + /** + * Register translation messages. + */ + protected function registerTranslations(): void + { + if (!isset(Yii::$app->i18n->translations['user'])) { + Yii::$app->i18n->translations['user'] = [ + 'class' => 'yii\i18n\PhpMessageSource', + 'sourceLanguage' => 'en-US', + 'basePath' => __DIR__ . '/messages', + ]; + } + } +} diff --git a/src/commands/MigrateFromDektriumController.php b/src/commands/MigrateFromDektriumController.php new file mode 100644 index 0000000..466b2f6 --- /dev/null +++ b/src/commands/MigrateFromDektriumController.php @@ -0,0 +1,440 @@ +stdout("=== Dektrium to cgsmith/yii2-user Migration Preview ===\n\n", Console::FG_CYAN); + + $db = $this->getDb(); + + // Check if dektrium tables exist + $dektriumTables = $this->checkDektriumTables($db); + + if (empty($dektriumTables)) { + $this->stdout("No dektrium tables found. Nothing to migrate.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + + $this->stdout("Found dektrium tables:\n", Console::FG_GREEN); + foreach ($dektriumTables as $table => $count) { + $this->stdout(" - {$table}: {$count} rows\n"); + } + + $this->stdout("\nMigration will:\n", Console::FG_CYAN); + $this->stdout(" 1. Create new tables (user_new, user_profile_new, user_token_new)\n"); + $this->stdout(" 2. Transform and copy data with these conversions:\n"); + $this->stdout(" - confirmed_at (int) -> email_confirmed_at (datetime)\n"); + $this->stdout(" - blocked_at (int) -> blocked_at (datetime) + status='blocked'\n"); + $this->stdout(" - created_at/updated_at (int) -> datetime\n"); + $this->stdout(" - token.type (int) -> ENUM('confirmation','recovery','email_change')\n"); + $this->stdout(" - profile table -> user_profile table\n"); + $this->stdout(" 3. Backup original tables (user -> user_dektrium_backup, etc.)\n"); + $this->stdout(" 4. Rename new tables to production names\n"); + + $this->stdout("\nRun 'yii migrate-from-dektrium/execute' to proceed.\n", Console::FG_YELLOW); + + return ExitCode::OK; + } + + /** + * Execute migration. + */ + public function actionExecute(): int + { + $this->stdout("=== Executing Dektrium Migration ===\n\n", Console::FG_CYAN); + + if (!$this->confirm('This will modify your database. Have you backed up your data?')) { + $this->stdout("Migration cancelled.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + + $db = $this->getDb(); + $transaction = $db->beginTransaction(); + + try { + // Step 1: Check dektrium tables exist + $dektriumTables = $this->checkDektriumTables($db); + + if (empty($dektriumTables)) { + $this->stdout("No dektrium tables found. Nothing to migrate.\n", Console::FG_YELLOW); + $transaction->rollBack(); + return ExitCode::OK; + } + + // Step 2: Create new tables + $this->stdout("Creating new tables...\n"); + $this->createNewTables($db); + + // Step 3: Migrate users + $this->stdout("Migrating users...\n"); + $userCount = $this->migrateUsers($db); + $this->stdout(" Migrated {$userCount} users\n", Console::FG_GREEN); + + // Step 4: Migrate profiles + $this->stdout("Migrating profiles...\n"); + $profileCount = $this->migrateProfiles($db); + $this->stdout(" Migrated {$profileCount} profiles\n", Console::FG_GREEN); + + // Step 5: Migrate tokens + $this->stdout("Migrating tokens...\n"); + $tokenCount = $this->migrateTokens($db); + $this->stdout(" Migrated {$tokenCount} tokens\n", Console::FG_GREEN); + + // Step 6: Backup and swap tables + $this->stdout("Backing up original tables...\n"); + $this->backupAndSwapTables($db); + + $transaction->commit(); + + $this->stdout("\n=== Migration completed successfully! ===\n", Console::FG_GREEN); + $this->stdout("Original tables backed up as: user_dektrium_backup, profile_dektrium_backup, token_dektrium_backup\n"); + $this->stdout("\nNext steps:\n"); + $this->stdout(" 1. Update your config to use cgsmith\\user\\Module\n"); + $this->stdout(" 2. Update model imports from dektrium\\user to cgsmith\\user\n"); + $this->stdout(" 3. Test your application thoroughly\n"); + $this->stdout(" 4. Once verified, you can drop the backup tables\n"); + + return ExitCode::OK; + } catch (\Exception $e) { + $transaction->rollBack(); + $this->stderr("Migration failed: " . $e->getMessage() . "\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + } + + /** + * Rollback migration. + */ + public function actionRollback(): int + { + $this->stdout("=== Rolling Back Migration ===\n\n", Console::FG_CYAN); + + if (!$this->confirm('This will restore the original dektrium tables and remove cgsmith tables. Continue?')) { + $this->stdout("Rollback cancelled.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + + $db = $this->getDb(); + $transaction = $db->beginTransaction(); + + try { + // Check if backup tables exist + $schema = $db->schema; + $hasUserBackup = $schema->getTableSchema('user_dektrium_backup') !== null; + $hasProfileBackup = $schema->getTableSchema('profile_dektrium_backup') !== null; + $hasTokenBackup = $schema->getTableSchema('token_dektrium_backup') !== null; + + if (!$hasUserBackup) { + $this->stdout("No backup tables found. Cannot rollback.\n", Console::FG_YELLOW); + $transaction->rollBack(); + return ExitCode::OK; + } + + // Drop new tables + $this->stdout("Dropping new tables...\n"); + $db->createCommand("DROP TABLE IF EXISTS {{%user_token}}")->execute(); + $db->createCommand("DROP TABLE IF EXISTS {{%user_profile}}")->execute(); + $db->createCommand("DROP TABLE IF EXISTS {{%user_social_account}}")->execute(); + $db->createCommand("DROP TABLE IF EXISTS {{%user}}")->execute(); + + // Restore backup tables + $this->stdout("Restoring backup tables...\n"); + $db->createCommand("RENAME TABLE {{%user_dektrium_backup}} TO {{%user}}")->execute(); + + if ($hasProfileBackup) { + $db->createCommand("RENAME TABLE {{%profile_dektrium_backup}} TO {{%profile}}")->execute(); + } + + if ($hasTokenBackup) { + $db->createCommand("RENAME TABLE {{%token_dektrium_backup}} TO {{%token}}")->execute(); + } + + $transaction->commit(); + + $this->stdout("\n=== Rollback completed successfully! ===\n", Console::FG_GREEN); + + return ExitCode::OK; + } catch (\Exception $e) { + $transaction->rollBack(); + $this->stderr("Rollback failed: " . $e->getMessage() . "\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + } + + /** + * Check if dektrium tables exist. + */ + protected function checkDektriumTables(Connection $db): array + { + $tables = []; + $schema = $db->schema; + + // Check user table with dektrium schema (has flags column) + $userTable = $schema->getTableSchema('user'); + if ($userTable !== null && isset($userTable->columns['flags'])) { + $count = $db->createCommand("SELECT COUNT(*) FROM {{%user}}")->queryScalar(); + $tables['user'] = (int) $count; + } + + // Check profile table + $profileTable = $schema->getTableSchema('profile'); + if ($profileTable !== null) { + $count = $db->createCommand("SELECT COUNT(*) FROM {{%profile}}")->queryScalar(); + $tables['profile'] = (int) $count; + } + + // Check token table (dektrium uses smallint type) + $tokenTable = $schema->getTableSchema('token'); + if ($tokenTable !== null && isset($tokenTable->columns['type']) && $tokenTable->columns['type']->phpType === 'integer') { + $count = $db->createCommand("SELECT COUNT(*) FROM {{%token}}")->queryScalar(); + $tables['token'] = (int) $count; + } + + return $tables; + } + + /** + * Create new tables for cgsmith/yii2-user. + */ + protected function createNewTables(Connection $db): void + { + $tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB'; + + // User table + $db->createCommand(" + CREATE TABLE {{%user_new}} ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + username VARCHAR(255) UNIQUE, + password_hash VARCHAR(255) NOT NULL, + auth_key VARCHAR(32) NOT NULL, + status ENUM('pending', 'active', 'blocked') NOT NULL DEFAULT 'pending', + email_confirmed_at DATETIME NULL, + blocked_at DATETIME NULL, + last_login_at DATETIME NULL, + last_login_ip VARCHAR(45) NULL, + registration_ip VARCHAR(45) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + gdpr_consent_at DATETIME NULL, + gdpr_deleted_at DATETIME NULL, + INDEX idx_status (status), + INDEX idx_email_confirmed (email_confirmed_at) + ) {$tableOptions} + ")->execute(); + + // Profile table + $db->createCommand(" + CREATE TABLE {{%user_profile_new}} ( + user_id INT UNSIGNED PRIMARY KEY, + name VARCHAR(255) NULL, + bio TEXT NULL, + location VARCHAR(255) NULL, + website VARCHAR(255) NULL, + timezone VARCHAR(40) NULL, + avatar_path VARCHAR(255) NULL, + gravatar_email VARCHAR(255) NULL, + use_gravatar TINYINT(1) DEFAULT 1, + public_email VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) {$tableOptions} + ")->execute(); + + // Token table + $db->createCommand(" + CREATE TABLE {{%user_token_new}} ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NOT NULL, + type ENUM('confirmation', 'recovery', 'email_change') NOT NULL, + token VARCHAR(64) NOT NULL UNIQUE, + data JSON NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_type (user_id, type), + INDEX idx_expires (expires_at) + ) {$tableOptions} + ")->execute(); + } + + /** + * Migrate users from dektrium to new schema. + */ + protected function migrateUsers(Connection $db): int + { + return (int) $db->createCommand(" + INSERT INTO {{%user_new}} ( + id, email, username, password_hash, auth_key, + status, email_confirmed_at, blocked_at, + last_login_at, registration_ip, created_at, updated_at + ) + SELECT + id, email, username, password_hash, auth_key, + CASE + WHEN blocked_at IS NOT NULL THEN 'blocked' + WHEN confirmed_at IS NOT NULL THEN 'active' + ELSE 'pending' + END, + FROM_UNIXTIME(confirmed_at), + FROM_UNIXTIME(blocked_at), + FROM_UNIXTIME(last_login_at), + registration_ip, + FROM_UNIXTIME(created_at), + FROM_UNIXTIME(updated_at) + FROM {{%user}} + ")->execute(); + } + + /** + * Migrate profiles from dektrium to new schema. + */ + protected function migrateProfiles(Connection $db): int + { + $schema = $db->schema; + + if ($schema->getTableSchema('profile') === null) { + return 0; + } + + return (int) $db->createCommand(" + INSERT INTO {{%user_profile_new}} ( + user_id, name, bio, location, website, timezone, + gravatar_email, public_email, use_gravatar + ) + SELECT + user_id, name, bio, location, website, timezone, + gravatar_email, public_email, 1 + FROM {{%profile}} + ")->execute(); + } + + /** + * Migrate tokens from dektrium to new schema. + */ + protected function migrateTokens(Connection $db): int + { + $schema = $db->schema; + + if ($schema->getTableSchema('token') === null) { + return 0; + } + + // Dektrium token types: 0 = confirmation, 1 = recovery, 2 = confirm_new_email, 3 = confirm_old_email + // We map 2 and 3 to email_change + return (int) $db->createCommand(" + INSERT INTO {{%user_token_new}} ( + user_id, type, token, expires_at, created_at + ) + SELECT + user_id, + CASE type + WHEN 0 THEN 'confirmation' + WHEN 1 THEN 'recovery' + ELSE 'email_change' + END, + CONCAT(code, SUBSTRING(MD5(RAND()), 1, 32)), + DATE_ADD(FROM_UNIXTIME(created_at), INTERVAL 24 HOUR), + FROM_UNIXTIME(created_at) + FROM {{%token}} + ")->execute(); + } + + /** + * Backup original tables and swap with new ones. + */ + protected function backupAndSwapTables(Connection $db): void + { + $schema = $db->schema; + + // Backup user table + $db->createCommand("RENAME TABLE {{%user}} TO {{%user_dektrium_backup}}")->execute(); + + // Backup profile table if exists + if ($schema->getTableSchema('profile') !== null) { + $db->createCommand("RENAME TABLE {{%profile}} TO {{%profile_dektrium_backup}}")->execute(); + } + + // Backup token table if exists + if ($schema->getTableSchema('token') !== null) { + $db->createCommand("RENAME TABLE {{%token}} TO {{%token_dektrium_backup}}")->execute(); + } + + // Rename new tables to production names + $db->createCommand("RENAME TABLE {{%user_new}} TO {{%user}}")->execute(); + $db->createCommand("RENAME TABLE {{%user_profile_new}} TO {{%user_profile}}")->execute(); + $db->createCommand("RENAME TABLE {{%user_token_new}} TO {{%user_token}}")->execute(); + + // Add foreign keys + $db->createCommand(" + ALTER TABLE {{%user_profile}} + ADD CONSTRAINT fk_user_profile_user + FOREIGN KEY (user_id) REFERENCES {{%user}}(id) ON DELETE CASCADE + ")->execute(); + + $db->createCommand(" + ALTER TABLE {{%user_token}} + ADD CONSTRAINT fk_user_token_user + FOREIGN KEY (user_id) REFERENCES {{%user}}(id) ON DELETE CASCADE + ")->execute(); + + // Create social account table (empty, for v2.0) + $tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB'; + $db->createCommand(" + CREATE TABLE {{%user_social_account}} ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NULL, + provider VARCHAR(50) NOT NULL, + provider_id VARCHAR(255) NOT NULL, + data JSON NULL, + email VARCHAR(255) NULL, + username VARCHAR(255) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE INDEX idx_provider_id (provider, provider_id), + INDEX idx_user (user_id), + CONSTRAINT fk_user_social_account_user + FOREIGN KEY (user_id) REFERENCES {{%user}}(id) ON DELETE CASCADE + ) {$tableOptions} + ")->execute(); + } + + /** + * Get database connection. + */ + protected function getDb(): Connection + { + return Yii::$app->get($this->db); + } +} diff --git a/src/commands/UserController.php b/src/commands/UserController.php new file mode 100644 index 0000000..11821e9 --- /dev/null +++ b/src/commands/UserController.php @@ -0,0 +1,208 @@ + [password] Create a new user + * yii user/delete Delete a user + * yii user/password [password] Change user password + * yii user/confirm Confirm user email + * yii user/block Block a user + * yii user/unblock Unblock a user + */ +class UserController extends Controller +{ + /** + * @var Module + */ + public $module; + + /** + * Create a new user. + * + * @param string $email User email + * @param string|null $password Password (auto-generated if not provided) + */ + public function actionCreate(string $email, ?string $password = null): int + { + if ($password === null) { + $password = Password::generate(12); + $this->stdout("Generated password: {$password}\n", Console::FG_YELLOW); + } + + $user = new User(); + $user->email = $email; + $user->password = $password; + $user->status = User::STATUS_ACTIVE; + $user->email_confirmed_at = date('Y-m-d H:i:s'); + + if (!$user->save()) { + $this->stderr("Failed to create user:\n", Console::FG_RED); + foreach ($user->errors as $attribute => $errors) { + foreach ($errors as $error) { + $this->stderr(" - {$attribute}: {$error}\n"); + } + } + return ExitCode::UNSPECIFIED_ERROR; + } + + $this->stdout("User created successfully!\n", Console::FG_GREEN); + $this->stdout(" ID: {$user->id}\n"); + $this->stdout(" Email: {$user->email}\n"); + + return ExitCode::OK; + } + + /** + * Delete a user. + * + * @param string $email User email + */ + public function actionDelete(string $email): int + { + $user = User::findByEmail($email); + + if ($user === null) { + $this->stderr("User not found: {$email}\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if (!$this->confirm("Are you sure you want to delete user {$email}?")) { + return ExitCode::OK; + } + + if ($user->delete()) { + $this->stdout("User deleted successfully.\n", Console::FG_GREEN); + return ExitCode::OK; + } + + $this->stderr("Failed to delete user.\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + /** + * Change user password. + * + * @param string $email User email + * @param string|null $password New password (auto-generated if not provided) + */ + public function actionPassword(string $email, ?string $password = null): int + { + $user = User::findByEmail($email); + + if ($user === null) { + $this->stderr("User not found: {$email}\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if ($password === null) { + $password = Password::generate(12); + $this->stdout("Generated password: {$password}\n", Console::FG_YELLOW); + } + + if ($user->resetPassword($password)) { + $this->stdout("Password changed successfully.\n", Console::FG_GREEN); + return ExitCode::OK; + } + + $this->stderr("Failed to change password.\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + /** + * Confirm user email. + * + * @param string $email User email + */ + public function actionConfirm(string $email): int + { + $user = User::findByEmail($email); + + if ($user === null) { + $this->stderr("User not found: {$email}\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if ($user->getIsConfirmed()) { + $this->stdout("User is already confirmed.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + + if ($user->confirm()) { + $this->stdout("User confirmed successfully.\n", Console::FG_GREEN); + return ExitCode::OK; + } + + $this->stderr("Failed to confirm user.\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + /** + * Block a user. + * + * @param string $email User email + */ + public function actionBlock(string $email): int + { + $user = User::findByEmail($email); + + if ($user === null) { + $this->stderr("User not found: {$email}\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if ($user->getIsBlocked()) { + $this->stdout("User is already blocked.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + + if ($user->block()) { + $this->stdout("User blocked successfully.\n", Console::FG_GREEN); + return ExitCode::OK; + } + + $this->stderr("Failed to block user.\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + /** + * Unblock a user. + * + * @param string $email User email + */ + public function actionUnblock(string $email): int + { + $user = User::findByEmail($email); + + if ($user === null) { + $this->stderr("User not found: {$email}\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if (!$user->getIsBlocked()) { + $this->stdout("User is not blocked.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + + if ($user->unblock()) { + $this->stdout("User unblocked successfully.\n", Console::FG_GREEN); + return ExitCode::OK; + } + + $this->stderr("Failed to unblock user.\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } +} diff --git a/src/contracts/UserInterface.php b/src/contracts/UserInterface.php new file mode 100644 index 0000000..22814a8 --- /dev/null +++ b/src/contracts/UserInterface.php @@ -0,0 +1,33 @@ + [ + 'class' => AccessControl::class, + 'ruleConfig' => [ + 'class' => AccessRule::class, + ], + 'rules' => [ + [ + 'allow' => true, + 'actions' => ['stop-impersonate'], + 'matchCallback' => function () { + return Yii::$app->session->has(self::ORIGINAL_USER_SESSION_KEY); + }, + ], + ['allow' => true, 'roles' => ['admin']], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'delete' => ['post'], + 'block' => ['post'], + 'unblock' => ['post'], + 'confirm' => ['post'], + 'resend-password' => ['post'], + ], + ], + ]; + } + + /** + * List all users. + */ + public function actionIndex(): string + { + /** @var Module $module */ + $module = $this->module; + + $searchModel = new UserSearch(); + $dataProvider = $searchModel->search(Yii::$app->request->queryParams); + + return $this->render('index', [ + 'searchModel' => $searchModel, + 'dataProvider' => $dataProvider, + 'module' => $module, + ]); + } + + /** + * Create a new user. + */ + public function actionCreate(): Response|string + { + /** @var Module $module */ + $module = $this->module; + + $model = new User(['scenario' => 'create']); + + if ($model->load(Yii::$app->request->post())) { + // Handle AJAX validation + if (Yii::$app->request->isAjax) { + return $this->asJson(\yii\widgets\ActiveForm::validate($model)); + } + + if ($model->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'User has been created.')); + return $this->redirect(['index']); + } + } + + return $this->render('create', [ + 'model' => $model, + 'module' => $module, + ]); + } + + /** + * Update user account details. + */ + public function actionUpdate(int $id): Response|string + { + /** @var Module $module */ + $module = $this->module; + + $user = $this->findUser($id); + $user->scenario = 'update'; + + if ($user->load(Yii::$app->request->post())) { + // Handle AJAX validation + if (Yii::$app->request->isAjax) { + return $this->asJson(\yii\widgets\ActiveForm::validate($user)); + } + + if ($user->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'User has been updated.')); + return $this->redirect(['update', 'id' => $user->id]); + } + } + + return $this->render('_account', [ + 'user' => $user, + 'module' => $module, + ]); + } + + /** + * Update user profile. + */ + public function actionUpdateProfile(int $id): Response|string + { + /** @var Module $module */ + $module = $this->module; + + $user = $this->findUser($id); + $profile = $user->profile; + + if ($profile === null) { + $profile = new \cgsmith\user\models\Profile(['user_id' => $user->id]); + } + + if ($profile->load(Yii::$app->request->post())) { + // Handle AJAX validation + if (Yii::$app->request->isAjax) { + return $this->asJson(\yii\widgets\ActiveForm::validate($profile)); + } + + if ($profile->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Profile has been updated.')); + return $this->redirect(['update-profile', 'id' => $user->id]); + } + } + + return $this->render('_profile', [ + 'user' => $user, + 'profile' => $profile, + 'module' => $module, + ]); + } + + /** + * Show user information. + */ + public function actionInfo(int $id): string + { + $user = $this->findUser($id); + + return $this->render('_info', [ + 'user' => $user, + ]); + } + + /** + * Delete user. + */ + public function actionDelete(int $id): Response + { + $model = $this->findUser($id); + + // Prevent self-deletion + if ($model->id === Yii::$app->user->id) { + Yii::$app->session->setFlash('danger', Yii::t('user', 'You cannot delete your own account.')); + return $this->redirect(['index']); + } + + /** @var UserService $service */ + $service = Yii::$container->get(UserService::class); + + if ($service->delete($model)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'User has been deleted.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while deleting the user.')); + } + + return $this->redirect(['index']); + } + + /** + * Block user. + */ + public function actionBlock(int $id): Response + { + $model = $this->findUser($id); + + // Prevent self-blocking + if ($model->id === Yii::$app->user->id) { + Yii::$app->session->setFlash('danger', Yii::t('user', 'You cannot block your own account.')); + return $this->redirect(['index']); + } + + /** @var UserService $service */ + $service = Yii::$container->get(UserService::class); + + if ($service->block($model)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'User has been blocked.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while blocking the user.')); + } + + return $this->redirect(['index']); + } + + /** + * Unblock user. + */ + public function actionUnblock(int $id): Response + { + $model = $this->findUser($id); + + /** @var UserService $service */ + $service = Yii::$container->get(UserService::class); + + if ($service->unblock($model)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'User has been unblocked.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while unblocking the user.')); + } + + return $this->redirect(['index']); + } + + /** + * Manually confirm user email. + */ + public function actionConfirm(int $id): Response + { + $model = $this->findUser($id); + + /** @var UserService $service */ + $service = Yii::$container->get(UserService::class); + + if ($service->confirm($model)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'User email has been confirmed.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while confirming the user.')); + } + + return $this->redirect(['index']); + } + + /** + * Generate and send a new password to the user. + */ + public function actionResendPassword(int $id): Response + { + $model = $this->findUser($id); + + /** @var UserService $service */ + $service = Yii::$container->get(UserService::class); + + /** @var MailerService $mailer */ + $mailer = Yii::$container->get(MailerService::class); + + try { + if ($service->resendPassword($model, $mailer)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'New password has been generated and sent to user.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while generating the password.')); + } + } catch (\yii\base\InvalidCallException $e) { + Yii::$app->session->setFlash('danger', $e->getMessage()); + } + + return $this->redirect(['index']); + } + + /** + * Impersonate user. + */ + public function actionImpersonate(int $id): Response + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enableImpersonation) { + throw new NotFoundHttpException(); + } + + $model = $this->findUser($id); + + /** @var UserService $service */ + $service = Yii::$container->get(UserService::class); + + if ($service->impersonate($model)) { + Yii::$app->session->setFlash('warning', Yii::t('user', 'You are now impersonating {user}. Click "Stop Impersonating" to return to your account.', ['user' => $model->email])); + return $this->goHome(); + } + + Yii::$app->session->setFlash('danger', Yii::t('user', 'You are not allowed to impersonate this user.')); + + return $this->redirect(['index']); + } + + /** + * Stop impersonating. + */ + public function actionStopImpersonate(): Response + { + /** @var UserService $service */ + $service = Yii::$container->get(UserService::class); + + if ($service->stopImpersonation()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'You have returned to your account.')); + } + + return $this->redirect(['index']); + } + + /** + * Find user by ID. + */ + protected function findUser(int $id): User + { + $user = User::findOne($id); + + if ($user === null) { + throw new NotFoundHttpException(Yii::t('user', 'User not found.')); + } + + return $user; + } +} diff --git a/src/controllers/GdprController.php b/src/controllers/GdprController.php new file mode 100644 index 0000000..64ad8e5 --- /dev/null +++ b/src/controllers/GdprController.php @@ -0,0 +1,181 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + ['allow' => true, 'roles' => ['@']], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'delete' => ['post'], + ], + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function beforeAction($action): bool + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enableGdpr) { + throw new NotFoundHttpException(); + } + + return parent::beforeAction($action); + } + + /** + * GDPR overview page. + */ + public function actionIndex(): string + { + return $this->render('index', [ + 'module' => $this->module, + ]); + } + + /** + * Export user data. + */ + public function actionExport(): Response + { + /** @var User $user */ + $user = Yii::$app->user->identity; + + $data = [ + 'user' => [ + 'id' => $user->id, + 'email' => $user->email, + 'username' => $user->username, + 'status' => $user->status, + 'email_confirmed_at' => $user->email_confirmed_at, + 'last_login_at' => $user->last_login_at, + 'last_login_ip' => $user->last_login_ip, + 'registration_ip' => $user->registration_ip, + 'created_at' => $user->created_at, + 'gdpr_consent_at' => $user->gdpr_consent_at, + ], + 'profile' => null, + 'exported_at' => date('Y-m-d H:i:s'), + ]; + + if ($user->profile !== null) { + $data['profile'] = [ + 'name' => $user->profile->name, + 'bio' => $user->profile->bio, + 'location' => $user->profile->location, + 'website' => $user->profile->website, + 'timezone' => $user->profile->timezone, + 'public_email' => $user->profile->public_email, + ]; + } + + // Return as JSON download + Yii::$app->response->format = Response::FORMAT_JSON; + Yii::$app->response->headers->set('Content-Disposition', 'attachment; filename="user-data-' . $user->id . '.json"'); + + return $this->asJson($data); + } + + /** + * Delete user account (GDPR right to be forgotten). + */ + public function actionDelete(): Response|string + { + /** @var User $user */ + $user = Yii::$app->user->identity; + + $model = new class extends \yii\base\Model { + public ?string $password = null; + public bool $confirm = false; + + public function rules(): array + { + return [ + ['password', 'required'], + ['confirm', 'required'], + ['confirm', 'boolean'], + ['confirm', 'compare', 'compareValue' => true, 'message' => Yii::t('user', 'You must confirm that you want to delete your account.')], + ]; + } + + public function attributeLabels(): array + { + return [ + 'password' => Yii::t('user', 'Current Password'), + 'confirm' => Yii::t('user', 'I understand this action cannot be undone'), + ]; + } + }; + + if ($model->load(Yii::$app->request->post()) && $model->validate()) { + if (!$user->validatePassword($model->password)) { + $model->addError('password', Yii::t('user', 'Password is incorrect.')); + } else { + // Soft delete - anonymize data but keep record for audit + $user->email = 'deleted_' . $user->id . '@deleted.local'; + $user->username = null; + $user->password_hash = ''; + $user->auth_key = Yii::$app->security->generateRandomString(32); + $user->status = User::STATUS_BLOCKED; + $user->gdpr_deleted_at = new Expression('NOW()'); + + // Clear profile + if ($user->profile !== null) { + $user->profile->name = null; + $user->profile->bio = null; + $user->profile->location = null; + $user->profile->website = null; + $user->profile->public_email = null; + $user->profile->gravatar_email = null; + $user->profile->avatar_path = null; + $user->profile->save(false); + } + + if ($user->save(false)) { + Yii::$app->user->logout(); + Yii::$app->session->setFlash('success', Yii::t('user', 'Your account has been deleted.')); + return $this->goHome(); + } + + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while deleting your account.')); + } + } + + return $this->render('delete', [ + 'model' => $model, + 'module' => $this->module, + ]); + } +} diff --git a/src/controllers/RecoveryController.php b/src/controllers/RecoveryController.php new file mode 100644 index 0000000..639c6b8 --- /dev/null +++ b/src/controllers/RecoveryController.php @@ -0,0 +1,112 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + ['allow' => true, 'actions' => ['request', 'reset'], 'roles' => ['?']], + ], + ], + ]; + } + + /** + * Request password recovery. + */ + public function actionRequest(): Response|string + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enablePasswordRecovery) { + throw new NotFoundHttpException(Yii::t('user', 'Password recovery is disabled.')); + } + + /** @var RecoveryForm $model */ + $model = $module->createModel('RecoveryForm'); + + if ($model->load(Yii::$app->request->post()) && $model->validate()) { + /** @var RecoveryService $service */ + $service = Yii::$container->get(RecoveryService::class); + $service->sendRecoveryMessage($model); + + Yii::$app->session->setFlash('success', Yii::t('user', 'If the email exists, we have sent password recovery instructions.')); + + return $this->redirect(['/user/login']); + } + + return $this->render('request', [ + 'model' => $model, + 'module' => $module, + ]); + } + + /** + * Reset password with token. + */ + public function actionReset(int $id, string $token): Response|string + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enablePasswordRecovery) { + throw new NotFoundHttpException(Yii::t('user', 'Password recovery is disabled.')); + } + + $user = User::findOne($id); + + if ($user === null) { + throw new NotFoundHttpException(Yii::t('user', 'User not found.')); + } + + /** @var RecoveryService $service */ + $service = Yii::$container->get(RecoveryService::class); + + if (!$service->validateToken($user, $token)) { + Yii::$app->session->setFlash('danger', Yii::t('user', 'The recovery link is invalid or has expired.')); + return $this->redirect(['/user/recovery/request']); + } + + /** @var RecoveryResetForm $model */ + $model = $module->createModel('RecoveryResetForm'); + + if ($model->load(Yii::$app->request->post()) && $model->validate()) { + if ($service->resetPassword($user, $token, $model->password)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Your password has been reset. You can now sign in.')); + return $this->redirect(['/user/login']); + } + + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while resetting your password.')); + } + + return $this->render('reset', [ + 'model' => $model, + 'module' => $module, + ]); + } +} diff --git a/src/controllers/RegistrationController.php b/src/controllers/RegistrationController.php new file mode 100644 index 0000000..df0fc8b --- /dev/null +++ b/src/controllers/RegistrationController.php @@ -0,0 +1,172 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + ['allow' => true, 'actions' => ['register', 'confirm', 'resend'], 'roles' => ['?']], + ], + ], + ]; + } + + /** + * Display registration form. + */ + public function actionRegister(): Response|string + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enableRegistration) { + throw new NotFoundHttpException(Yii::t('user', 'Registration is disabled.')); + } + + /** @var RegistrationForm $model */ + $model = $module->createModel('RegistrationForm'); + + // Handle AJAX validation + if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) { + return $this->asJson(\yii\widgets\ActiveForm::validate($model)); + } + + if ($model->load(Yii::$app->request->post())) { + /** @var RegistrationService $service */ + $service = Yii::$container->get(RegistrationService::class); + + $user = $service->register($model); + + if ($user !== null) { + if ($module->enableConfirmation) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Your account has been created. Please check your email for confirmation instructions.')); + } else { + Yii::$app->session->setFlash('success', Yii::t('user', 'Your account has been created and you can now sign in.')); + } + + return $this->redirect(['/user/login']); + } + } + + return $this->render('register', [ + 'model' => $model, + 'module' => $module, + ]); + } + + /** + * Confirm email with token. + */ + public function actionConfirm(int $id, string $token): Response + { + /** @var Module $module */ + $module = $this->module; + + $user = User::findOne($id); + + if ($user === null) { + throw new NotFoundHttpException(Yii::t('user', 'User not found.')); + } + + if ($user->getIsConfirmed()) { + Yii::$app->session->setFlash('info', Yii::t('user', 'Your email has already been confirmed.')); + return $this->redirect(['/user/login']); + } + + /** @var RegistrationService $service */ + $service = Yii::$container->get(RegistrationService::class); + + if ($service->confirm($user, $token)) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Thank you! Your email has been confirmed.')); + + // Auto-login after confirmation + Yii::$app->user->login($user, $module->rememberFor); + + return $this->goHome(); + } + + Yii::$app->session->setFlash('danger', Yii::t('user', 'The confirmation link is invalid or has expired.')); + + return $this->redirect(['/user/login']); + } + + /** + * Resend confirmation email. + */ + public function actionResend(): Response|string + { + /** @var Module $module */ + $module = $this->module; + + if (!$module->enableConfirmation) { + throw new NotFoundHttpException(); + } + + $model = new class extends \yii\base\Model { + public ?string $email = null; + + public function rules(): array + { + return [ + ['email', 'required'], + ['email', 'email'], + ]; + } + + public function attributeLabels(): array + { + return [ + 'email' => Yii::t('user', 'Email'), + ]; + } + }; + + if ($model->load(Yii::$app->request->post()) && $model->validate()) { + $user = User::findByEmail($model->email); + + if ($user !== null && !$user->getIsConfirmed()) { + /** @var RegistrationService $service */ + $service = Yii::$container->get(RegistrationService::class); + $service->resendConfirmation($user); + } + + // Always show success message to prevent email enumeration + Yii::$app->session->setFlash('success', Yii::t('user', 'If the email exists and is not confirmed, we have sent a new confirmation link.')); + + return $this->redirect(['/user/login']); + } + + return $this->render('resend', [ + 'model' => $model, + 'module' => $module, + ]); + } +} diff --git a/src/controllers/SecurityController.php b/src/controllers/SecurityController.php new file mode 100644 index 0000000..bcc3ff2 --- /dev/null +++ b/src/controllers/SecurityController.php @@ -0,0 +1,106 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + ['allow' => true, 'actions' => ['login'], 'roles' => ['?']], + ['allow' => true, 'actions' => ['login', 'logout'], 'roles' => ['@']], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'logout' => ['post'], + ], + ], + ]; + } + + /** + * Display login page and handle login. + */ + public function actionLogin(): Response|string + { + if (!Yii::$app->user->isGuest) { + return $this->goHome(); + } + + /** @var Module $module */ + $module = $this->module; + + /** @var LoginForm $model */ + $model = $module->createModel('LoginForm'); + + // Handle AJAX validation + if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) { + return $this->asJson(\yii\widgets\ActiveForm::validate($model)); + } + + // Trigger before login event + $event = new FormEvent(['form' => $model]); + $module->trigger(self::EVENT_BEFORE_LOGIN, $event); + + if ($model->load(Yii::$app->request->post()) && $model->login()) { + // Trigger after login event + $event = new FormEvent(['form' => $model]); + $module->trigger(self::EVENT_AFTER_LOGIN, $event); + + return $this->goBack(); + } + + return $this->render('login', [ + 'model' => $model, + 'module' => $module, + ]); + } + + /** + * Logout user. + */ + public function actionLogout(): Response + { + /** @var Module $module */ + $module = $this->module; + + // Trigger before logout event + $event = new FormEvent(['form' => null]); + $module->trigger(self::EVENT_BEFORE_LOGOUT, $event); + + Yii::$app->user->logout(); + + // Trigger after logout event + $event = new FormEvent(['form' => null]); + $module->trigger(self::EVENT_AFTER_LOGOUT, $event); + + return $this->goHome(); + } +} diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php new file mode 100644 index 0000000..61dc085 --- /dev/null +++ b/src/controllers/SettingsController.php @@ -0,0 +1,233 @@ + [ + 'class' => AccessControl::class, + 'rules' => [ + ['allow' => true, 'roles' => ['@']], + ], + ], + 'verbs' => [ + 'class' => VerbFilter::class, + 'actions' => [ + 'delete-avatar' => ['post'], + ], + ], + ]; + } + + /** + * Account settings (email, password). + */ + public function actionAccount(): Response|string + { + /** @var Module $module */ + $module = $this->module; + + /** @var User $user */ + $user = Yii::$app->user->identity; + + $model = new SettingsForm($user); + + if ($model->load(Yii::$app->request->post()) && $model->validate()) { + $transaction = Yii::$app->db->beginTransaction(); + + try { + // Handle email change + if ($model->isEmailChanged()) { + if ($module->emailChangeStrategy === Module::EMAIL_CHANGE_INSECURE) { + $user->email = $model->email; + } else { + /** @var TokenService $tokenService */ + $tokenService = Yii::$container->get(TokenService::class); + $token = $tokenService->createEmailChangeToken($user, $model->email); + + /** @var MailerService $mailer */ + $mailer = Yii::$container->get(MailerService::class); + $mailer->sendEmailChangeMessage($user, $token, $model->email); + + Yii::$app->session->setFlash('info', Yii::t('user', 'A confirmation email has been sent to your new email address.')); + } + } + + // Handle username change + if ($model->username !== $user->username) { + $user->username = $model->username; + } + + // Handle password change + if ($model->isPasswordChanged()) { + $user->password = $model->new_password; + } + + if (!$user->save()) { + $transaction->rollBack(); + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while saving your settings.')); + } else { + $transaction->commit(); + Yii::$app->session->setFlash('success', Yii::t('user', 'Your settings have been updated.')); + } + } catch (\Exception $e) { + $transaction->rollBack(); + Yii::error('Settings update failed: ' . $e->getMessage(), __METHOD__); + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while saving your settings.')); + } + + return $this->refresh(); + } + + return $this->render('account', [ + 'model' => $model, + 'module' => $module, + ]); + } + + /** + * Profile settings. + */ + public function actionProfile(): Response|string + { + /** @var Module $module */ + $module = $this->module; + + /** @var User $user */ + $user = Yii::$app->user->identity; + $profile = $user->profile; + + if ($profile->load(Yii::$app->request->post())) { + // Handle avatar upload + if ($module->enableAvatarUpload) { + $avatarFile = UploadedFile::getInstance($profile, 'avatar_path'); + + if ($avatarFile !== null) { + $uploadPath = Yii::getAlias($module->avatarPath); + + if (!is_dir($uploadPath)) { + mkdir($uploadPath, 0755, true); + } + + // Delete old avatar + if (!empty($profile->avatar_path)) { + $oldPath = $uploadPath . '/' . $profile->avatar_path; + if (file_exists($oldPath)) { + unlink($oldPath); + } + } + + // Save new avatar + $filename = $user->id . '_' . time() . '.' . $avatarFile->extension; + $avatarFile->saveAs($uploadPath . '/' . $filename); + $profile->avatar_path = $filename; + } + } + + if ($profile->save()) { + Yii::$app->session->setFlash('success', Yii::t('user', 'Your profile has been updated.')); + return $this->refresh(); + } + } + + return $this->render('profile', [ + 'model' => $profile, + 'module' => $module, + ]); + } + + /** + * Delete avatar. + */ + public function actionDeleteAvatar(): Response + { + /** @var Module $module */ + $module = $this->module; + + /** @var User $user */ + $user = Yii::$app->user->identity; + $profile = $user->profile; + + if (!empty($profile->avatar_path)) { + $uploadPath = Yii::getAlias($module->avatarPath); + $path = $uploadPath . '/' . $profile->avatar_path; + + if (file_exists($path)) { + unlink($path); + } + + $profile->avatar_path = null; + $profile->save(false, ['avatar_path']); + + Yii::$app->session->setFlash('success', Yii::t('user', 'Your avatar has been deleted.')); + } + + return $this->redirect(['profile']); + } + + /** + * Confirm email change. + */ + public function actionConfirmEmail(int $id, string $token): Response + { + /** @var User $user */ + $user = Yii::$app->user->identity; + + if ($user->id !== $id) { + Yii::$app->session->setFlash('danger', Yii::t('user', 'Invalid request.')); + return $this->redirect(['account']); + } + + /** @var TokenService $tokenService */ + $tokenService = Yii::$container->get(TokenService::class); + $tokenModel = $tokenService->findEmailChangeToken($token); + + if ($tokenModel === null || $tokenModel->user_id !== $user->id) { + Yii::$app->session->setFlash('danger', Yii::t('user', 'The confirmation link is invalid or has expired.')); + return $this->redirect(['account']); + } + + $newEmail = $tokenModel->data['new_email'] ?? null; + + if ($newEmail === null) { + Yii::$app->session->setFlash('danger', Yii::t('user', 'Invalid email change request.')); + return $this->redirect(['account']); + } + + $user->email = $newEmail; + + if ($user->save(false, ['email'])) { + $tokenService->deleteToken($tokenModel); + Yii::$app->session->setFlash('success', Yii::t('user', 'Your email has been changed.')); + } else { + Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while changing your email.')); + } + + return $this->redirect(['account']); + } +} diff --git a/src/events/FormEvent.php b/src/events/FormEvent.php new file mode 100644 index 0000000..2dda5f7 --- /dev/null +++ b/src/events/FormEvent.php @@ -0,0 +1,19 @@ +roles)) { + return true; + } + + foreach ($this->roles as $role) { + if ($role === '?') { + if ($user->getIsGuest()) { + return true; + } + } elseif ($role === '@') { + if (!$user->getIsGuest()) { + return true; + } + } elseif ($role === 'admin') { + // Check if user is admin via UserInterface + if (!$user->getIsGuest()) { + $identity = $user->identity; + if ($identity instanceof UserInterface && $identity->getIsAdmin()) { + return true; + } + } + } elseif (!$user->getIsGuest() && $user->can($role)) { + return true; + } + } + + return false; + } +} diff --git a/src/helpers/Password.php b/src/helpers/Password.php new file mode 100644 index 0000000..566dca8 --- /dev/null +++ b/src/helpers/Password.php @@ -0,0 +1,96 @@ +security->generatePasswordHash($password, $cost); + } + + /** + * Validate a password against a hash. + */ + public static function validate(string $password, string $hash): bool + { + return Yii::$app->security->validatePassword($password, $hash); + } + + /** + * Generate a random password. + */ + public static function generate(int $length = 12): string + { + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'; + $password = ''; + + for ($i = 0; $i < $length; $i++) { + $password .= $chars[random_int(0, strlen($chars) - 1)]; + } + + return $password; + } + + /** + * Check password strength. + * + * @return array Array with 'score' (0-4) and 'feedback' messages + */ + public static function checkStrength(string $password): array + { + $score = 0; + $feedback = []; + + // Length check + $length = strlen($password); + if ($length >= 8) $score++; + if ($length >= 12) $score++; + if ($length < 8) { + $feedback[] = Yii::t('user', 'Password should be at least 8 characters.'); + } + + // Lowercase + if (preg_match('/[a-z]/', $password)) { + $score += 0.5; + } else { + $feedback[] = Yii::t('user', 'Add lowercase letters.'); + } + + // Uppercase + if (preg_match('/[A-Z]/', $password)) { + $score += 0.5; + } else { + $feedback[] = Yii::t('user', 'Add uppercase letters.'); + } + + // Numbers + if (preg_match('/[0-9]/', $password)) { + $score += 0.5; + } else { + $feedback[] = Yii::t('user', 'Add numbers.'); + } + + // Special characters + if (preg_match('/[^a-zA-Z0-9]/', $password)) { + $score += 0.5; + } else { + $feedback[] = Yii::t('user', 'Add special characters.'); + } + + return [ + 'score' => min(4, (int) $score), + 'feedback' => $feedback, + ]; + } +} diff --git a/src/messages/en/user.php b/src/messages/en/user.php new file mode 100644 index 0000000..8bef7d5 --- /dev/null +++ b/src/messages/en/user.php @@ -0,0 +1,193 @@ + 'ID', + 'Email' => 'Email', + 'Username' => 'Username', + 'Password' => 'Password', + 'Status' => 'Status', + 'Created At' => 'Created At', + 'Updated At' => 'Updated At', + + // User statuses + 'Pending' => 'Pending', + 'Active' => 'Active', + 'Blocked' => 'Blocked', + 'Confirmed' => 'Confirmed', + 'Unconfirmed' => 'Unconfirmed', + + // Login + 'Sign In' => 'Sign In', + 'Email or Username' => 'Email or Username', + 'Remember me' => 'Remember me', + 'Forgot password?' => 'Forgot password?', + "Don't have an account?" => "Don't have an account?", + 'Invalid login or password.' => 'Invalid login or password.', + 'Your account has been blocked.' => 'Your account has been blocked.', + 'You need to confirm your email address.' => 'You need to confirm your email address.', + + // Registration + 'Sign Up' => 'Sign Up', + 'Sign up' => 'Sign up', + 'Username (optional)' => 'Username (optional)', + 'Already have an account?' => 'Already have an account?', + 'Registration is disabled.' => 'Registration is disabled.', + 'Your account has been created. Please check your email for confirmation instructions.' => 'Your account has been created. Please check your email for confirmation instructions.', + 'Your account has been created and you can now sign in.' => 'Your account has been created and you can now sign in.', + 'This email address has already been taken.' => 'This email address has already been taken.', + 'This username has already been taken.' => 'This username has already been taken.', + 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.' => 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.', + + // Confirmation + 'Confirm your email on {app}' => 'Confirm your email on {app}', + 'Resend Confirmation' => 'Resend Confirmation', + 'Resend' => 'Resend', + 'Enter your email address and we will send you a new confirmation link.' => 'Enter your email address and we will send you a new confirmation link.', + 'If the email exists and is not confirmed, we have sent a new confirmation link.' => 'If the email exists and is not confirmed, we have sent a new confirmation link.', + 'Your email has already been confirmed.' => 'Your email has already been confirmed.', + 'Thank you! Your email has been confirmed.' => 'Thank you! Your email has been confirmed.', + 'The confirmation link is invalid or has expired.' => 'The confirmation link is invalid or has expired.', + + // Recovery + 'Forgot Password' => 'Forgot Password', + 'Send Reset Link' => 'Send Reset Link', + 'Enter your email address and we will send you a link to reset your password.' => 'Enter your email address and we will send you a link to reset your password.', + 'If the email exists, we have sent password recovery instructions.' => 'If the email exists, we have sent password recovery instructions.', + 'Password recovery is disabled.' => 'Password recovery is disabled.', + 'Reset Password' => 'Reset Password', + 'New Password' => 'New Password', + 'Confirm Password' => 'Confirm Password', + 'Enter your new password below.' => 'Enter your new password below.', + 'The recovery link is invalid or has expired.' => 'The recovery link is invalid or has expired.', + 'Your password has been reset. You can now sign in.' => 'Your password has been reset. You can now sign in.', + 'An error occurred while resetting your password.' => 'An error occurred while resetting your password.', + 'Passwords do not match.' => 'Passwords do not match.', + 'Password recovery on {app}' => 'Password recovery on {app}', + + // Settings + 'Account Settings' => 'Account Settings', + 'Profile Settings' => 'Profile Settings', + 'Account' => 'Account', + 'Profile' => 'Profile', + 'Privacy & Data' => 'Privacy & Data', + 'Change Password' => 'Change Password', + 'Confirm New Password' => 'Confirm New Password', + 'Current Password' => 'Current Password', + 'Required to change email or password.' => 'Required to change email or password.', + 'Current password is required to change email or password.' => 'Current password is required to change email or password.', + 'Current password is incorrect.' => 'Current password is incorrect.', + 'Save Changes' => 'Save Changes', + 'Your settings have been updated.' => 'Your settings have been updated.', + 'Your profile has been updated.' => 'Your profile has been updated.', + 'An error occurred while saving your settings.' => 'An error occurred while saving your settings.', + 'A confirmation email has been sent to your new email address.' => 'A confirmation email has been sent to your new email address.', + 'Your email has been changed.' => 'Your email has been changed.', + 'An error occurred while changing your email.' => 'An error occurred while changing your email.', + 'Confirm email change on {app}' => 'Confirm email change on {app}', + 'Invalid request.' => 'Invalid request.', + 'Invalid email change request.' => 'Invalid email change request.', + + // Profile + 'Name' => 'Name', + 'Bio' => 'Bio', + 'Location' => 'Location', + 'Website' => 'Website', + 'Timezone' => 'Timezone', + 'Select timezone...' => 'Select timezone...', + 'Avatar' => 'Avatar', + 'Upload Avatar' => 'Upload Avatar', + 'Delete Avatar' => 'Delete Avatar', + 'Are you sure you want to delete your avatar?' => 'Are you sure you want to delete your avatar?', + 'Your avatar has been deleted.' => 'Your avatar has been deleted.', + 'Gravatar Email' => 'Gravatar Email', + 'Use Gravatar' => 'Use Gravatar', + 'Leave empty to use your account email for Gravatar.' => 'Leave empty to use your account email for Gravatar.', + 'Public Email' => 'Public Email', + + // Admin + 'Manage Users' => 'Manage Users', + 'Create User' => 'Create User', + 'Update User: {email}' => 'Update User: {email}', + 'Update' => 'Update', + 'Create' => 'Create', + 'Cancel' => 'Cancel', + 'Delete' => 'Delete', + 'Block' => 'Block', + 'Unblock' => 'Unblock', + 'Confirm' => 'Confirm', + 'Impersonate' => 'Impersonate', + 'User' => 'User', + 'Email Confirmed' => 'Email Confirmed', + 'Last Login' => 'Last Login', + 'Registration IP' => 'Registration IP', + 'Blocked At' => 'Blocked At', + 'User not found.' => 'User not found.', + 'User has been created.' => 'User has been created.', + 'User has been updated.' => 'User has been updated.', + 'User has been deleted.' => 'User has been deleted.', + 'User has been blocked.' => 'User has been blocked.', + 'User has been unblocked.' => 'User has been unblocked.', + 'User email has been confirmed.' => 'User email has been confirmed.', + 'You cannot delete your own account.' => 'You cannot delete your own account.', + 'You cannot block your own account.' => 'You cannot block your own account.', + 'Are you sure you want to delete this user?' => 'Are you sure you want to delete this user?', + 'Are you sure you want to block this user?' => 'Are you sure you want to block this user?', + 'Are you sure you want to unblock this user?' => 'Are you sure you want to unblock this user?', + 'Leave empty to keep current password.' => 'Leave empty to keep current password.', + + // Impersonation + 'You are now impersonating {user}. Click "Stop Impersonating" to return to your account.' => 'You are now impersonating {user}. Click "Stop Impersonating" to return to your account.', + 'You are not allowed to impersonate this user.' => 'You are not allowed to impersonate this user.', + 'You have returned to your account.' => 'You have returned to your account.', + + // GDPR + 'Export Your Data' => 'Export Your Data', + 'Download a copy of your personal data in JSON format.' => 'Download a copy of your personal data in JSON format.', + 'Export Data' => 'Export Data', + 'Delete Account' => 'Delete Account', + 'Delete My Account' => 'Delete My Account', + 'Permanently Delete My Account' => 'Permanently Delete My Account', + 'This action is permanent and cannot be undone. All your data will be deleted.' => 'This action is permanent and cannot be undone. All your data will be deleted.', + 'Warning:' => 'Warning:', + 'This action is irreversible!' => 'This action is irreversible!', + 'Deleting your account will:' => 'Deleting your account will:', + 'Remove all your personal information' => 'Remove all your personal information', + 'Delete your profile and settings' => 'Delete your profile and settings', + 'Log you out immediately' => 'Log you out immediately', + 'Enter your current password' => 'Enter your current password', + 'I understand this action cannot be undone' => 'I understand this action cannot be undone', + 'You must confirm that you want to delete your account.' => 'You must confirm that you want to delete your account.', + 'Password is incorrect.' => 'Password is incorrect.', + 'Your account has been deleted.' => 'Your account has been deleted.', + 'An error occurred while deleting your account.' => 'An error occurred while deleting your account.', + + // Email templates + 'Welcome to {app}!' => 'Welcome to {app}!', + 'Welcome to {app}' => 'Welcome to {app}', + 'Thank you for registering.' => 'Thank you for registering.', + 'Please click the button below to confirm your email address:' => 'Please click the button below to confirm your email address:', + 'Please click the link below to confirm your email address:' => 'Please click the link below to confirm your email address:', + 'Confirm Email' => 'Confirm Email', + 'If the button above does not work, copy and paste this URL into your browser:' => 'If the button above does not work, copy and paste this URL into your browser:', + 'This link will expire in {hours} hours.' => 'This link will expire in {hours} hours.', + 'If you did not create an account, please ignore this email.' => 'If you did not create an account, please ignore this email.', + 'If you did not request this email, please ignore it.' => 'If you did not request this email, please ignore it.', + 'You can now sign in to your account.' => 'You can now sign in to your account.', + 'Confirm Your Email' => 'Confirm Your Email', + 'Reset Your Password' => 'Reset Your Password', + 'We received a request to reset your password. Click the button below to create a new password:' => 'We received a request to reset your password. Click the button below to create a new password:', + 'We received a request to reset your password. Click the link below to create a new password:' => 'We received a request to reset your password. Click the link below to create a new password:', + 'If you did not request a password reset, please ignore this email. Your password will not be changed.' => 'If you did not request a password reset, please ignore this email. Your password will not be changed.', + 'Your account on {app}' => 'Your account on {app}', + + // Password strength + 'Password should be at least 8 characters.' => 'Password should be at least 8 characters.', + 'Add lowercase letters.' => 'Add lowercase letters.', + 'Add uppercase letters.' => 'Add uppercase letters.', + 'Add numbers.' => 'Add numbers.', + 'Add special characters.' => 'Add special characters.', +]; diff --git a/src/migrations/m250115_000001_create_user_table.php b/src/migrations/m250115_000001_create_user_table.php new file mode 100644 index 0000000..4890ff4 --- /dev/null +++ b/src/migrations/m250115_000001_create_user_table.php @@ -0,0 +1,51 @@ +db->driverName === 'mysql') { + $tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB'; + } + + $this->createTable('{{%user}}', [ + 'id' => $this->primaryKey()->unsigned(), + 'email' => $this->string(255)->notNull()->unique(), + 'username' => $this->string(255)->unique(), + 'password_hash' => $this->string(255)->notNull(), + 'auth_key' => $this->string(32)->notNull(), + 'status' => "ENUM('pending', 'active', 'blocked') NOT NULL DEFAULT 'pending'", + 'email_confirmed_at' => $this->dateTime(), + 'blocked_at' => $this->dateTime(), + 'last_login_at' => $this->dateTime(), + 'last_login_ip' => $this->string(45), + 'registration_ip' => $this->string(45), + 'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'), + 'updated_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), + 'gdpr_consent_at' => $this->dateTime(), + 'gdpr_deleted_at' => $this->dateTime(), + ], $tableOptions); + + $this->createIndex('idx_user_status', '{{%user}}', 'status'); + $this->createIndex('idx_user_email_confirmed', '{{%user}}', 'email_confirmed_at'); + } + + /** + * {@inheritdoc} + */ + public function safeDown(): void + { + $this->dropTable('{{%user}}'); + } +} diff --git a/src/migrations/m250115_000002_create_profile_table.php b/src/migrations/m250115_000002_create_profile_table.php new file mode 100644 index 0000000..b4bd84b --- /dev/null +++ b/src/migrations/m250115_000002_create_profile_table.php @@ -0,0 +1,56 @@ +db->driverName === 'mysql') { + $tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB'; + } + + $this->createTable('{{%user_profile}}', [ + 'user_id' => $this->primaryKey()->unsigned(), + 'name' => $this->string(255), + 'bio' => $this->text(), + 'location' => $this->string(255), + 'website' => $this->string(255), + 'timezone' => $this->string(40), + 'avatar_path' => $this->string(255), + 'gravatar_email' => $this->string(255), + 'use_gravatar' => $this->boolean()->defaultValue(true), + 'public_email' => $this->string(255), + 'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'), + 'updated_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'), + ], $tableOptions); + + $this->addForeignKey( + 'fk_user_profile_user', + '{{%user_profile}}', + 'user_id', + '{{%user}}', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + + /** + * {@inheritdoc} + */ + public function safeDown(): void + { + $this->dropForeignKey('fk_user_profile_user', '{{%user_profile}}'); + $this->dropTable('{{%user_profile}}'); + } +} diff --git a/src/migrations/m250115_000003_create_token_table.php b/src/migrations/m250115_000003_create_token_table.php new file mode 100644 index 0000000..1951d78 --- /dev/null +++ b/src/migrations/m250115_000003_create_token_table.php @@ -0,0 +1,54 @@ +db->driverName === 'mysql') { + $tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB'; + } + + $this->createTable('{{%user_token}}', [ + 'id' => $this->primaryKey()->unsigned(), + 'user_id' => $this->integer()->unsigned()->notNull(), + 'type' => "ENUM('confirmation', 'recovery', 'email_change') NOT NULL", + 'token' => $this->string(64)->notNull()->unique(), + 'data' => $this->json(), + 'expires_at' => $this->dateTime()->notNull(), + 'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'), + ], $tableOptions); + + $this->createIndex('idx_user_token_user_type', '{{%user_token}}', ['user_id', 'type']); + $this->createIndex('idx_user_token_expires', '{{%user_token}}', 'expires_at'); + + $this->addForeignKey( + 'fk_user_token_user', + '{{%user_token}}', + 'user_id', + '{{%user}}', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + + /** + * {@inheritdoc} + */ + public function safeDown(): void + { + $this->dropForeignKey('fk_user_token_user', '{{%user_token}}'); + $this->dropTable('{{%user_token}}'); + } +} diff --git a/src/migrations/m250115_000004_create_social_account_table.php b/src/migrations/m250115_000004_create_social_account_table.php new file mode 100644 index 0000000..047c549 --- /dev/null +++ b/src/migrations/m250115_000004_create_social_account_table.php @@ -0,0 +1,55 @@ +db->driverName === 'mysql') { + $tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB'; + } + + $this->createTable('{{%user_social_account}}', [ + 'id' => $this->primaryKey()->unsigned(), + 'user_id' => $this->integer()->unsigned(), + 'provider' => $this->string(50)->notNull(), + 'provider_id' => $this->string(255)->notNull(), + 'data' => $this->json(), + 'email' => $this->string(255), + 'username' => $this->string(255), + 'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'), + ], $tableOptions); + + $this->createIndex('idx_user_social_provider', '{{%user_social_account}}', ['provider', 'provider_id'], true); + $this->createIndex('idx_user_social_user', '{{%user_social_account}}', 'user_id'); + + $this->addForeignKey( + 'fk_user_social_account_user', + '{{%user_social_account}}', + 'user_id', + '{{%user}}', + 'id', + 'CASCADE', + 'CASCADE' + ); + } + + /** + * {@inheritdoc} + */ + public function safeDown(): void + { + $this->dropForeignKey('fk_user_social_account_user', '{{%user_social_account}}'); + $this->dropTable('{{%user_social_account}}'); + } +} diff --git a/src/models/LoginForm.php b/src/models/LoginForm.php new file mode 100644 index 0000000..ad0c6f3 --- /dev/null +++ b/src/models/LoginForm.php @@ -0,0 +1,124 @@ + Yii::t('user', 'Email or Username'), + 'password' => Yii::t('user', 'Password'), + 'rememberMe' => Yii::t('user', 'Remember me'), + ]; + } + + /** + * Validate the password. + */ + public function validatePassword(string $attribute): void + { + if ($this->hasErrors()) { + return; + } + + $user = $this->getUser(); + + if ($user === null) { + $this->addError($attribute, Yii::t('user', 'Invalid login or password.')); + return; + } + + if ($user->getIsBlocked()) { + $this->addError($attribute, Yii::t('user', 'Your account has been blocked.')); + return; + } + + $module = $this->getModule(); + if (!$module->enableUnconfirmedLogin && !$user->getIsConfirmed()) { + $this->addError($attribute, Yii::t('user', 'You need to confirm your email address.')); + return; + } + + if (!$user->validatePassword($this->password)) { + $this->addError($attribute, Yii::t('user', 'Invalid login or password.')); + } + } + + /** + * Attempt to log in the user. + */ + public function login(): bool + { + if (!$this->validate()) { + return false; + } + + $user = $this->getUser(); + $module = $this->getModule(); + + $duration = $this->rememberMe ? $module->rememberFor : 0; + + if (Yii::$app->user->login($user, $duration)) { + $user->updateLastLogin(); + return true; + } + + return false; + } + + /** + * Get the user by login (email or username). + */ + public function getUser(): ?User + { + if ($this->_user === null && $this->login !== null) { + $this->_user = User::findByEmailOrUsername($this->login); + } + + return $this->_user; + } + + /** + * Get the user module. + */ + protected function getModule(): Module + { + /** @var Module $module */ + $module = Yii::$app->getModule('user'); + + return $module; + } +} diff --git a/src/models/Profile.php b/src/models/Profile.php new file mode 100644 index 0000000..638150f --- /dev/null +++ b/src/models/Profile.php @@ -0,0 +1,153 @@ + TimestampBehavior::class, + 'value' => new Expression('NOW()'), + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function rules(): array + { + return [ + [['name', 'location', 'public_email', 'gravatar_email'], 'string', 'max' => 255], + [['website'], 'url'], + [['bio'], 'string'], + [['timezone'], 'string', 'max' => 40], + [['timezone'], 'in', 'range' => \DateTimeZone::listIdentifiers()], + [['use_gravatar'], 'boolean'], + [['use_gravatar'], 'default', 'value' => true], + [['public_email', 'gravatar_email'], 'email'], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'user_id' => Yii::t('user', 'User'), + 'name' => Yii::t('user', 'Name'), + 'bio' => Yii::t('user', 'Bio'), + 'location' => Yii::t('user', 'Location'), + 'website' => Yii::t('user', 'Website'), + 'timezone' => Yii::t('user', 'Timezone'), + 'avatar_path' => Yii::t('user', 'Avatar'), + 'gravatar_email' => Yii::t('user', 'Gravatar Email'), + 'use_gravatar' => Yii::t('user', 'Use Gravatar'), + 'public_email' => Yii::t('user', 'Public Email'), + ]; + } + + /** + * Get user relation. + */ + public function getUser(): ActiveQuery + { + return $this->hasOne(User::class, ['id' => 'user_id']); + } + + /** + * Get avatar URL. + */ + public function getAvatarUrl(int $size = 200): ?string + { + // Local avatar takes precedence + if (!empty($this->avatar_path)) { + $module = Yii::$app->getModule('user'); + return Yii::getAlias($module->avatarUrl) . '/' . $this->avatar_path; + } + + // Gravatar fallback + if ($this->use_gravatar) { + $email = $this->gravatar_email ?? $this->user->email ?? ''; + return $this->getGravatarUrl($email, $size); + } + + return null; + } + + /** + * Generate Gravatar URL. + */ + public function getGravatarUrl(string $email, int $size = 200): string + { + $hash = md5(strtolower(trim($email))); + + return "https://www.gravatar.com/avatar/{$hash}?s={$size}&d=identicon"; + } + + /** + * Get timezone list for dropdown. + */ + public static function getTimezoneList(): array + { + $timezones = []; + $identifiers = \DateTimeZone::listIdentifiers(); + + foreach ($identifiers as $identifier) { + $timezones[$identifier] = str_replace('_', ' ', $identifier); + } + + return $timezones; + } +} diff --git a/src/models/RecoveryForm.php b/src/models/RecoveryForm.php new file mode 100644 index 0000000..2ba24f5 --- /dev/null +++ b/src/models/RecoveryForm.php @@ -0,0 +1,70 @@ + Yii::t('user', 'Email'), + ]; + } + + /** + * Validate that the email exists. + */ + public function validateEmail(string $attribute): void + { + $user = $this->getUser(); + + if ($user === null) { + // Don't reveal that the email doesn't exist (security) + return; + } + + if ($user->getIsBlocked()) { + $this->addError($attribute, Yii::t('user', 'Your account has been blocked.')); + } + } + + /** + * Get the user by email. + */ + public function getUser(): ?User + { + if ($this->_user === null && $this->email !== null) { + $this->_user = User::findByEmail($this->email); + } + + return $this->_user; + } +} diff --git a/src/models/RecoveryResetForm.php b/src/models/RecoveryResetForm.php new file mode 100644 index 0000000..783db29 --- /dev/null +++ b/src/models/RecoveryResetForm.php @@ -0,0 +1,54 @@ +getModule(); + + return [ + [['password', 'password_confirm'], 'required'], + ['password', 'string', 'min' => $module->minPasswordLength, 'max' => $module->maxPasswordLength], + ['password_confirm', 'compare', 'compareAttribute' => 'password', 'message' => Yii::t('user', 'Passwords do not match.')], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'password' => Yii::t('user', 'New Password'), + 'password_confirm' => Yii::t('user', 'Confirm Password'), + ]; + } + + /** + * Get the user module. + */ + protected function getModule(): Module + { + /** @var Module $module */ + $module = Yii::$app->getModule('user'); + + return $module; + } +} diff --git a/src/models/RegistrationForm.php b/src/models/RegistrationForm.php new file mode 100644 index 0000000..124f53a --- /dev/null +++ b/src/models/RegistrationForm.php @@ -0,0 +1,73 @@ +getModule(); + + $rules = [ + // Email + ['email', 'trim'], + ['email', 'required'], + ['email', 'email'], + ['email', 'string', 'max' => 255], + ['email', 'unique', 'targetClass' => User::class, 'message' => Yii::t('user', 'This email address has already been taken.')], + + // Username (optional) + ['username', 'trim'], + ['username', 'string', 'min' => 3, 'max' => 255], + ['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.]+$/', 'message' => Yii::t('user', 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.')], + ['username', 'unique', 'targetClass' => User::class, 'message' => Yii::t('user', 'This username has already been taken.')], + ]; + + // Password rules (unless generated) + if (!$module->enableGeneratedPassword) { + $rules[] = ['password', 'required']; + $rules[] = ['password', 'string', 'min' => $module->minPasswordLength, 'max' => $module->maxPasswordLength]; + } + + return $rules; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'email' => Yii::t('user', 'Email'), + 'username' => Yii::t('user', 'Username'), + 'password' => Yii::t('user', 'Password'), + ]; + } + + /** + * Get the user module. + */ + protected function getModule(): Module + { + /** @var Module $module */ + $module = Yii::$app->getModule('user'); + + return $module; + } +} diff --git a/src/models/SettingsForm.php b/src/models/SettingsForm.php new file mode 100644 index 0000000..3dec9fa --- /dev/null +++ b/src/models/SettingsForm.php @@ -0,0 +1,128 @@ +_user = $user; + $this->email = $user->email; + $this->username = $user->username; + + parent::__construct($config); + } + + /** + * {@inheritdoc} + */ + public function rules(): array + { + $module = $this->getModule(); + + return [ + // Email + ['email', 'trim'], + ['email', 'required'], + ['email', 'email'], + ['email', 'string', 'max' => 255], + ['email', 'unique', 'targetClass' => User::class, 'filter' => ['!=', 'id', $this->_user->id], 'message' => Yii::t('user', 'This email address has already been taken.')], + + // Username + ['username', 'trim'], + ['username', 'string', 'min' => 3, 'max' => 255], + ['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.]+$/', 'message' => Yii::t('user', 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.')], + ['username', 'unique', 'targetClass' => User::class, 'filter' => ['!=', 'id', $this->_user->id], 'message' => Yii::t('user', 'This username has already been taken.')], + + // New password + ['new_password', 'string', 'min' => $module->minPasswordLength, 'max' => $module->maxPasswordLength], + ['new_password_confirm', 'compare', 'compareAttribute' => 'new_password', 'message' => Yii::t('user', 'Passwords do not match.')], + + // Current password (required when changing email or password) + ['current_password', 'required', 'when' => function ($model) { + return $model->email !== $this->_user->email || !empty($model->new_password); + }, 'message' => Yii::t('user', 'Current password is required to change email or password.')], + ['current_password', 'validateCurrentPassword'], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'email' => Yii::t('user', 'Email'), + 'username' => Yii::t('user', 'Username'), + 'new_password' => Yii::t('user', 'New Password'), + 'new_password_confirm' => Yii::t('user', 'Confirm New Password'), + 'current_password' => Yii::t('user', 'Current Password'), + ]; + } + + /** + * Validate current password. + */ + public function validateCurrentPassword(string $attribute): void + { + if ($this->hasErrors()) { + return; + } + + if (!empty($this->current_password) && !$this->_user->validatePassword($this->current_password)) { + $this->addError($attribute, Yii::t('user', 'Current password is incorrect.')); + } + } + + /** + * Check if email has changed. + */ + public function isEmailChanged(): bool + { + return $this->email !== $this->_user->email; + } + + /** + * Check if password should be changed. + */ + public function isPasswordChanged(): bool + { + return !empty($this->new_password); + } + + /** + * Get the associated user. + */ + public function getUser(): User + { + return $this->_user; + } + + /** + * Get the user module. + */ + protected function getModule(): Module + { + /** @var Module $module */ + $module = Yii::$app->getModule('user'); + + return $module; + } +} diff --git a/src/models/Token.php b/src/models/Token.php new file mode 100644 index 0000000..1346f26 --- /dev/null +++ b/src/models/Token.php @@ -0,0 +1,219 @@ + [self::TYPE_CONFIRMATION, self::TYPE_RECOVERY, self::TYPE_EMAIL_CHANGE]], + [['token'], 'string', 'max' => 64], + [['token'], 'unique'], + [['data'], 'safe'], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels(): array + { + return [ + 'id' => Yii::t('user', 'ID'), + 'user_id' => Yii::t('user', 'User'), + 'type' => Yii::t('user', 'Type'), + 'token' => Yii::t('user', 'Token'), + 'data' => Yii::t('user', 'Data'), + 'expires_at' => Yii::t('user', 'Expires At'), + 'created_at' => Yii::t('user', 'Created At'), + ]; + } + + /** + * {@inheritdoc} + */ + public function beforeSave($insert): bool + { + if (!parent::beforeSave($insert)) { + return false; + } + + if ($insert) { + $this->created_at = new Expression('NOW()'); + } + + // Serialize data as JSON if it's an array + if (is_array($this->data)) { + $this->data = json_encode($this->data); + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function afterFind(): void + { + parent::afterFind(); + + // Deserialize JSON data + if (is_string($this->data)) { + $this->data = json_decode($this->data, true); + } + } + + /** + * Get user relation. + */ + public function getUser(): ActiveQuery + { + return $this->hasOne(User::class, ['id' => 'user_id']); + } + + /** + * Check if token is expired. + */ + public function getIsExpired(): bool + { + return strtotime($this->expires_at) < time(); + } + + /** + * Generate a new secure token. + */ + public static function generateToken(): string + { + return Yii::$app->security->generateRandomString(64); + } + + /** + * Create a confirmation token for a user. + */ + public static function createConfirmationToken(User $user): static + { + $module = Yii::$app->getModule('user'); + + $token = new static(); + $token->user_id = $user->id; + $token->type = self::TYPE_CONFIRMATION; + $token->token = self::generateToken(); + $token->expires_at = date('Y-m-d H:i:s', time() + $module->confirmWithin); + + return $token; + } + + /** + * Create a recovery token for a user. + */ + public static function createRecoveryToken(User $user): static + { + $module = Yii::$app->getModule('user'); + + $token = new static(); + $token->user_id = $user->id; + $token->type = self::TYPE_RECOVERY; + $token->token = self::generateToken(); + $token->expires_at = date('Y-m-d H:i:s', time() + $module->recoverWithin); + + return $token; + } + + /** + * Create an email change token for a user. + */ + public static function createEmailChangeToken(User $user, string $newEmail): static + { + $module = Yii::$app->getModule('user'); + + $token = new static(); + $token->user_id = $user->id; + $token->type = self::TYPE_EMAIL_CHANGE; + $token->token = self::generateToken(); + $token->expires_at = date('Y-m-d H:i:s', time() + $module->confirmWithin); + $token->data = ['new_email' => $newEmail]; + + return $token; + } + + /** + * Find token by token string and type. + */ + public static function findByToken(string $token, string $type): ?static + { + return static::find() + ->where(['token' => $token, 'type' => $type]) + ->notExpired() + ->one(); + } + + /** + * Delete all tokens for a user of a specific type. + */ + public static function deleteAllForUser(int $userId, ?string $type = null): int + { + $condition = ['user_id' => $userId]; + + if ($type !== null) { + $condition['type'] = $type; + } + + return static::deleteAll($condition); + } + + /** + * Delete expired tokens. + */ + public static function deleteExpired(): int + { + return static::deleteAll(['<', 'expires_at', new Expression('NOW()')]); + } +} diff --git a/src/models/User.php b/src/models/User.php new file mode 100644 index 0000000..f4359b2 --- /dev/null +++ b/src/models/User.php @@ -0,0 +1,368 @@ + TimestampBehavior::class, + 'value' => new Expression('NOW()'), + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function rules() + { + return [ + // Email + ['email', 'trim'], + ['email', 'required'], + ['email', 'email'], + ['email', 'string', 'max' => 255], + ['email', 'unique', 'message' => Yii::t('user', 'This email address has already been taken.')], + + // Username + ['username', 'trim'], + ['username', 'string', 'min' => 3, 'max' => 255], + ['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.]+$/', 'message' => Yii::t('user', 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.')], + ['username', 'unique', 'message' => Yii::t('user', 'This username has already been taken.')], + + // Password + ['password', 'string', 'min' => $this->getModule()->minPasswordLength, 'max' => $this->getModule()->maxPasswordLength], + + // Status + ['status', 'in', 'range' => [self::STATUS_PENDING, self::STATUS_ACTIVE, self::STATUS_BLOCKED]], + ['status', 'default', 'value' => self::STATUS_PENDING], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels() + { + return [ + 'id' => Yii::t('user', 'ID'), + 'email' => Yii::t('user', 'Email'), + 'username' => Yii::t('user', 'Username'), + 'password' => Yii::t('user', 'Password'), + 'status' => Yii::t('user', 'Status'), + 'email_confirmed_at' => Yii::t('user', 'Email Confirmed'), + 'blocked_at' => Yii::t('user', 'Blocked At'), + 'last_login_at' => Yii::t('user', 'Last Login'), + 'registration_ip' => Yii::t('user', 'Registration IP'), + 'created_at' => Yii::t('user', 'Created At'), + 'updated_at' => Yii::t('user', 'Updated At'), + ]; + } + + /** + * {@inheritdoc} + */ + public function beforeSave($insert): bool + { + if (!parent::beforeSave($insert)) { + return false; + } + + if ($insert) { + $this->auth_key = Yii::$app->security->generateRandomString(32); + if (Yii::$app->request instanceof \yii\web\Request) { + $this->registration_ip = Yii::$app->request->userIP; + } + } + + if (!empty($this->password)) { + $this->password_hash = Password::hash($this->password, $this->getModule()->cost); + } + + return true; + } + + /** + * {@inheritdoc} + */ + public function afterSave($insert, $changedAttributes): void + { + parent::afterSave($insert, $changedAttributes); + + if ($insert) { + $profile = new Profile(['user_id' => $this->id]); + $profile->save(false); + } + } + + // IdentityInterface implementation + + /** + * {@inheritdoc} + */ + public static function findIdentity($id): ?static + { + return static::find()->active()->andWhere(['id' => $id])->one(); + } + + /** + * {@inheritdoc} + */ + public static function findIdentityByAccessToken($token, $type = null): ?static + { + throw new NotSupportedException('findIdentityByAccessToken is not implemented.'); + } + + /** + * {@inheritdoc} + */ + public function getId(): int + { + return $this->id; + } + + /** + * {@inheritdoc} + */ + public function getAuthKey(): string + { + return $this->auth_key; + } + + /** + * {@inheritdoc} + */ + public function validateAuthKey($authKey): bool + { + return $this->auth_key === $authKey; + } + + // UserInterface implementation + + /** + * {@inheritdoc} + */ + public function getIsAdmin(): bool + { + $module = $this->getModule(); + + // Check RBAC permission first + if ($module->adminPermission !== null && Yii::$app->authManager !== null) { + if (Yii::$app->authManager->checkAccess($this->id, $module->adminPermission)) { + return true; + } + } + + // Fallback to admins array (check by email) + return in_array($this->email, $module->admins, true); + } + + /** + * {@inheritdoc} + */ + public function getIsBlocked(): bool + { + return $this->status === self::STATUS_BLOCKED || $this->blocked_at !== null; + } + + /** + * {@inheritdoc} + */ + public function getIsConfirmed(): bool + { + return $this->email_confirmed_at !== null; + } + + // Relations + + /** + * Get user profile relation. + */ + public function getProfile(): ActiveQuery + { + return $this->hasOne(Profile::class, ['user_id' => 'id']); + } + + /** + * Get user tokens relation. + */ + public function getTokens(): ActiveQuery + { + return $this->hasMany(Token::class, ['user_id' => 'id']); + } + + // Helper methods + + /** + * Validate password against stored hash. + */ + public function validatePassword(string $password): bool + { + return Password::validate($password, $this->password_hash); + } + + /** + * Find user by email. + */ + public static function findByEmail(string $email): ?static + { + return static::find()->where(['email' => $email])->one(); + } + + /** + * Find user by username. + */ + public static function findByUsername(string $username): ?static + { + return static::find()->where(['username' => $username])->one(); + } + + /** + * Find user by email or username. + */ + public static function findByEmailOrUsername(string $login): ?static + { + return static::find() + ->where(['or', ['email' => $login], ['username' => $login]]) + ->one(); + } + + /** + * Confirm user email. + */ + public function confirm(): bool + { + $this->status = self::STATUS_ACTIVE; + $this->email_confirmed_at = new Expression('NOW()'); + + return $this->save(false, ['status', 'email_confirmed_at']); + } + + /** + * Block user. + */ + public function block(): bool + { + $this->status = self::STATUS_BLOCKED; + $this->blocked_at = new Expression('NOW()'); + $this->auth_key = Yii::$app->security->generateRandomString(32); + + return $this->save(false, ['status', 'blocked_at', 'auth_key']); + } + + /** + * Unblock user. + */ + public function unblock(): bool + { + $this->status = $this->email_confirmed_at !== null ? self::STATUS_ACTIVE : self::STATUS_PENDING; + $this->blocked_at = null; + + return $this->save(false, ['status', 'blocked_at']); + } + + /** + * Update last login information. + */ + public function updateLastLogin(): bool + { + $this->last_login_at = new Expression('NOW()'); + if (Yii::$app->request instanceof \yii\web\Request) { + $this->last_login_ip = Yii::$app->request->userIP; + } + + return $this->save(false, ['last_login_at', 'last_login_ip']); + } + + /** + * Reset password. + */ + public function resetPassword(string $password): bool + { + $this->password_hash = Password::hash($password, $this->getModule()->cost); + + return $this->save(false, ['password_hash']); + } + + /** + * Get the user module instance. + */ + protected function getModule(): Module + { + /** @var Module $module */ + $module = Yii::$app->getModule('user'); + + return $module; + } +} diff --git a/src/models/UserSearch.php b/src/models/UserSearch.php new file mode 100644 index 0000000..80f59ed --- /dev/null +++ b/src/models/UserSearch.php @@ -0,0 +1,75 @@ + $query, + 'sort' => [ + 'defaultOrder' => ['id' => SORT_DESC], + ], + 'pagination' => [ + 'pageSize' => 20, + ], + ]); + + $this->load($params); + + if (!$this->validate()) { + return $dataProvider; + } + + $query->andFilterWhere([ + 'id' => $this->id, + 'status' => $this->status, + ]); + + $query + ->andFilterWhere(['like', 'email', $this->email]) + ->andFilterWhere(['like', 'username', $this->username]); + + if (!empty($this->created_at)) { + $query->andFilterWhere(['DATE(created_at)' => $this->created_at]); + } + + if (!empty($this->last_login_at)) { + $query->andFilterWhere(['DATE(last_login_at)' => $this->last_login_at]); + } + + return $dataProvider; + } +} diff --git a/src/models/query/ProfileQuery.php b/src/models/query/ProfileQuery.php new file mode 100644 index 0000000..72c4d32 --- /dev/null +++ b/src/models/query/ProfileQuery.php @@ -0,0 +1,41 @@ +andWhere(['user_id' => $userId]); + } + + /** + * Filter profiles with avatar. + */ + public function withAvatar(): static + { + return $this->andWhere(['not', ['avatar_path' => null]]); + } + + /** + * Filter profiles using gravatar. + */ + public function usingGravatar(): static + { + return $this->andWhere(['use_gravatar' => true]); + } +} diff --git a/src/models/query/TokenQuery.php b/src/models/query/TokenQuery.php new file mode 100644 index 0000000..9e05748 --- /dev/null +++ b/src/models/query/TokenQuery.php @@ -0,0 +1,82 @@ +andWhere(['type' => $type]); + } + + /** + * Filter by user ID. + */ + public function byUserId(int $userId): static + { + return $this->andWhere(['user_id' => $userId]); + } + + /** + * Filter by token string. + */ + public function byToken(string $token): static + { + return $this->andWhere(['token' => $token]); + } + + /** + * Filter tokens that are not expired. + */ + public function notExpired(): static + { + return $this->andWhere(['>', 'expires_at', new Expression('NOW()')]); + } + + /** + * Filter tokens that are expired. + */ + public function expired(): static + { + return $this->andWhere(['<', 'expires_at', new Expression('NOW()')]); + } + + /** + * Filter confirmation tokens. + */ + public function confirmation(): static + { + return $this->byType(Token::TYPE_CONFIRMATION); + } + + /** + * Filter recovery tokens. + */ + public function recovery(): static + { + return $this->byType(Token::TYPE_RECOVERY); + } + + /** + * Filter email change tokens. + */ + public function emailChange(): static + { + return $this->byType(Token::TYPE_EMAIL_CHANGE); + } +} diff --git a/src/models/query/UserQuery.php b/src/models/query/UserQuery.php new file mode 100644 index 0000000..5e43476 --- /dev/null +++ b/src/models/query/UserQuery.php @@ -0,0 +1,85 @@ +andWhere(['!=', 'status', User::STATUS_BLOCKED]) + ->andWhere(['gdpr_deleted_at' => null]); + } + + /** + * Filter by confirmed users. + */ + public function confirmed(): static + { + return $this->andWhere(['not', ['email_confirmed_at' => null]]); + } + + /** + * Filter by unconfirmed users. + */ + public function unconfirmed(): static + { + return $this->andWhere(['email_confirmed_at' => null]); + } + + /** + * Filter by blocked users. + */ + public function blocked(): static + { + return $this->andWhere(['status' => User::STATUS_BLOCKED]); + } + + /** + * Filter by pending users. + */ + public function pending(): static + { + return $this->andWhere(['status' => User::STATUS_PENDING]); + } + + /** + * Filter users that can log in. + */ + public function canLogin(): static + { + return $this + ->active() + ->confirmed(); + } + + /** + * Filter by email. + */ + public function byEmail(string $email): static + { + return $this->andWhere(['email' => $email]); + } + + /** + * Filter by username. + */ + public function byUsername(string $username): static + { + return $this->andWhere(['username' => $username]); + } +} diff --git a/src/services/MailerService.php b/src/services/MailerService.php new file mode 100644 index 0000000..17c26aa --- /dev/null +++ b/src/services/MailerService.php @@ -0,0 +1,155 @@ + $user->id, 'token' => $token->token], true); + } else { + $url = null; + } + + return $this->sendMessage( + $user->email, + Yii::t('user', 'Welcome to {app}', ['app' => Yii::$app->name]), + 'welcome', + [ + 'user' => $user, + 'token' => $token, + 'url' => $url, + 'module' => $this->module, + ] + ); + } + + /** + * Send confirmation email. + */ + public function sendConfirmationMessage(User $user, Token $token): bool + { + $url = Url::to(['/user/registration/confirm', 'id' => $user->id, 'token' => $token->token], true); + + return $this->sendMessage( + $user->email, + Yii::t('user', 'Confirm your email on {app}', ['app' => Yii::$app->name]), + 'confirmation', + [ + 'user' => $user, + 'token' => $token, + 'url' => $url, + 'module' => $this->module, + ] + ); + } + + /** + * Send password recovery email. + */ + public function sendRecoveryMessage(User $user, Token $token): bool + { + $url = Url::to(['/user/recovery/reset', 'id' => $user->id, 'token' => $token->token], true); + + return $this->sendMessage( + $user->email, + Yii::t('user', 'Password recovery on {app}', ['app' => Yii::$app->name]), + 'recovery', + [ + 'user' => $user, + 'token' => $token, + 'url' => $url, + 'module' => $this->module, + ] + ); + } + + /** + * Send email change confirmation. + */ + public function sendEmailChangeMessage(User $user, Token $token, string $newEmail): bool + { + $url = Url::to(['/user/settings/confirm-email', 'id' => $user->id, 'token' => $token->token], true); + + return $this->sendMessage( + $newEmail, + Yii::t('user', 'Confirm email change on {app}', ['app' => Yii::$app->name]), + 'email_change', + [ + 'user' => $user, + 'token' => $token, + 'url' => $url, + 'newEmail' => $newEmail, + 'module' => $this->module, + ] + ); + } + + /** + * Send generated password email. + */ + public function sendGeneratedPasswordMessage(User $user, string $password): bool + { + return $this->sendMessage( + $user->email, + Yii::t('user', 'Your account on {app}', ['app' => Yii::$app->name]), + 'generated_password', + [ + 'user' => $user, + 'password' => $password, + 'module' => $this->module, + ] + ); + } + + /** + * Send email message. + */ + protected function sendMessage(string $to, string $subject, string $view, array $params = []): bool + { + $mailer = $this->getMailer(); + $sender = $this->module->getMailerSender(); + + $viewPath = $this->module->mailer['viewPath'] ?? '@cgsmith/user/views/mail'; + + $message = $mailer->compose([ + 'html' => "{$viewPath}/{$view}", + 'text' => "{$viewPath}/{$view}-text", + ], $params) + ->setTo($to) + ->setFrom($sender) + ->setSubject($subject); + + return $message->send(); + } + + /** + * Get the mailer component. + */ + protected function getMailer(): MailerInterface + { + $mailerId = $this->module->mailer['mailer'] ?? 'mailer'; + + return Yii::$app->get($mailerId); + } +} diff --git a/src/services/RecoveryService.php b/src/services/RecoveryService.php new file mode 100644 index 0000000..df6f3ca --- /dev/null +++ b/src/services/RecoveryService.php @@ -0,0 +1,117 @@ +validate()) { + return true; // Don't reveal validation errors for security + } + + $user = $form->getUser(); + + if ($user === null || $user->getIsBlocked()) { + // User not found or blocked - return true to prevent enumeration + return true; + } + + $tokenService = $this->getTokenService(); + $token = $tokenService->createRecoveryToken($user); + + if ($token === null) { + Yii::error('Failed to create recovery token for user: ' . $user->id, __METHOD__); + return true; + } + + $mailer = $this->getMailerService(); + $mailer->sendRecoveryMessage($user, $token); + + return true; + } + + /** + * Reset password with token. + */ + public function resetPassword(User $user, string $tokenString, string $password): bool + { + $tokenService = $this->getTokenService(); + $token = $tokenService->findRecoveryToken($tokenString); + + if ($token === null || $token->user_id !== $user->id) { + return false; + } + + $transaction = Yii::$app->db->beginTransaction(); + + try { + // Reset password + if (!$user->resetPassword($password)) { + $transaction->rollBack(); + return false; + } + + // Delete token + $tokenService->deleteToken($token); + + // Delete all other recovery tokens for this user + Token::deleteAllForUser($user->id, Token::TYPE_RECOVERY); + + $transaction->commit(); + + return true; + } catch (\Exception $e) { + $transaction->rollBack(); + Yii::error('Password reset failed: ' . $e->getMessage(), __METHOD__); + throw $e; + } + } + + /** + * Validate recovery token. + */ + public function validateToken(User $user, string $tokenString): bool + { + $tokenService = $this->getTokenService(); + $token = $tokenService->findRecoveryToken($tokenString); + + return $token !== null && $token->user_id === $user->id; + } + + /** + * Get mailer service. + */ + protected function getMailerService(): MailerService + { + return Yii::$container->get(MailerService::class); + } + + /** + * Get token service. + */ + protected function getTokenService(): TokenService + { + return Yii::$container->get(TokenService::class); + } +} diff --git a/src/services/RegistrationService.php b/src/services/RegistrationService.php new file mode 100644 index 0000000..7d18451 --- /dev/null +++ b/src/services/RegistrationService.php @@ -0,0 +1,181 @@ +validate()) { + return null; + } + + $transaction = Yii::$app->db->beginTransaction(Transaction::SERIALIZABLE); + + try { + $user = new User(); + $user->email = $form->email; + $user->username = $form->username; + + // Handle password + if ($this->module->enableGeneratedPassword) { + $password = Password::generate(); + $user->password = $password; + } else { + $user->password = $form->password; + } + + // Set confirmation status + if (!$this->module->enableConfirmation) { + $user->status = User::STATUS_ACTIVE; + $user->email_confirmed_at = date('Y-m-d H:i:s'); + } + + // Trigger before event + $event = new RegistrationEvent(['user' => $user, 'form' => $form]); + $this->module->trigger(self::EVENT_BEFORE_REGISTER, $event); + + if (!$user->save()) { + $transaction->rollBack(); + Yii::error('Failed to save user: ' . json_encode($user->errors), __METHOD__); + return null; + } + + // Create confirmation token if needed + $token = null; + if ($this->module->enableConfirmation) { + $token = Token::createConfirmationToken($user); + if (!$token->save()) { + $transaction->rollBack(); + Yii::error('Failed to save confirmation token: ' . json_encode($token->errors), __METHOD__); + return null; + } + } + + // Send welcome email + $mailer = $this->getMailerService(); + if ($this->module->enableGeneratedPassword) { + $mailer->sendGeneratedPasswordMessage($user, $password); + } else { + $mailer->sendWelcomeMessage($user, $token); + } + + // Trigger after event + $event = new RegistrationEvent(['user' => $user, 'form' => $form, 'token' => $token]); + $this->module->trigger(self::EVENT_AFTER_REGISTER, $event); + + $transaction->commit(); + + return $user; + } catch (\Exception $e) { + $transaction->rollBack(); + Yii::error('Registration failed: ' . $e->getMessage(), __METHOD__); + throw $e; + } + } + + /** + * Confirm user email with token. + */ + public function confirm(User $user, string $tokenString): bool + { + $tokenService = $this->getTokenService(); + $token = $tokenService->findConfirmationToken($tokenString); + + if ($token === null || $token->user_id !== $user->id) { + return false; + } + + $transaction = Yii::$app->db->beginTransaction(); + + try { + // Trigger before event + $event = new RegistrationEvent(['user' => $user, 'token' => $token]); + $this->module->trigger(self::EVENT_BEFORE_CONFIRM, $event); + + // Confirm user + if (!$user->confirm()) { + $transaction->rollBack(); + return false; + } + + // Delete token + $tokenService->deleteToken($token); + + // Trigger after event + $event = new RegistrationEvent(['user' => $user]); + $this->module->trigger(self::EVENT_AFTER_CONFIRM, $event); + + $transaction->commit(); + + return true; + } catch (\Exception $e) { + $transaction->rollBack(); + Yii::error('Confirmation failed: ' . $e->getMessage(), __METHOD__); + throw $e; + } + } + + /** + * Resend confirmation email. + */ + public function resendConfirmation(User $user): bool + { + if ($user->getIsConfirmed()) { + return false; + } + + $tokenService = $this->getTokenService(); + $token = $tokenService->createConfirmationToken($user); + + if ($token === null) { + return false; + } + + $mailer = $this->getMailerService(); + + return $mailer->sendConfirmationMessage($user, $token); + } + + /** + * Get mailer service. + */ + protected function getMailerService(): MailerService + { + return Yii::$container->get(MailerService::class); + } + + /** + * Get token service. + */ + protected function getTokenService(): TokenService + { + return Yii::$container->get(TokenService::class); + } +} diff --git a/src/services/TokenService.php b/src/services/TokenService.php new file mode 100644 index 0000000..03a8601 --- /dev/null +++ b/src/services/TokenService.php @@ -0,0 +1,106 @@ +id, Token::TYPE_CONFIRMATION); + + $token = Token::createConfirmationToken($user); + + return $token->save() ? $token : null; + } + + /** + * Create a recovery token. + */ + public function createRecoveryToken(User $user): ?Token + { + // Delete existing recovery tokens + Token::deleteAllForUser($user->id, Token::TYPE_RECOVERY); + + $token = Token::createRecoveryToken($user); + + return $token->save() ? $token : null; + } + + /** + * Create an email change token. + */ + public function createEmailChangeToken(User $user, string $newEmail): ?Token + { + // Delete existing email change tokens + Token::deleteAllForUser($user->id, Token::TYPE_EMAIL_CHANGE); + + $token = Token::createEmailChangeToken($user, $newEmail); + + return $token->save() ? $token : null; + } + + /** + * Find and validate a confirmation token. + */ + public function findConfirmationToken(string $tokenString): ?Token + { + return Token::findByToken($tokenString, Token::TYPE_CONFIRMATION); + } + + /** + * Find and validate a recovery token. + */ + public function findRecoveryToken(string $tokenString): ?Token + { + return Token::findByToken($tokenString, Token::TYPE_RECOVERY); + } + + /** + * Find and validate an email change token. + */ + public function findEmailChangeToken(string $tokenString): ?Token + { + return Token::findByToken($tokenString, Token::TYPE_EMAIL_CHANGE); + } + + /** + * Delete a token. + */ + public function deleteToken(Token $token): bool + { + return $token->delete() !== false; + } + + /** + * Delete all tokens for a user. + */ + public function deleteAllUserTokens(User $user): int + { + return Token::deleteAllForUser($user->id); + } + + /** + * Cleanup expired tokens. + */ + public function cleanupExpiredTokens(): int + { + return Token::deleteExpired(); + } +} diff --git a/src/services/UserService.php b/src/services/UserService.php new file mode 100644 index 0000000..39499ef --- /dev/null +++ b/src/services/UserService.php @@ -0,0 +1,230 @@ +email = $email; + $user->password = $password; + + if ($confirmed) { + $user->status = User::STATUS_ACTIVE; + $user->email_confirmed_at = date('Y-m-d H:i:s'); + } + + if (!$user->save()) { + Yii::error('Failed to create user: ' . json_encode($user->errors), __METHOD__); + return null; + } + + return $user; + } + + /** + * Update user. + */ + public function update(User $user, array $attributes): bool + { + $user->setAttributes($attributes); + + if (!$user->save()) { + Yii::error('Failed to update user: ' . json_encode($user->errors), __METHOD__); + return false; + } + + return true; + } + + /** + * Delete user. + */ + public function delete(User $user): bool + { + return $user->delete() !== false; + } + + /** + * Block user. + */ + public function block(User $user): bool + { + return $user->block(); + } + + /** + * Unblock user. + */ + public function unblock(User $user): bool + { + return $user->unblock(); + } + + /** + * Confirm user email. + */ + public function confirm(User $user): bool + { + return $user->confirm(); + } + + /** + * Reset user password. + */ + public function resetPassword(User $user, string $password): bool + { + return $user->resetPassword($password); + } + + /** + * Generate a new password and send it to the user. + * + * @throws InvalidCallException if user is an admin + */ + public function resendPassword(User $user, MailerService $mailer): bool + { + if ($user->getIsAdmin()) { + throw new InvalidCallException(Yii::t('user', 'Password generation is not allowed for admin users.')); + } + + $password = Password::generate($this->module->minPasswordLength); + + if (!$user->resetPassword($password)) { + return false; + } + + return $mailer->sendGeneratedPasswordMessage($user, $password); + } + + /** + * Find user by ID. + */ + public function findById(int $id): ?User + { + return User::findOne($id); + } + + /** + * Find user by email. + */ + public function findByEmail(string $email): ?User + { + return User::findByEmail($email); + } + + /** + * Find user by username. + */ + public function findByUsername(string $username): ?User + { + return User::findByUsername($username); + } + + /** + * Check if user can be impersonated by current user. + */ + public function canImpersonate(User $targetUser): bool + { + if (!$this->module->enableImpersonation) { + return false; + } + + $currentUser = Yii::$app->user->identity; + + if (!$currentUser instanceof User) { + return false; + } + + // Can't impersonate yourself + if ($currentUser->id === $targetUser->id) { + return false; + } + + // Check admin permission + if (!$currentUser->getIsAdmin()) { + return false; + } + + // Check impersonate permission if configured + if ($this->module->impersonatePermission !== null && Yii::$app->authManager !== null) { + return Yii::$app->authManager->checkAccess($currentUser->id, $this->module->impersonatePermission); + } + + return true; + } + + /** + * Impersonate a user. + * + * @return string|null Previous user auth key for reverting, or null on failure + */ + public function impersonate(User $targetUser): ?string + { + if (!$this->canImpersonate($targetUser)) { + return null; + } + + $currentUser = Yii::$app->user->identity; + $previousAuthKey = $currentUser->auth_key; + + // Store original user for reverting + Yii::$app->session->set(AdminController::ORIGINAL_USER_SESSION_KEY, $currentUser->id); + + // Login as target user + Yii::$app->user->login($targetUser); + + return $previousAuthKey; + } + + /** + * Stop impersonating and return to original user. + */ + public function stopImpersonation(): bool + { + $originalUserId = Yii::$app->session->get(AdminController::ORIGINAL_USER_SESSION_KEY); + + if ($originalUserId === null) { + return false; + } + + $originalUser = $this->findById($originalUserId); + + if ($originalUser === null) { + return false; + } + + Yii::$app->session->remove(AdminController::ORIGINAL_USER_SESSION_KEY); + Yii::$app->user->login($originalUser); + + return true; + } + + /** + * Check if current user is impersonating. + */ + public function isImpersonating(): bool + { + return Yii::$app->session->has(AdminController::ORIGINAL_USER_SESSION_KEY); + } +} diff --git a/src/views/_alert.php b/src/views/_alert.php new file mode 100644 index 0000000..258dd02 --- /dev/null +++ b/src/views/_alert.php @@ -0,0 +1,20 @@ + + +enableFlashMessages): ?> +
+ session->getAllFlashes() as $type => $message): ?> + +
+ +
+ + +
+ diff --git a/src/views/admin/_account.php b/src/views/admin/_account.php new file mode 100644 index 0000000..41007dd --- /dev/null +++ b/src/views/admin/_account.php @@ -0,0 +1,31 @@ + + +beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?> + +activeFormClass; +$form = $formClass::begin([ + 'id' => 'account-form', + 'enableAjaxValidation' => true, + 'enableClientValidation' => false, +] + ($module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : [])); +?> + +render('_user', ['form' => $form, 'user' => $user]) ?> + +
+ 'user-btn user-btn-primary']) ?> +
+ + + +endContent() ?> diff --git a/src/views/admin/_assignments.php b/src/views/admin/_assignments.php new file mode 100644 index 0000000..8c214c1 --- /dev/null +++ b/src/views/admin/_assignments.php @@ -0,0 +1,21 @@ + + +beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?> + +
+ +
+ +authManager !== null): ?> +

+ +

+ + +endContent() ?> diff --git a/src/views/admin/_form.php b/src/views/admin/_form.php new file mode 100644 index 0000000..9b67d7a --- /dev/null +++ b/src/views/admin/_form.php @@ -0,0 +1,57 @@ +activeFormClass; +$formConfig = array_merge([ + 'id' => 'user-form', + 'enableAjaxValidation' => true, + 'enableClientValidation' => false, +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + +
+
+
+ + +
+
+ field($model, 'email') ?> +
+
+ field($model, 'username') ?> +
+
+ + field($model, 'password') + ->passwordInput() + ->hint($model->isNewRecord ? '' : Yii::t('user', 'Leave empty to keep current password.')) ?> + + isNewRecord): ?> + field($model, 'status')->dropDownList([ + User::STATUS_PENDING => Yii::t('user', 'Pending'), + User::STATUS_ACTIVE => Yii::t('user', 'Active'), + User::STATUS_BLOCKED => Yii::t('user', 'Blocked'), + ]) ?> + + +
+ isNewRecord ? Yii::t('user', 'Create') : Yii::t('user', 'Update'), + ['class' => 'user-btn user-btn-primary'] + ) ?> + 'user-btn user-btn-secondary']) ?> +
+ + +
+
+
diff --git a/src/views/admin/_info.php b/src/views/admin/_info.php new file mode 100644 index 0000000..776a01d --- /dev/null +++ b/src/views/admin/_info.php @@ -0,0 +1,52 @@ + + +beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?> + + + + + + + registration_ip !== null): ?> + + + + + + + + isConfirmed): ?> + + + + + + + + isBlocked): ?> + + + + + + last_login_at !== null): ?> + + + + + + last_login_ip !== null): ?> + + + + + +
:created_at)]) ?>
:registration_ip ?>
:email_confirmed_at)]) ?>
:blocked_at)]) ?>
:last_login_at)]) ?>
:last_login_ip ?>
+ +endContent() ?> diff --git a/src/views/admin/_menu.php b/src/views/admin/_menu.php new file mode 100644 index 0000000..4236336 --- /dev/null +++ b/src/views/admin/_menu.php @@ -0,0 +1,13 @@ + + + diff --git a/src/views/admin/_profile.php b/src/views/admin/_profile.php new file mode 100644 index 0000000..807b087 --- /dev/null +++ b/src/views/admin/_profile.php @@ -0,0 +1,36 @@ + + +beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?> + +activeFormClass; +$form = $formClass::begin([ + 'id' => 'profile-form', + 'enableAjaxValidation' => true, + 'enableClientValidation' => false, +] + ($module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : [])); +?> + +field($profile, 'name') ?> +field($profile, 'public_email') ?> +field($profile, 'website') ?> +field($profile, 'location') ?> +field($profile, 'bio')->textarea() ?> + +
+ 'user-btn user-btn-primary']) ?> +
+ + + +endContent() ?> diff --git a/src/views/admin/_user.php b/src/views/admin/_user.php new file mode 100644 index 0000000..32f9c27 --- /dev/null +++ b/src/views/admin/_user.php @@ -0,0 +1,11 @@ + + +field($user, 'email')->textInput(['maxlength' => 255]) ?> +field($user, 'username')->textInput(['maxlength' => 255]) ?> +field($user, 'password')->passwordInput() ?> diff --git a/src/views/admin/create.php b/src/views/admin/create.php new file mode 100644 index 0000000..d1f698c --- /dev/null +++ b/src/views/admin/create.php @@ -0,0 +1,27 @@ +title = Yii::t('user', 'Create User'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Manage Users'), 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +render('/_alert', ['module' => Yii::$app->getModule('user')]) ?> + +render('_menu') ?> + +
+

title) ?>

+ + render('_form', [ + 'model' => $model, + 'module' => $module, + ]) ?> +
diff --git a/src/views/admin/index.php b/src/views/admin/index.php new file mode 100644 index 0000000..6c27afc --- /dev/null +++ b/src/views/admin/index.php @@ -0,0 +1,130 @@ +title = Yii::t('user', 'Manage Users'); +$this->params['breadcrumbs'][] = $this->title; +?> + +render('/_alert', ['module' => Yii::$app->getModule('user')]) ?> + +render('_menu') ?> + +
+
+

title) ?>

+ 'user-btn user-btn-primary']) ?> +
+ +
+
+ $dataProvider, + 'filterModel' => $searchModel, + 'tableOptions' => ['class' => 'user-table'], + 'columns' => [ + 'id', + 'email:email', + 'username', + [ + 'attribute' => 'status', + 'format' => 'raw', + 'filter' => [ + User::STATUS_PENDING => Yii::t('user', 'Pending'), + User::STATUS_ACTIVE => Yii::t('user', 'Active'), + User::STATUS_BLOCKED => Yii::t('user', 'Blocked'), + ], + 'value' => function (User $model) { + $classes = [ + User::STATUS_PENDING => 'user-badge user-badge-warning', + User::STATUS_ACTIVE => 'user-badge user-badge-success', + User::STATUS_BLOCKED => 'user-badge user-badge-danger', + ]; + $class = $classes[$model->status] ?? 'user-badge'; + return Html::tag('span', Html::encode($model->status), ['class' => $class]); + }, + ], + [ + 'attribute' => 'email_confirmed_at', + 'format' => 'raw', + 'value' => function (User $model) { + if ($model->getIsConfirmed()) { + return '' . Yii::t('user', 'Confirmed') . ''; + } + return '' . Yii::t('user', 'Unconfirmed') . ''; + }, + ], + 'created_at:datetime', + 'last_login_at:datetime', + [ + 'class' => 'yii\grid\ActionColumn', + 'template' => '{update} {block} {confirm} {impersonate} {delete}', + 'buttons' => [ + 'block' => function ($url, User $model) use ($module) { + if ($model->id === Yii::$app->user->id) { + return ''; + } + if ($model->getIsBlocked()) { + return Html::a(Yii::t('user', 'Unblock'), ['unblock', 'id' => $model->id], [ + 'class' => 'user-btn user-btn-sm user-btn-success', + 'title' => Yii::t('user', 'Unblock'), + 'data' => ['method' => 'post', 'confirm' => Yii::t('user', 'Are you sure you want to unblock this user?')], + ]); + } + return Html::a(Yii::t('user', 'Block'), ['block', 'id' => $model->id], [ + 'class' => 'user-btn user-btn-sm user-btn-warning', + 'title' => Yii::t('user', 'Block'), + 'data' => ['method' => 'post', 'confirm' => Yii::t('user', 'Are you sure you want to block this user?')], + ]); + }, + 'confirm' => function ($url, User $model) { + if ($model->getIsConfirmed()) { + return ''; + } + return Html::a(Yii::t('user', 'Confirm'), ['confirm', 'id' => $model->id], [ + 'class' => 'user-btn user-btn-sm user-btn-info', + 'title' => Yii::t('user', 'Confirm'), + 'data' => ['method' => 'post'], + ]); + }, + 'impersonate' => function ($url, User $model) use ($module) { + if (!$module->enableImpersonation || $model->id === Yii::$app->user->id) { + return ''; + } + return Html::a(Yii::t('user', 'Impersonate'), ['impersonate', 'id' => $model->id], [ + 'class' => 'user-btn user-btn-sm user-btn-secondary', + 'title' => Yii::t('user', 'Impersonate'), + ]); + }, + 'update' => function ($url, User $model) { + return Html::a(Yii::t('user', 'Update'), ['update', 'id' => $model->id], [ + 'class' => 'user-btn user-btn-sm user-btn-primary', + 'title' => Yii::t('user', 'Update'), + ]); + }, + 'delete' => function ($url, User $model) { + if ($model->id === Yii::$app->user->id) { + return ''; + } + return Html::a(Yii::t('user', 'Delete'), ['delete', 'id' => $model->id], [ + 'class' => 'user-btn user-btn-sm user-btn-danger', + 'title' => Yii::t('user', 'Delete'), + 'data' => ['method' => 'post', 'confirm' => Yii::t('user', 'Are you sure you want to delete this user?')], + ]); + }, + ], + ], + ], + ]); ?> +
+
+
diff --git a/src/views/admin/update.php b/src/views/admin/update.php new file mode 100644 index 0000000..9f10eaf --- /dev/null +++ b/src/views/admin/update.php @@ -0,0 +1,63 @@ +title = Yii::t('user', 'Update user account'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Users'), 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; +?> + +render('/_alert', ['module' => Yii::$app->getModule('user')]) ?> + +render('_menu') ?> + +
+
+
+ +
+
+
+
+ +
+
+
+
+
diff --git a/src/views/gdpr/delete.php b/src/views/gdpr/delete.php new file mode 100644 index 0000000..c199de2 --- /dev/null +++ b/src/views/gdpr/delete.php @@ -0,0 +1,60 @@ +title = Yii::t('user', 'Delete Account'); +$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Privacy & Data'), 'url' => ['index']]; +$this->params['breadcrumbs'][] = $this->title; + +$formClass = $module->activeFormClass; +$formConfig = array_merge([ + 'id' => 'delete-account-form', +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + +
+
+
+
+

title) ?>

+
+
+
+

+

+
    +
  • +
  • +
  • +
+
+ + + + field($model, 'password')->passwordInput([ + 'placeholder' => Yii::t('user', 'Enter your current password'), + ]) ?> + + field($model, 'confirm')->checkbox([ + 'label' => Yii::t('user', 'I understand this action cannot be undone'), + ]) ?> + +
+ 'user-btn user-btn-danger'] + ) ?> + 'user-btn user-btn-secondary']) ?> +
+ + +
+
+
+
diff --git a/src/views/gdpr/index.php b/src/views/gdpr/index.php new file mode 100644 index 0000000..a8f12d8 --- /dev/null +++ b/src/views/gdpr/index.php @@ -0,0 +1,54 @@ +title = Yii::t('user', 'Privacy & Data'); +$this->params['breadcrumbs'][] = $this->title; +?> + +
+ +
diff --git a/src/views/mail/confirmation-text.php b/src/views/mail/confirmation-text.php new file mode 100644 index 0000000..e016fe0 --- /dev/null +++ b/src/views/mail/confirmation-text.php @@ -0,0 +1,20 @@ + + + + + + + + round($module->confirmWithin / 3600)]) ?> + + diff --git a/src/views/mail/confirmation.php b/src/views/mail/confirmation.php new file mode 100644 index 0000000..9e166cd --- /dev/null +++ b/src/views/mail/confirmation.php @@ -0,0 +1,37 @@ + +

+ +

+ +

+ + + +

+ +

+ +
+ +

+ +

+ round($module->confirmWithin / 3600)]) ?> +

+ +

+ +

diff --git a/src/views/mail/generated_password-text.php b/src/views/mail/generated_password-text.php new file mode 100644 index 0000000..d05ce9e --- /dev/null +++ b/src/views/mail/generated_password-text.php @@ -0,0 +1,19 @@ + + + + Yii::$app->name]) ?> + + + + + + diff --git a/src/views/mail/generated_password.php b/src/views/mail/generated_password.php new file mode 100644 index 0000000..ab25944 --- /dev/null +++ b/src/views/mail/generated_password.php @@ -0,0 +1,27 @@ + +

+ +

Html::encode(Yii::$app->name)]) ?>

+ +

+ +

+ +

+ +

+ +

+ +

diff --git a/src/views/mail/layouts/html.php b/src/views/mail/layouts/html.php new file mode 100644 index 0000000..b6dafb5 --- /dev/null +++ b/src/views/mail/layouts/html.php @@ -0,0 +1,34 @@ + +beginPage() ?> + + + + + + head() ?> + + +beginBody() ?> + +
+
+ +
+
+

© name) ?>

+
+
+ +endBody() ?> + + +endPage() ?> diff --git a/src/views/mail/recovery-text.php b/src/views/mail/recovery-text.php new file mode 100644 index 0000000..2cd0012 --- /dev/null +++ b/src/views/mail/recovery-text.php @@ -0,0 +1,20 @@ + + + + + + + + round($module->recoverWithin / 3600)]) ?> + + diff --git a/src/views/mail/recovery.php b/src/views/mail/recovery.php new file mode 100644 index 0000000..b2bf6c6 --- /dev/null +++ b/src/views/mail/recovery.php @@ -0,0 +1,37 @@ + +

+ +

+ +

+ + + +

+ +

+ +
+ +

+ +

+ round($module->recoverWithin / 3600)]) ?> +

+ +

+ +

diff --git a/src/views/mail/welcome-text.php b/src/views/mail/welcome-text.php new file mode 100644 index 0000000..e07198b --- /dev/null +++ b/src/views/mail/welcome-text.php @@ -0,0 +1,27 @@ + + Yii::$app->name]) ?> + + + + + + + + + round($module->confirmWithin / 3600)]) ?> + + + + + + diff --git a/src/views/mail/welcome.php b/src/views/mail/welcome.php new file mode 100644 index 0000000..1d2b5b9 --- /dev/null +++ b/src/views/mail/welcome.php @@ -0,0 +1,43 @@ + +

Html::encode(Yii::$app->name)]) ?>

+ +

+ + +

+ +

+ + + +

+ +

+ +
+ +

+ +

+ round($module->confirmWithin / 3600)]) ?> +

+ +

+ + +

+ +

diff --git a/src/views/message.php b/src/views/message.php new file mode 100644 index 0000000..d288352 --- /dev/null +++ b/src/views/message.php @@ -0,0 +1,12 @@ +title = $title; +?> + +render('/_alert', ['module' => $module]) ?> diff --git a/src/views/recovery/request.php b/src/views/recovery/request.php new file mode 100644 index 0000000..d6e3081 --- /dev/null +++ b/src/views/recovery/request.php @@ -0,0 +1,51 @@ +title = Yii::t('user', 'Forgot Password'); +$this->params['breadcrumbs'][] = $this->title; + +$formClass = $module->activeFormClass; +$formConfig = array_merge([ + 'id' => 'recovery-form', +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + +
+
+
+
+

title) ?>

+ +

+ +

+ + + + field($model, 'email') + ->textInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'Email')]) ?> + +
+ 'user-btn user-btn-primary user-btn-lg']) ?> +
+ + + +
+ + +
+
+
+
diff --git a/src/views/recovery/reset.php b/src/views/recovery/reset.php new file mode 100644 index 0000000..210d10b --- /dev/null +++ b/src/views/recovery/reset.php @@ -0,0 +1,46 @@ +title = Yii::t('user', 'Reset Password'); +$this->params['breadcrumbs'][] = $this->title; + +$formClass = $module->activeFormClass; +$formConfig = array_merge([ + 'id' => 'reset-form', +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + +
+
+
+
+

title) ?>

+ +

+ +

+ + + + field($model, 'password') + ->passwordInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'New Password')]) ?> + + field($model, 'password_confirm') + ->passwordInput(['placeholder' => Yii::t('user', 'Confirm Password')]) ?> + +
+ 'user-btn user-btn-primary user-btn-lg']) ?> +
+ + +
+
+
+
diff --git a/src/views/registration/register.php b/src/views/registration/register.php new file mode 100644 index 0000000..3d6e9a9 --- /dev/null +++ b/src/views/registration/register.php @@ -0,0 +1,58 @@ +title = Yii::t('user', 'Sign Up'); +$this->params['breadcrumbs'][] = $this->title; + +$formClass = $module->activeFormClass; +$formConfig = array_merge([ + 'id' => 'registration-form', + 'enableAjaxValidation' => true, + 'enableClientValidation' => false, +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + +
+
+
+
+

title) ?>

+ + + + field($model, 'email') + ->textInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'Email')]) ?> + + field($model, 'username') + ->textInput(['placeholder' => Yii::t('user', 'Username (optional)')]) ?> + + enableGeneratedPassword): ?> + field($model, 'password') + ->passwordInput(['placeholder' => Yii::t('user', 'Password')]) ?> + + +
+ 'user-btn user-btn-primary user-btn-lg']) ?> +
+ + + +
+ + +
+
+
+
diff --git a/src/views/registration/resend.php b/src/views/registration/resend.php new file mode 100644 index 0000000..d32fa12 --- /dev/null +++ b/src/views/registration/resend.php @@ -0,0 +1,51 @@ +title = Yii::t('user', 'Resend Confirmation'); +$this->params['breadcrumbs'][] = $this->title; + +$formClass = $module->activeFormClass; +$formConfig = array_merge([ + 'id' => 'resend-form', +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + +
+
+
+
+

title) ?>

+ +

+ +

+ + + + field($model, 'email') + ->textInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'Email')]) ?> + +
+ 'user-btn user-btn-primary user-btn-lg']) ?> +
+ + + +
+ + +
+
+
+
diff --git a/src/views/security/login.php b/src/views/security/login.php new file mode 100644 index 0000000..4618809 --- /dev/null +++ b/src/views/security/login.php @@ -0,0 +1,63 @@ +title = Yii::t('user', 'Sign In'); +$this->params['breadcrumbs'][] = $this->title; + +$formClass = $module->activeFormClass; +$formConfig = array_merge([ + 'id' => 'login-form', + 'enableAjaxValidation' => true, + 'enableClientValidation' => false, +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + + diff --git a/src/views/settings/_menu.php b/src/views/settings/_menu.php new file mode 100644 index 0000000..82cd5af --- /dev/null +++ b/src/views/settings/_menu.php @@ -0,0 +1,31 @@ +controller->action->id; +?> + + diff --git a/src/views/settings/account.php b/src/views/settings/account.php new file mode 100644 index 0000000..fe3ac8c --- /dev/null +++ b/src/views/settings/account.php @@ -0,0 +1,62 @@ +title = Yii::t('user', 'Account Settings'); +$this->params['breadcrumbs'][] = $this->title; + +$formClass = $module->activeFormClass; +$formConfig = array_merge([ + 'id' => 'account-form', + 'enableAjaxValidation' => true, + 'enableClientValidation' => false, +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + + diff --git a/src/views/settings/profile.php b/src/views/settings/profile.php new file mode 100644 index 0000000..cfdedb8 --- /dev/null +++ b/src/views/settings/profile.php @@ -0,0 +1,93 @@ +title = Yii::t('user', 'Profile Settings'); +$this->params['breadcrumbs'][] = $this->title; + +$formClass = $module->activeFormClass; +$formConfig = array_merge([ + 'id' => 'profile-form', + 'options' => ['enctype' => 'multipart/form-data'], +], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []); +?> + + diff --git a/src/widgets/Login.php b/src/widgets/Login.php new file mode 100644 index 0000000..89a61e8 --- /dev/null +++ b/src/widgets/Login.php @@ -0,0 +1,50 @@ +getModule('user'); + + /** @var LoginForm $model */ + $model = $module->createModel('LoginForm'); + + return $this->render($this->view, [ + 'model' => $model, + 'module' => $module, + 'enableAjaxValidation' => $this->enableAjaxValidation, + 'action' => $this->action ?? ['/user/security/login'], + ]); + } +}