init commit

This commit is contained in:
2026-01-27 19:18:29 +01:00
commit 4389470233
76 changed files with 7355 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@@ -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

98
AGENT.md Normal file
View File

@@ -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 <email> [password]
php yii user/delete <email>
php yii user/confirm <email>
php yii user/password <email> [password]
php yii user/block <email>
php yii user/unblock <email>
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

21
LICENSE.md Normal file
View File

@@ -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.

462
README.md Normal file
View File

@@ -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
<?php
use yii\db\Migration;
class m241215_000000_migrate_custom_user_fields extends Migration
{
public function safeUp()
{
// Add custom column to new user table
$this->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.

50
composer.json Normal file
View File

@@ -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
}
}
}

32
phpunit.xml Normal file
View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
executionOrder="depends,defects"
requireCoverageMetadata="true"
beStrictAboutCoverageMetadata="true"
beStrictAboutOutputDuringTests="true"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="Unit">
<directory>tests/unit</directory>
</testsuite>
<testsuite name="Functional">
<directory>tests/functional</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
<php>
<env name="YII_ENV" value="test"/>
<env name="YII_DEBUG" value="true"/>
</php>
</phpunit>

156
src/Bootstrap.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace cgsmith\user;
use Yii;
use yii\base\Application;
use yii\base\BootstrapInterface;
use yii\console\Application as ConsoleApplication;
use yii\web\Application as WebApplication;
/**
* Bootstrap class for the user module.
*
* Registers URL rules and container bindings.
*/
class Bootstrap implements BootstrapInterface
{
/**
* {@inheritdoc}
*/
public function bootstrap($app): void
{
/** @var Module|null $module */
$module = $app->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/<id:\d+>/<token:[A-Za-z0-9_-]+>' => 'registration/confirm',
'resend' => 'registration/resend',
// Password Recovery
'recovery' => 'recovery/request',
'recovery/<id:\d+>/<token:[A-Za-z0-9_-]+>' => '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/<id:\d+>' => 'admin/update',
'admin/delete/<id:\d+>' => 'admin/delete',
'admin/block/<id:\d+>' => 'admin/block',
'admin/unblock/<id:\d+>' => 'admin/unblock',
'admin/confirm/<id:\d+>' => 'admin/confirm',
'admin/impersonate/<id:\d+>' => 'admin/impersonate',
];
// GDPR routes
if ($module->enableGdpr) {
$rules['gdpr'] = 'gdpr/index';
$rules['gdpr/export'] = 'gdpr/export';
$rules['gdpr/delete'] = 'gdpr/delete';
}
return $rules;
}
}

428
src/Module.php Normal file
View File

@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace cgsmith\user;
use Yii;
use yii\base\Application;
use yii\base\BootstrapInterface;
use yii\base\Module as BaseModule;
use yii\console\Application as ConsoleApplication;
use yii\web\Application as WebApplication;
/**
* User module for Yii2.
*
* @property-read string $version
*/
class Module extends BaseModule implements BootstrapInterface
{
public const VERSION = '1.0.0';
/**
* Email change strategies
*/
public const EMAIL_CHANGE_INSECURE = 0; // Change immediately
public const EMAIL_CHANGE_DEFAULT = 1; // Confirm new email only
public const EMAIL_CHANGE_SECURE = 2; // Confirm both old and new email
/**
* Whether to enable user registration.
*/
public bool $enableRegistration = true;
/**
* Whether to require email confirmation after registration.
*/
public bool $enableConfirmation = true;
/**
* Whether to allow login without email confirmation.
*/
public bool $enableUnconfirmedLogin = false;
/**
* Whether to enable password recovery.
*/
public bool $enablePasswordRecovery = true;
/**
* Whether to enable GDPR features (data export, account deletion).
* @todo GDPR is not yet fully implemented - planned for v2
*/
public bool $enableGdpr = false;
/**
* Whether to enable user impersonation by admins.
*/
public bool $enableImpersonation = true;
/**
* Whether to generate password automatically during registration.
*/
public bool $enableGeneratedPassword = false;
/**
* Whether to enable gravatar support for profile avatars.
*/
public bool $enableGravatar = true;
/**
* Whether to enable local avatar uploads.
*/
public bool $enableAvatarUpload = true;
/**
* Whether to show flash messages in module views.
*/
public bool $enableFlashMessages = true;
/**
* Whether to enable account deletion by users.
*/
public bool $enableAccountDelete = true;
/**
* Email change strategy.
*/
public int $emailChangeStrategy = self::EMAIL_CHANGE_DEFAULT;
/**
* Duration (in seconds) for "remember me" login. Default: 2 weeks.
*/
public int $rememberFor = 1209600;
/**
* Duration (in seconds) before confirmation token expires. Default: 24 hours.
*/
public int $confirmWithin = 86400;
/**
* Duration (in seconds) before recovery token expires. Default: 6 hours.
*/
public int $recoverWithin = 21600;
/**
* Minimum password length.
*/
public int $minPasswordLength = 8;
/**
* Maximum password length.
*/
public int $maxPasswordLength = 72;
/**
* Cost parameter for password hashing (bcrypt).
*/
public int $cost = 12;
/**
* Admin email addresses (for fallback admin check when RBAC is not configured).
*/
public array $admins = [];
/**
* RBAC permission name that grants admin access.
*/
public ?string $adminPermission = null;
/**
* RBAC permission name required to impersonate users.
*/
public ?string $impersonatePermission = null;
/**
* Mailer configuration.
*/
public array $mailer = [];
/**
* Model class map for overriding default models.
*/
public array $modelMap = [];
/**
* Identity class for user component (convenience property).
* If set, overrides modelMap['User'] for the identity class.
*/
public ?string $identityClass = null;
/**
* URL prefix for module routes.
*/
public string $urlPrefix = 'user';
/**
* Default controller namespace.
*/
public $controllerNamespace = 'cgsmith\user\controllers';
/**
* Path to avatar upload directory.
*/
public string $avatarPath = '@webroot/uploads/avatars';
/**
* URL to avatar directory.
*/
public string $avatarUrl = '@web/uploads/avatars';
/**
* Maximum avatar file size in bytes. Default: 2MB.
*/
public int $maxAvatarSize = 2097152;
/**
* Allowed avatar file extensions.
*/
public array $avatarExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
/**
* ActiveForm class to use in views.
* Change this to match your Bootstrap version:
* - 'yii\bootstrap\ActiveForm' for Bootstrap 3
* - 'yii\bootstrap4\ActiveForm' for Bootstrap 4
* - 'yii\bootstrap5\ActiveForm' for Bootstrap 5
* - 'yii\widgets\ActiveForm' for no Bootstrap dependency
*/
public string $activeFormClass = 'yii\widgets\ActiveForm';
/**
* Form field configuration for ActiveForm.
* Override this to customize field templates for your CSS framework.
*/
public array $formFieldConfig = [];
/**
* Default model classes.
*/
private array $defaultModelMap = [
'User' => '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/<id:\d+>/<token:[A-Za-z0-9_-]+>' => 'registration/confirm',
'resend' => 'registration/resend',
'recovery' => 'recovery/request',
'recovery/<id:\d+>/<token:[A-Za-z0-9_-]+>' => '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/<id:\d+>' => 'admin/update',
'admin/delete/<id:\d+>' => 'admin/delete',
'admin/block/<id:\d+>' => 'admin/block',
'admin/unblock/<id:\d+>' => 'admin/unblock',
'admin/confirm/<id:\d+>' => 'admin/confirm',
'admin/impersonate/<id:\d+>' => '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',
];
}
}
}

View File

@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\commands;
use cgsmith\user\Module;
use Yii;
use yii\console\Controller;
use yii\console\ExitCode;
use yii\db\Connection;
use yii\helpers\Console;
/**
* Migrate users from dektrium/yii2-user to cgsmith/yii2-user.
*
* Usage:
* yii migrate-from-dektrium/preview Preview migration changes
* yii migrate-from-dektrium/execute Execute migration
* yii migrate-from-dektrium/rollback Rollback migration
*/
class MigrateFromDektriumController extends Controller
{
/**
* @var Module
*/
public $module;
/**
* @var string Database component ID
*/
public string $db = 'db';
/**
* Preview migration - show what will be changed.
*/
public function actionPreview(): int
{
$this->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);
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\commands;
use cgsmith\user\helpers\Password;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\console\Controller;
use yii\console\ExitCode;
use yii\helpers\Console;
/**
* User management console commands.
*
* Usage:
* yii user/create <email> [password] Create a new user
* yii user/delete <email> Delete a user
* yii user/password <email> [password] Change user password
* yii user/confirm <email> Confirm user email
* yii user/block <email> Block a user
* yii user/unblock <email> 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;
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\contracts;
use yii\web\IdentityInterface;
/**
* Interface for User model.
*/
interface UserInterface extends IdentityInterface
{
/**
* Check if user is an administrator.
*/
public function getIsAdmin(): bool;
/**
* Check if user is blocked.
*/
public function getIsBlocked(): bool;
/**
* Check if user email is confirmed.
*/
public function getIsConfirmed(): bool;
/**
* Get the user's profile.
*/
public function getProfile(): mixed;
}

View File

@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\filters\AccessRule;
use cgsmith\user\models\User;
use cgsmith\user\models\UserSearch;
use cgsmith\user\Module;
use cgsmith\user\services\RegistrationService;
use cgsmith\user\services\MailerService;
use cgsmith\user\services\UserService;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* Admin controller for user management.
*/
class AdminController extends Controller
{
/**
* Session key for storing original user ID during impersonation.
*/
public const ORIGINAL_USER_SESSION_KEY = 'user.original_user_id';
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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;
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\db\Expression;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* GDPR controller for data export and deletion.
*/
class GdprController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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,
]);
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\RecoveryForm;
use cgsmith\user\models\RecoveryResetForm;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\RecoveryService;
use Yii;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* Password recovery controller.
*/
class RecoveryController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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,
]);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\RegistrationForm;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\RegistrationService;
use Yii;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* Registration controller.
*/
class RegistrationController extends Controller
{
public const EVENT_BEFORE_REGISTER = 'beforeRegister';
public const EVENT_AFTER_REGISTER = 'afterRegister';
public const EVENT_BEFORE_CONFIRM = 'beforeConfirm';
public const EVENT_AFTER_CONFIRM = 'afterConfirm';
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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,
]);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\events\FormEvent;
use cgsmith\user\models\LoginForm;
use cgsmith\user\Module;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\Response;
/**
* Security controller for login/logout.
*/
class SecurityController extends Controller
{
public const EVENT_BEFORE_LOGIN = 'beforeLogin';
public const EVENT_AFTER_LOGIN = 'afterLogin';
public const EVENT_BEFORE_LOGOUT = 'beforeLogout';
public const EVENT_AFTER_LOGOUT = 'afterLogout';
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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();
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\Profile;
use cgsmith\user\models\SettingsForm;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\MailerService;
use cgsmith\user\services\TokenService;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\Response;
use yii\web\UploadedFile;
/**
* Settings controller for account and profile.
*/
class SettingsController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'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']);
}
}

19
src/events/FormEvent.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\events;
use yii\base\Event;
use yii\base\Model;
/**
* Form-related event.
*/
class FormEvent extends Event
{
/**
* The form model associated with this event.
*/
public ?Model $form = null;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\events;
use cgsmith\user\models\RegistrationForm;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use yii\base\Event;
/**
* Registration-related event.
*/
class RegistrationEvent extends Event
{
/**
* The user associated with this event.
*/
public ?User $user = null;
/**
* The registration form.
*/
public ?RegistrationForm $form = null;
/**
* The confirmation token (if applicable).
*/
public ?Token $token = null;
}

19
src/events/UserEvent.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\events;
use cgsmith\user\models\User;
use yii\base\Event;
/**
* User-related event.
*/
class UserEvent extends Event
{
/**
* The user associated with this event.
*/
public ?User $user = null;
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\filters;
use cgsmith\user\contracts\UserInterface;
use yii\filters\AccessRule as BaseAccessRule;
/**
* Access rule that supports the 'admin' role.
*
* This rule extends Yii's AccessRule to add support for checking
* if a user is an admin via the UserInterface::getIsAdmin() method.
*/
class AccessRule extends BaseAccessRule
{
/**
* {@inheritdoc}
*/
protected function matchRole($user): bool
{
if (empty($this->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;
}
}

96
src/helpers/Password.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\helpers;
use Yii;
/**
* Password helper for hashing and validation.
*/
class Password
{
/**
* Hash a password.
*/
public static function hash(string $password, int $cost = 12): string
{
return Yii::$app->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,
];
}
}

193
src/messages/en/user.php Normal file
View File

@@ -0,0 +1,193 @@
<?php
/**
* English translations for cgsmith/yii2-user.
*/
return [
// General
'ID' => '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.',
];

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use yii\db\Migration;
/**
* Create user table.
*/
class m250115_000001_create_user_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp(): void
{
$tableOptions = null;
if ($this->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}}');
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use yii\db\Migration;
/**
* Create user_profile table.
*/
class m250115_000002_create_profile_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp(): void
{
$tableOptions = null;
if ($this->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}}');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use yii\db\Migration;
/**
* Create user_token table.
*/
class m250115_000003_create_token_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp(): void
{
$tableOptions = null;
if ($this->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}}');
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use yii\db\Migration;
/**
* Create user_social_account table (placeholder for v2.0 social login).
*/
class m250115_000004_create_social_account_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp(): void
{
$tableOptions = null;
if ($this->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}}');
}
}

124
src/models/LoginForm.php Normal file
View File

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\Module;
use Yii;
use yii\base\Model;
/**
* Login form model.
*/
class LoginForm extends Model
{
public ?string $login = null;
public ?string $password = null;
public bool $rememberMe = false;
private ?User $_user = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['login', 'password'], 'required'],
[['login'], 'string'],
[['password'], 'string'],
[['rememberMe'], 'boolean'],
[['password'], 'validatePassword'],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'login' => 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;
}
}

153
src/models/Profile.php Normal file
View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\models\query\ProfileQuery;
use Yii;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
use yii\db\Expression;
/**
* Profile ActiveRecord model.
*
* @property int $user_id
* @property string|null $name
* @property string|null $bio
* @property string|null $location
* @property string|null $website
* @property string|null $timezone
* @property string|null $avatar_path
* @property string|null $gravatar_email
* @property bool $use_gravatar
* @property string|null $public_email
* @property string $created_at
* @property string $updated_at
*
* @property-read User $user
* @property-read string|null $avatarUrl
*/
class Profile extends ActiveRecord
{
/**
* {@inheritdoc}
*/
public static function tableName(): string
{
return '{{%user_profile}}';
}
/**
* {@inheritdoc}
* @return ProfileQuery
*/
public static function find(): ProfileQuery
{
return new ProfileQuery(static::class);
}
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
[
'class' => 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;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use Yii;
use yii\base\Model;
/**
* Password recovery request form.
*/
class RecoveryForm extends Model
{
public ?string $email = null;
private ?User $_user = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
['email', 'trim'],
['email', 'required'],
['email', 'email'],
['email', 'validateEmail'],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'email' => 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;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\Module;
use Yii;
use yii\base\Model;
/**
* Password reset form (after recovery).
*/
class RecoveryResetForm extends Model
{
public ?string $password = null;
public ?string $password_confirm = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
$module = $this->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;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\Module;
use Yii;
use yii\base\Model;
/**
* Registration form model.
*/
class RegistrationForm extends Model
{
public ?string $email = null;
public ?string $username = null;
public ?string $password = null;
/**
* {@inheritdoc}
*/
public function rules()
{
$module = $this->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;
}
}

128
src/models/SettingsForm.php Normal file
View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\Module;
use Yii;
use yii\base\Model;
/**
* Account settings form.
*/
class SettingsForm extends Model
{
public ?string $email = null;
public ?string $username = null;
public ?string $new_password = null;
public ?string $new_password_confirm = null;
public ?string $current_password = null;
private User $_user;
public function __construct(User $user, array $config = [])
{
$this->_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;
}
}

219
src/models/Token.php Normal file
View File

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\models\query\TokenQuery;
use Yii;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
use yii\db\Expression;
/**
* Token ActiveRecord model.
*
* @property int $id
* @property int $user_id
* @property string $type
* @property string $token
* @property array|null $data
* @property string $expires_at
* @property string $created_at
*
* @property-read User $user
* @property-read bool $isExpired
*/
class Token extends ActiveRecord
{
public const TYPE_CONFIRMATION = 'confirmation';
public const TYPE_RECOVERY = 'recovery';
public const TYPE_EMAIL_CHANGE = 'email_change';
/**
* {@inheritdoc}
*/
public static function tableName(): string
{
return '{{%user_token}}';
}
/**
* {@inheritdoc}
* @return TokenQuery
*/
public static function find(): TokenQuery
{
return new TokenQuery(static::class);
}
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['user_id', 'type', 'token', 'expires_at'], 'required'],
[['type'], 'in', 'range' => [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()')]);
}
}

368
src/models/User.php Normal file
View File

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\contracts\UserInterface;
use cgsmith\user\helpers\Password;
use cgsmith\user\models\query\UserQuery;
use cgsmith\user\Module;
use Yii;
use yii\base\NotSupportedException;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
use yii\db\Expression;
use yii\web\IdentityInterface;
/**
* User ActiveRecord model.
*
* @property int $id
* @property string $email
* @property string|null $username
* @property string $password_hash
* @property string $auth_key
* @property string $status
* @property string|null $email_confirmed_at
* @property string|null $blocked_at
* @property string|null $last_login_at
* @property string|null $last_login_ip
* @property string|null $registration_ip
* @property string $created_at
* @property string $updated_at
* @property string|null $gdpr_consent_at
* @property string|null $gdpr_deleted_at
*
* @property-read bool $isAdmin
* @property-read bool $isBlocked
* @property-read bool $isConfirmed
* @property-read Profile $profile
* @property-read Token[] $tokens
*/
class User extends ActiveRecord implements IdentityInterface, UserInterface
{
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_BLOCKED = 'blocked';
/**
* Plain password for validation and hashing.
*/
public ?string $password = null;
/**
* {@inheritdoc}
*/
public static function tableName(): string
{
return '{{%user}}';
}
/**
* {@inheritdoc}
* @return UserQuery
*/
public static function find(): UserQuery
{
return new UserQuery(static::class);
}
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
[
'class' => 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;
}
}

75
src/models/UserSearch.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use yii\base\Model;
use yii\data\ActiveDataProvider;
/**
* User search model for admin grid.
*/
class UserSearch extends Model
{
public ?int $id = null;
public ?string $email = null;
public ?string $username = null;
public ?string $status = null;
public ?string $created_at = null;
public ?string $last_login_at = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['id'], 'integer'],
[['email', 'username', 'status', 'created_at', 'last_login_at'], 'safe'],
];
}
/**
* Search users.
*/
public function search(array $params): ActiveDataProvider
{
$query = User::find();
$dataProvider = new ActiveDataProvider([
'query' => $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;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models\query;
use cgsmith\user\models\Profile;
use yii\db\ActiveQuery;
/**
* Profile query class.
*
* @method Profile|null one($db = null)
* @method Profile[] all($db = null)
*/
class ProfileQuery extends ActiveQuery
{
/**
* Filter by user ID.
*/
public function byUserId(int $userId): static
{
return $this->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]);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models\query;
use cgsmith\user\models\Token;
use yii\db\ActiveQuery;
use yii\db\Expression;
/**
* Token query class.
*
* @method Token|null one($db = null)
* @method Token[] all($db = null)
*/
class TokenQuery extends ActiveQuery
{
/**
* Filter by token type.
*/
public function byType(string $type): static
{
return $this->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);
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models\query;
use cgsmith\user\models\User;
use yii\db\ActiveQuery;
/**
* User query class.
*
* @method User|null one($db = null)
* @method User[] all($db = null)
*/
class UserQuery extends ActiveQuery
{
/**
* Filter by active status (not blocked, not soft deleted).
*/
public function active(): static
{
return $this
->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]);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\helpers\Url;
use yii\mail\MailerInterface;
/**
* Email service for user-related emails.
*/
class MailerService
{
public function __construct(
protected Module $module
) {}
/**
* Send welcome/confirmation email.
*/
public function sendWelcomeMessage(User $user, ?Token $token = null): bool
{
if ($token !== null) {
$url = Url::to(['/user/registration/confirm', 'id' => $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);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\models\RecoveryForm;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
/**
* Password recovery service.
*/
class RecoveryService
{
public function __construct(
protected Module $module
) {}
/**
* Send recovery email.
*
* @return bool Always returns true to prevent email enumeration attacks
*/
public function sendRecoveryMessage(RecoveryForm $form): bool
{
if (!$form->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);
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\events\RegistrationEvent;
use cgsmith\user\helpers\Password;
use cgsmith\user\models\RegistrationForm;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\db\Transaction;
/**
* Registration service.
*/
class RegistrationService
{
public const EVENT_BEFORE_REGISTER = 'beforeRegister';
public const EVENT_AFTER_REGISTER = 'afterRegister';
public const EVENT_BEFORE_CONFIRM = 'beforeConfirm';
public const EVENT_AFTER_CONFIRM = 'afterConfirm';
public function __construct(
protected Module $module
) {}
/**
* Register a new user.
*/
public function register(RegistrationForm $form): ?User
{
if (!$form->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);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
/**
* Token management service.
*/
class TokenService
{
public function __construct(
protected Module $module
) {}
/**
* Create a confirmation token.
*/
public function createConfirmationToken(User $user): ?Token
{
// Delete existing confirmation tokens
Token::deleteAllForUser($user->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();
}
}

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\controllers\AdminController;
use cgsmith\user\helpers\Password;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\base\InvalidCallException;
/**
* User management service.
*/
class UserService
{
public function __construct(
protected Module $module
) {}
/**
* Create a new user (admin creation).
*/
public function create(string $email, string $password, bool $confirmed = true): ?User
{
$user = new User();
$user->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);
}
}

20
src/views/_alert.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
use yii\helpers\Html;
/**
* @var cgsmith\user\Module $module
*/
?>
<?php if ($module->enableFlashMessages): ?>
<div class="user-alerts">
<?php foreach (Yii::$app->session->getAllFlashes() as $type => $message): ?>
<?php if (in_array($type, ['success', 'danger', 'warning', 'info'])): ?>
<div class="user-alert user-alert-<?= $type ?>">
<?= Html::encode($message) ?>
</div>
<?php endif ?>
<?php endforeach ?>
</div>
<?php endif ?>

View File

@@ -0,0 +1,31 @@
<?php
use yii\helpers\Html;
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var cgsmith\user\Module $module
*/
?>
<?php $this->beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?>
<?php
$formClass = $module->activeFormClass;
$form = $formClass::begin([
'id' => 'account-form',
'enableAjaxValidation' => true,
'enableClientValidation' => false,
] + ($module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []));
?>
<?= $this->render('_user', ['form' => $form, 'user' => $user]) ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Update'), ['class' => 'user-btn user-btn-primary']) ?>
</div>
<?php $form::end(); ?>
<?php $this->endContent() ?>

View File

@@ -0,0 +1,21 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
*/
?>
<?php $this->beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?>
<div class="user-alert user-alert-info">
<?= Yii::t('user', 'You can assign multiple roles or permissions to user by using the form below') ?>
</div>
<?php if (Yii::$app->authManager !== null): ?>
<p><?= Yii::t('user', 'RBAC assignment widget would go here. Configure your RBAC module to enable this feature.') ?></p>
<?php else: ?>
<p class="text-muted"><?= Yii::t('user', 'RBAC is not configured. Configure authManager in your application to use this feature.') ?></p>
<?php endif ?>
<?php $this->endContent() ?>

57
src/views/admin/_form.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $model
* @var cgsmith\user\Module $module
*/
use cgsmith\user\models\User;
use yii\helpers\Html;
$formClass = $module->activeFormClass;
$formConfig = array_merge([
'id' => 'user-form',
'enableAjaxValidation' => true,
'enableClientValidation' => false,
], $module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []);
?>
<div class="user-admin-form">
<div class="user-card">
<div class="user-card-body">
<?php $form = $formClass::begin($formConfig); ?>
<div class="user-form-row">
<div class="user-form-col">
<?= $form->field($model, 'email') ?>
</div>
<div class="user-form-col">
<?= $form->field($model, 'username') ?>
</div>
</div>
<?= $form->field($model, 'password')
->passwordInput()
->hint($model->isNewRecord ? '' : Yii::t('user', 'Leave empty to keep current password.')) ?>
<?php if (!$model->isNewRecord): ?>
<?= $form->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'),
]) ?>
<?php endif; ?>
<div class="user-form-actions">
<?= Html::submitButton(
$model->isNewRecord ? Yii::t('user', 'Create') : Yii::t('user', 'Update'),
['class' => 'user-btn user-btn-primary']
) ?>
<?= Html::a(Yii::t('user', 'Cancel'), ['index'], ['class' => 'user-btn user-btn-secondary']) ?>
</div>
<?php $formClass::end(); ?>
</div>
</div>
</div>

52
src/views/admin/_info.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
*/
?>
<?php $this->beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?>
<table class="table">
<tr>
<td><strong><?= Yii::t('user', 'Registration time') ?>:</strong></td>
<td><?= Yii::t('user', '{0, date, MMMM dd, YYYY HH:mm}', [strtotime($user->created_at)]) ?></td>
</tr>
<?php if ($user->registration_ip !== null): ?>
<tr>
<td><strong><?= Yii::t('user', 'Registration IP') ?>:</strong></td>
<td><?= $user->registration_ip ?></td>
</tr>
<?php endif ?>
<tr>
<td><strong><?= Yii::t('user', 'Confirmation status') ?>:</strong></td>
<?php if ($user->isConfirmed): ?>
<td class="text-success"><?= Yii::t('user', 'Confirmed at {0, date, MMMM dd, YYYY HH:mm}', [strtotime($user->email_confirmed_at)]) ?></td>
<?php else: ?>
<td class="text-danger"><?= Yii::t('user', 'Unconfirmed') ?></td>
<?php endif ?>
</tr>
<tr>
<td><strong><?= Yii::t('user', 'Block status') ?>:</strong></td>
<?php if ($user->isBlocked): ?>
<td class="text-danger"><?= Yii::t('user', 'Blocked at {0, date, MMMM dd, YYYY HH:mm}', [strtotime($user->blocked_at)]) ?></td>
<?php else: ?>
<td class="text-success"><?= Yii::t('user', 'Not blocked') ?></td>
<?php endif ?>
</tr>
<?php if ($user->last_login_at !== null): ?>
<tr>
<td><strong><?= Yii::t('user', 'Last login') ?>:</strong></td>
<td><?= Yii::t('user', '{0, date, MMMM dd, YYYY HH:mm}', [strtotime($user->last_login_at)]) ?></td>
</tr>
<?php endif ?>
<?php if ($user->last_login_ip !== null): ?>
<tr>
<td><strong><?= Yii::t('user', 'Last login IP') ?>:</strong></td>
<td><?= $user->last_login_ip ?></td>
</tr>
<?php endif ?>
</table>
<?php $this->endContent() ?>

13
src/views/admin/_menu.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
use yii\helpers\Html;
/**
* @var yii\web\View $this
*/
?>
<nav class="user-admin-nav">
<?= Html::a(Yii::t('user', 'Users'), ['/user/admin/index'], ['class' => 'user-admin-nav-item']) ?>
<?= Html::a(Yii::t('user', 'Create User'), ['/user/admin/create'], ['class' => 'user-admin-nav-item']) ?>
</nav>

View File

@@ -0,0 +1,36 @@
<?php
use yii\helpers\Html;
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var cgsmith\user\models\Profile $profile
* @var cgsmith\user\Module $module
*/
?>
<?php $this->beginContent('@cgsmith/user/views/admin/update.php', ['user' => $user]) ?>
<?php
$formClass = $module->activeFormClass;
$form = $formClass::begin([
'id' => 'profile-form',
'enableAjaxValidation' => true,
'enableClientValidation' => false,
] + ($module->formFieldConfig ? ['fieldConfig' => $module->formFieldConfig] : []));
?>
<?= $form->field($profile, 'name') ?>
<?= $form->field($profile, 'public_email') ?>
<?= $form->field($profile, 'website') ?>
<?= $form->field($profile, 'location') ?>
<?= $form->field($profile, 'bio')->textarea() ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Update'), ['class' => 'user-btn user-btn-primary']) ?>
</div>
<?php $form::end(); ?>
<?php $this->endContent() ?>

11
src/views/admin/_user.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
/**
* @var yii\widgets\ActiveForm $form
* @var cgsmith\user\models\User $user
*/
?>
<?= $form->field($user, 'email')->textInput(['maxlength' => 255]) ?>
<?= $form->field($user, 'username')->textInput(['maxlength' => 255]) ?>
<?= $form->field($user, 'password')->passwordInput() ?>

View File

@@ -0,0 +1,27 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->title = Yii::t('user', 'Create User');
$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Manage Users'), 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title;
?>
<?= $this->render('/_alert', ['module' => Yii::$app->getModule('user')]) ?>
<?= $this->render('_menu') ?>
<div class="user-admin-create">
<h1><?= Html::encode($this->title) ?></h1>
<?= $this->render('_form', [
'model' => $model,
'module' => $module,
]) ?>
</div>

130
src/views/admin/index.php Normal file
View File

@@ -0,0 +1,130 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\UserSearch $searchModel
* @var yii\data\ActiveDataProvider $dataProvider
* @var cgsmith\user\Module $module
*/
use cgsmith\user\models\User;
use yii\helpers\Html;
use yii\grid\GridView;
$this->title = Yii::t('user', 'Manage Users');
$this->params['breadcrumbs'][] = $this->title;
?>
<?= $this->render('/_alert', ['module' => Yii::$app->getModule('user')]) ?>
<?= $this->render('_menu') ?>
<div class="user-admin-index">
<div class="user-admin-header">
<h1><?= Html::encode($this->title) ?></h1>
<?= Html::a(Yii::t('user', 'Create User'), ['create'], ['class' => 'user-btn user-btn-primary']) ?>
</div>
<div class="user-card">
<div class="user-card-body">
<?= GridView::widget([
'dataProvider' => $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 '<span class="user-badge user-badge-success">' . Yii::t('user', 'Confirmed') . '</span>';
}
return '<span class="user-badge user-badge-warning">' . Yii::t('user', 'Unconfirmed') . '</span>';
},
],
'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?')],
]);
},
],
],
],
]); ?>
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<?php
use yii\helpers\Html;
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var string $content
*/
$this->title = Yii::t('user', 'Update user account');
$this->params['breadcrumbs'][] = ['label' => Yii::t('user', 'Users'), 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title;
?>
<?= $this->render('/_alert', ['module' => Yii::$app->getModule('user')]) ?>
<?= $this->render('_menu') ?>
<div class="user-admin-update">
<div class="user-admin-layout">
<div class="user-admin-sidebar">
<nav class="user-admin-user-nav">
<?= Html::a(Yii::t('user', 'Account details'), ['/user/admin/update', 'id' => $user->id], ['class' => 'user-nav-item']) ?>
<?= Html::a(Yii::t('user', 'Profile details'), ['/user/admin/update-profile', 'id' => $user->id], ['class' => 'user-nav-item']) ?>
<?= Html::a(Yii::t('user', 'Information'), ['/user/admin/info', 'id' => $user->id], ['class' => 'user-nav-item']) ?>
<hr>
<?php if (!$user->isConfirmed): ?>
<?= Html::a(Yii::t('user', 'Confirm'), ['/user/admin/confirm', 'id' => $user->id], [
'class' => 'user-nav-item user-nav-item-success',
'data-method' => 'post',
'data-confirm' => Yii::t('user', 'Are you sure you want to confirm this user?'),
]) ?>
<?php endif; ?>
<?php if (!$user->isBlocked): ?>
<?= Html::a(Yii::t('user', 'Block'), ['/user/admin/block', 'id' => $user->id], [
'class' => 'user-nav-item user-nav-item-danger',
'data-method' => 'post',
'data-confirm' => Yii::t('user', 'Are you sure you want to block this user?'),
]) ?>
<?php else: ?>
<?= Html::a(Yii::t('user', 'Unblock'), ['/user/admin/unblock', 'id' => $user->id], [
'class' => 'user-nav-item user-nav-item-success',
'data-method' => 'post',
'data-confirm' => Yii::t('user', 'Are you sure you want to unblock this user?'),
]) ?>
<?php endif; ?>
<?= Html::a(Yii::t('user', 'Delete'), ['/user/admin/delete', 'id' => $user->id], [
'class' => 'user-nav-item user-nav-item-danger',
'data-method' => 'post',
'data-confirm' => Yii::t('user', 'Are you sure you want to delete this user?'),
]) ?>
</nav>
</div>
<div class="user-admin-content">
<div class="user-card">
<div class="user-card-body">
<?= $content ?>
</div>
</div>
</div>
</div>
</div>

60
src/views/gdpr/delete.php Normal file
View File

@@ -0,0 +1,60 @@
<?php
/**
* @var yii\web\View $this
* @var yii\base\Model $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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] : []);
?>
<div class="user-gdpr-delete">
<div class="user-form-wrapper">
<div class="user-card user-card-danger">
<div class="user-card-header user-card-header-danger">
<h2 class="user-card-title"><?= Html::encode($this->title) ?></h2>
</div>
<div class="user-card-body">
<div class="user-alert user-alert-warning">
<h3><?= Yii::t('user', 'This action is irreversible!') ?></h3>
<p><?= Yii::t('user', 'Deleting your account will:') ?></p>
<ul>
<li><?= Yii::t('user', 'Remove all your personal information') ?></li>
<li><?= Yii::t('user', 'Delete your profile and settings') ?></li>
<li><?= Yii::t('user', 'Log you out immediately') ?></li>
</ul>
</div>
<?php $form = $formClass::begin($formConfig); ?>
<?= $form->field($model, 'password')->passwordInput([
'placeholder' => Yii::t('user', 'Enter your current password'),
]) ?>
<?= $form->field($model, 'confirm')->checkbox([
'label' => Yii::t('user', 'I understand this action cannot be undone'),
]) ?>
<div class="user-form-actions">
<?= Html::submitButton(
Yii::t('user', 'Permanently Delete My Account'),
['class' => 'user-btn user-btn-danger']
) ?>
<?= Html::a(Yii::t('user', 'Cancel'), ['index'], ['class' => 'user-btn user-btn-secondary']) ?>
</div>
<?php $formClass::end(); ?>
</div>
</div>
</div>
</div>

54
src/views/gdpr/index.php Normal file
View File

@@ -0,0 +1,54 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->title = Yii::t('user', 'Privacy & Data');
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="user-gdpr-index">
<div class="user-settings-layout">
<div class="user-settings-sidebar">
<?= $this->render('@cgsmith/user/views/settings/_menu') ?>
</div>
<div class="user-settings-content">
<div class="user-card">
<div class="user-card-header">
<h2 class="user-card-title"><?= Yii::t('user', 'Export Your Data') ?></h2>
</div>
<div class="user-card-body">
<p class="user-text-muted">
<?= Yii::t('user', 'Download a copy of your personal data in JSON format.') ?>
</p>
<?= Html::a(
Yii::t('user', 'Export Data'),
['export'],
['class' => 'user-btn user-btn-secondary']
) ?>
</div>
</div>
<div class="user-card user-card-danger">
<div class="user-card-header user-card-header-danger">
<h2 class="user-card-title"><?= Yii::t('user', 'Delete Account') ?></h2>
</div>
<div class="user-card-body">
<div class="user-alert user-alert-warning">
<strong><?= Yii::t('user', 'Warning:') ?></strong>
<?= Yii::t('user', 'This action is permanent and cannot be undone. All your data will be deleted.') ?>
</div>
<?= Html::a(
Yii::t('user', 'Delete My Account'),
['delete'],
['class' => 'user-btn user-btn-danger']
) ?>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,20 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var cgsmith\user\models\Token $token
* @var string $url
* @var cgsmith\user\Module $module
*/
?>
<?= Yii::t('user', 'Confirm Your Email') ?>
<?= Yii::t('user', 'Please click the link below to confirm your email address:') ?>
<?= $url ?>
<?= Yii::t('user', 'This link will expire in {hours} hours.', ['hours' => round($module->confirmWithin / 3600)]) ?>
<?= Yii::t('user', 'If you did not request this email, please ignore it.') ?>

View File

@@ -0,0 +1,37 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var cgsmith\user\models\Token $token
* @var string $url
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
?>
<h2 style="color: #333; margin-top: 0;"><?= Yii::t('user', 'Confirm Your Email') ?></h2>
<p><?= Yii::t('user', 'Please click the button below to confirm your email address:') ?></p>
<p style="text-align: center; margin: 30px 0;">
<a href="<?= Html::encode($url) ?>"
style="display: inline-block; padding: 12px 30px; background-color: #0d6efd; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold;">
<?= Yii::t('user', 'Confirm Email') ?>
</a>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'If the button above does not work, copy and paste this URL into your browser:') ?>
<br>
<a href="<?= Html::encode($url) ?>" style="color: #0d6efd; word-break: break-all;"><?= Html::encode($url) ?></a>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'This link will expire in {hours} hours.', ['hours' => round($module->confirmWithin / 3600)]) ?>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'If you did not request this email, please ignore it.') ?>
</p>

View File

@@ -0,0 +1,19 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var string $password
* @var cgsmith\user\Module $module
*/
?>
<?= Yii::t('user', 'Your New Password') ?>
<?= Yii::t('user', 'A new password has been generated for your account on {app}.', ['app' => Yii::$app->name]) ?>
<?= Yii::t('user', 'Your new password is:') ?> <?= $password ?>
<?= Yii::t('user', 'We recommend changing your password after logging in.') ?>
<?= Yii::t('user', 'If you did not request a new password, please contact the administrator immediately.') ?>

View File

@@ -0,0 +1,27 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var string $password
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
?>
<h2 style="color: #333; margin-top: 0;"><?= Yii::t('user', 'Your New Password') ?></h2>
<p><?= Yii::t('user', 'A new password has been generated for your account on {app}.', ['app' => Html::encode(Yii::$app->name)]) ?></p>
<p style="background-color: #f8f9fa; padding: 15px; border-radius: 5px; font-family: monospace; font-size: 16px;">
<?= Yii::t('user', 'Your new password is:') ?> <strong><?= Html::encode($password) ?></strong>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'We recommend changing your password after logging in.') ?>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'If you did not request a new password, please contact the administrator immediately.') ?>
</p>

View File

@@ -0,0 +1,34 @@
<?php
/**
* @var yii\web\View $this
* @var string $content
*/
use yii\helpers\Html;
?>
<?php $this->beginPage() ?>
<!DOCTYPE html>
<html lang="<?= Yii::$app->language ?>">
<head>
<meta charset="<?= Yii::$app->charset ?>">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<?php $this->head() ?>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0;">
<?php $this->beginBody() ?>
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: #f8f9fa; border-radius: 8px; padding: 30px;">
<?= $content ?>
</div>
<div style="text-align: center; margin-top: 20px; color: #6c757d; font-size: 12px;">
<p>&copy; <?= date('Y') ?> <?= Html::encode(Yii::$app->name) ?></p>
</div>
</div>
<?php $this->endBody() ?>
</body>
</html>
<?php $this->endPage() ?>

View File

@@ -0,0 +1,20 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var cgsmith\user\models\Token $token
* @var string $url
* @var cgsmith\user\Module $module
*/
?>
<?= Yii::t('user', 'Reset Your Password') ?>
<?= Yii::t('user', 'We received a request to reset your password. Click the link below to create a new password:') ?>
<?= $url ?>
<?= Yii::t('user', 'This link will expire in {hours} hours.', ['hours' => round($module->recoverWithin / 3600)]) ?>
<?= Yii::t('user', 'If you did not request a password reset, please ignore this email. Your password will not be changed.') ?>

View File

@@ -0,0 +1,37 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var cgsmith\user\models\Token $token
* @var string $url
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
?>
<h2 style="color: #333; margin-top: 0;"><?= Yii::t('user', 'Reset Your Password') ?></h2>
<p><?= Yii::t('user', 'We received a request to reset your password. Click the button below to create a new password:') ?></p>
<p style="text-align: center; margin: 30px 0;">
<a href="<?= Html::encode($url) ?>"
style="display: inline-block; padding: 12px 30px; background-color: #0d6efd; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold;">
<?= Yii::t('user', 'Reset Password') ?>
</a>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'If the button above does not work, copy and paste this URL into your browser:') ?>
<br>
<a href="<?= Html::encode($url) ?>" style="color: #0d6efd; word-break: break-all;"><?= Html::encode($url) ?></a>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'This link will expire in {hours} hours.', ['hours' => round($module->recoverWithin / 3600)]) ?>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'If you did not request a password reset, please ignore this email. Your password will not be changed.') ?>
</p>

View File

@@ -0,0 +1,27 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var cgsmith\user\models\Token|null $token
* @var string|null $url
* @var cgsmith\user\Module $module
*/
?>
<?= Yii::t('user', 'Welcome to {app}!', ['app' => Yii::$app->name]) ?>
<?= Yii::t('user', 'Thank you for registering.') ?>
<?php if ($url !== null): ?>
<?= Yii::t('user', 'Please click the link below to confirm your email address:') ?>
<?= $url ?>
<?= Yii::t('user', 'This link will expire in {hours} hours.', ['hours' => round($module->confirmWithin / 3600)]) ?>
<?php else: ?>
<?= Yii::t('user', 'You can now sign in to your account.') ?>
<?php endif; ?>
<?= Yii::t('user', 'If you did not create an account, please ignore this email.') ?>

View File

@@ -0,0 +1,43 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\User $user
* @var cgsmith\user\models\Token|null $token
* @var string|null $url
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
?>
<h2 style="color: #333; margin-top: 0;"><?= Yii::t('user', 'Welcome to {app}!', ['app' => Html::encode(Yii::$app->name)]) ?></h2>
<p><?= Yii::t('user', 'Thank you for registering.') ?></p>
<?php if ($url !== null): ?>
<p><?= Yii::t('user', 'Please click the button below to confirm your email address:') ?></p>
<p style="text-align: center; margin: 30px 0;">
<a href="<?= Html::encode($url) ?>"
style="display: inline-block; padding: 12px 30px; background-color: #0d6efd; color: #fff; text-decoration: none; border-radius: 5px; font-weight: bold;">
<?= Yii::t('user', 'Confirm Email') ?>
</a>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'If the button above does not work, copy and paste this URL into your browser:') ?>
<br>
<a href="<?= Html::encode($url) ?>" style="color: #0d6efd; word-break: break-all;"><?= Html::encode($url) ?></a>
</p>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'This link will expire in {hours} hours.', ['hours' => round($module->confirmWithin / 3600)]) ?>
</p>
<?php else: ?>
<p><?= Yii::t('user', 'You can now sign in to your account.') ?></p>
<?php endif; ?>
<p style="color: #6c757d; font-size: 14px;">
<?= Yii::t('user', 'If you did not create an account, please ignore this email.') ?>
</p>

12
src/views/message.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\Module $module
* @var string $title
*/
$this->title = $title;
?>
<?= $this->render('/_alert', ['module' => $module]) ?>

View File

@@ -0,0 +1,51 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\RecoveryForm $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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] : []);
?>
<div class="user-recovery-request">
<div class="user-form-wrapper">
<div class="user-card">
<div class="user-card-body">
<h1 class="user-form-title"><?= Html::encode($this->title) ?></h1>
<p class="user-form-description">
<?= Yii::t('user', 'Enter your email address and we will send you a link to reset your password.') ?>
</p>
<?php $form = $formClass::begin($formConfig); ?>
<?= $form->field($model, 'email')
->textInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'Email')]) ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Send Reset Link'), ['class' => 'user-btn user-btn-primary user-btn-lg']) ?>
</div>
<?php $formClass::end(); ?>
<hr>
<div class="user-form-links">
<p>
<?= Html::a(Yii::t('user', 'Back to login'), ['/user/security/login']) ?>
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,46 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\RecoveryResetForm $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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] : []);
?>
<div class="user-recovery-reset">
<div class="user-form-wrapper">
<div class="user-card">
<div class="user-card-body">
<h1 class="user-form-title"><?= Html::encode($this->title) ?></h1>
<p class="user-form-description">
<?= Yii::t('user', 'Enter your new password below.') ?>
</p>
<?php $form = $formClass::begin($formConfig); ?>
<?= $form->field($model, 'password')
->passwordInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'New Password')]) ?>
<?= $form->field($model, 'password_confirm')
->passwordInput(['placeholder' => Yii::t('user', 'Confirm Password')]) ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Reset Password'), ['class' => 'user-btn user-btn-primary user-btn-lg']) ?>
</div>
<?php $formClass::end(); ?>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,58 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\RegistrationForm $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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] : []);
?>
<div class="user-register">
<div class="user-form-wrapper">
<div class="user-card">
<div class="user-card-body">
<h1 class="user-form-title"><?= Html::encode($this->title) ?></h1>
<?php $form = $formClass::begin($formConfig); ?>
<?= $form->field($model, 'email')
->textInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'Email')]) ?>
<?= $form->field($model, 'username')
->textInput(['placeholder' => Yii::t('user', 'Username (optional)')]) ?>
<?php if (!$module->enableGeneratedPassword): ?>
<?= $form->field($model, 'password')
->passwordInput(['placeholder' => Yii::t('user', 'Password')]) ?>
<?php endif; ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Sign Up'), ['class' => 'user-btn user-btn-primary user-btn-lg']) ?>
</div>
<?php $formClass::end(); ?>
<hr>
<div class="user-form-links">
<p>
<?= Yii::t('user', 'Already have an account?') ?>
<?= Html::a(Yii::t('user', 'Sign in'), ['/user/security/login']) ?>
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,51 @@
<?php
/**
* @var yii\web\View $this
* @var yii\base\Model $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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] : []);
?>
<div class="user-resend">
<div class="user-form-wrapper">
<div class="user-card">
<div class="user-card-body">
<h1 class="user-form-title"><?= Html::encode($this->title) ?></h1>
<p class="user-form-description">
<?= Yii::t('user', 'Enter your email address and we will send you a new confirmation link.') ?>
</p>
<?php $form = $formClass::begin($formConfig); ?>
<?= $form->field($model, 'email')
->textInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'Email')]) ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Resend'), ['class' => 'user-btn user-btn-primary user-btn-lg']) ?>
</div>
<?php $formClass::end(); ?>
<hr>
<div class="user-form-links">
<p>
<?= Html::a(Yii::t('user', 'Back to login'), ['/user/security/login']) ?>
</p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,63 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\LoginForm $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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] : []);
?>
<div class="user-login">
<div class="user-form-wrapper">
<div class="user-card">
<div class="user-card-body">
<h1 class="user-form-title"><?= Html::encode($this->title) ?></h1>
<?php $form = $formClass::begin($formConfig); ?>
<?= $form->field($model, 'login')
->textInput(['autofocus' => true, 'placeholder' => Yii::t('user', 'Email or Username')]) ?>
<?= $form->field($model, 'password')
->passwordInput(['placeholder' => Yii::t('user', 'Password')]) ?>
<?= $form->field($model, 'rememberMe')->checkbox() ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Sign In'), ['class' => 'user-btn user-btn-primary user-btn-lg']) ?>
</div>
<?php $formClass::end(); ?>
<hr>
<div class="user-form-links">
<?php if ($module->enablePasswordRecovery): ?>
<p>
<?= Html::a(Yii::t('user', 'Forgot password?'), ['/user/recovery/request']) ?>
</p>
<?php endif; ?>
<?php if ($module->enableRegistration): ?>
<p>
<?= Yii::t('user', "Don't have an account?") ?>
<?= Html::a(Yii::t('user', 'Sign up'), ['/user/registration/register']) ?>
</p>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
<?php
/**
* Settings menu.
* @var yii\web\View $this
*/
use yii\helpers\Html;
$action = Yii::$app->controller->action->id;
?>
<nav class="user-settings-menu">
<?= Html::a(
Yii::t('user', 'Account'),
['/user/settings/account'],
['class' => 'user-menu-item' . ($action === 'account' ? ' user-menu-item-active' : '')]
) ?>
<?= Html::a(
Yii::t('user', 'Profile'),
['/user/settings/profile'],
['class' => 'user-menu-item' . ($action === 'profile' ? ' user-menu-item-active' : '')]
) ?>
<?php if (Yii::$app->getModule('user')->enableGdpr): ?>
<?= Html::a(
Yii::t('user', 'Privacy & Data'),
['/user/gdpr'],
['class' => 'user-menu-item' . (Yii::$app->controller->id === 'gdpr' ? ' user-menu-item-active' : '')]
) ?>
<?php endif; ?>
</nav>

View File

@@ -0,0 +1,62 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\SettingsForm $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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] : []);
?>
<div class="user-settings-account">
<div class="user-settings-layout">
<div class="user-settings-sidebar">
<?= $this->render('_menu') ?>
</div>
<div class="user-settings-content">
<div class="user-card">
<div class="user-card-header">
<h2 class="user-card-title"><?= Html::encode($this->title) ?></h2>
</div>
<div class="user-card-body">
<?php $form = $formClass::begin($formConfig); ?>
<?= $form->field($model, 'email') ?>
<?= $form->field($model, 'username') ?>
<hr>
<h3 class="user-section-title"><?= Yii::t('user', 'Change Password') ?></h3>
<?= $form->field($model, 'new_password')->passwordInput() ?>
<?= $form->field($model, 'new_password_confirm')->passwordInput() ?>
<hr>
<?= $form->field($model, 'current_password')
->passwordInput()
->hint(Yii::t('user', 'Required to change email or password.')) ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Save Changes'), ['class' => 'user-btn user-btn-primary']) ?>
</div>
<?php $formClass::end(); ?>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,93 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\Profile $model
* @var cgsmith\user\Module $module
*/
use yii\helpers\Html;
$this->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] : []);
?>
<div class="user-settings-profile">
<div class="user-settings-layout">
<div class="user-settings-sidebar">
<?= $this->render('_menu') ?>
</div>
<div class="user-settings-content">
<div class="user-card">
<div class="user-card-header">
<h2 class="user-card-title"><?= Html::encode($this->title) ?></h2>
</div>
<div class="user-card-body">
<?php $form = $formClass::begin($formConfig); ?>
<div class="user-profile-avatar-section">
<div class="user-avatar-container">
<?php if ($model->getAvatarUrl()): ?>
<img src="<?= Html::encode($model->getAvatarUrl(150)) ?>"
class="user-avatar"
alt="Avatar"
width="150"
height="150">
<?php else: ?>
<div class="user-avatar-placeholder">
<span><?= strtoupper(substr($model->user->email, 0, 1)) ?></span>
</div>
<?php endif; ?>
<?php if ($module->enableAvatarUpload): ?>
<?= $form->field($model, 'avatar_path')->fileInput(['accept' => 'image/*'])->label(Yii::t('user', 'Upload Avatar')) ?>
<?php if (!empty($model->avatar_path)): ?>
<?= Html::a(Yii::t('user', 'Delete Avatar'), ['delete-avatar'], [
'class' => 'user-btn user-btn-danger user-btn-sm',
'data' => ['method' => 'post', 'confirm' => Yii::t('user', 'Are you sure you want to delete your avatar?')],
]) ?>
<?php endif; ?>
<?php endif; ?>
</div>
<div class="user-profile-fields">
<?= $form->field($model, 'name') ?>
<?= $form->field($model, 'public_email') ?>
<?= $form->field($model, 'location') ?>
<?= $form->field($model, 'website') ?>
</div>
</div>
<?= $form->field($model, 'bio')->textarea(['rows' => 4]) ?>
<?= $form->field($model, 'timezone')->dropDownList(
cgsmith\user\models\Profile::getTimezoneList(),
['prompt' => Yii::t('user', 'Select timezone...')]
) ?>
<?php if ($module->enableGravatar): ?>
<?= $form->field($model, 'use_gravatar')->checkbox() ?>
<?= $form->field($model, 'gravatar_email')
->textInput()
->hint(Yii::t('user', 'Leave empty to use your account email for Gravatar.')) ?>
<?php endif; ?>
<div class="user-form-actions">
<?= Html::submitButton(Yii::t('user', 'Save Changes'), ['class' => 'user-btn user-btn-primary']) ?>
</div>
<?php $formClass::end(); ?>
</div>
</div>
</div>
</div>
</div>

50
src/widgets/Login.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\widgets;
use cgsmith\user\models\LoginForm;
use cgsmith\user\Module;
use Yii;
use yii\base\Widget;
/**
* Login widget for embedding login forms.
*/
class Login extends Widget
{
/**
* View file to render.
*/
public string $view = 'login';
/**
* Whether to validate via AJAX.
*/
public bool $enableAjaxValidation = false;
/**
* Form action URL. Defaults to login action.
*/
public ?string $action = null;
/**
* {@inheritdoc}
*/
public function run(): string
{
/** @var Module $module */
$module = Yii::$app->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'],
]);
}
}