mirror of
https://github.com/cgsmith/yii2-user.git
synced 2026-02-04 00:02:37 -06:00
init commit
This commit is contained in:
156
src/Bootstrap.php
Normal file
156
src/Bootstrap.php
Normal 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
428
src/Module.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
440
src/commands/MigrateFromDektriumController.php
Normal file
440
src/commands/MigrateFromDektriumController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
208
src/commands/UserController.php
Normal file
208
src/commands/UserController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/contracts/UserInterface.php
Normal file
33
src/contracts/UserInterface.php
Normal 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;
|
||||
}
|
||||
356
src/controllers/AdminController.php
Normal file
356
src/controllers/AdminController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
181
src/controllers/GdprController.php
Normal file
181
src/controllers/GdprController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
112
src/controllers/RecoveryController.php
Normal file
112
src/controllers/RecoveryController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
172
src/controllers/RegistrationController.php
Normal file
172
src/controllers/RegistrationController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
106
src/controllers/SecurityController.php
Normal file
106
src/controllers/SecurityController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
233
src/controllers/SettingsController.php
Normal file
233
src/controllers/SettingsController.php
Normal 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
19
src/events/FormEvent.php
Normal 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;
|
||||
}
|
||||
31
src/events/RegistrationEvent.php
Normal file
31
src/events/RegistrationEvent.php
Normal 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
19
src/events/UserEvent.php
Normal 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;
|
||||
}
|
||||
51
src/filters/AccessRule.php
Normal file
51
src/filters/AccessRule.php
Normal 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
96
src/helpers/Password.php
Normal 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
193
src/messages/en/user.php
Normal 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.',
|
||||
];
|
||||
51
src/migrations/m250115_000001_create_user_table.php
Normal file
51
src/migrations/m250115_000001_create_user_table.php
Normal 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}}');
|
||||
}
|
||||
}
|
||||
56
src/migrations/m250115_000002_create_profile_table.php
Normal file
56
src/migrations/m250115_000002_create_profile_table.php
Normal 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}}');
|
||||
}
|
||||
}
|
||||
54
src/migrations/m250115_000003_create_token_table.php
Normal file
54
src/migrations/m250115_000003_create_token_table.php
Normal 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}}');
|
||||
}
|
||||
}
|
||||
@@ -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
124
src/models/LoginForm.php
Normal 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
153
src/models/Profile.php
Normal 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;
|
||||
}
|
||||
}
|
||||
70
src/models/RecoveryForm.php
Normal file
70
src/models/RecoveryForm.php
Normal 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;
|
||||
}
|
||||
}
|
||||
54
src/models/RecoveryResetForm.php
Normal file
54
src/models/RecoveryResetForm.php
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/models/RegistrationForm.php
Normal file
73
src/models/RegistrationForm.php
Normal 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
128
src/models/SettingsForm.php
Normal 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
219
src/models/Token.php
Normal 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
368
src/models/User.php
Normal 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
75
src/models/UserSearch.php
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/models/query/ProfileQuery.php
Normal file
41
src/models/query/ProfileQuery.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
82
src/models/query/TokenQuery.php
Normal file
82
src/models/query/TokenQuery.php
Normal 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);
|
||||
}
|
||||
}
|
||||
85
src/models/query/UserQuery.php
Normal file
85
src/models/query/UserQuery.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
155
src/services/MailerService.php
Normal file
155
src/services/MailerService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
117
src/services/RecoveryService.php
Normal file
117
src/services/RecoveryService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
181
src/services/RegistrationService.php
Normal file
181
src/services/RegistrationService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
106
src/services/TokenService.php
Normal file
106
src/services/TokenService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
230
src/services/UserService.php
Normal file
230
src/services/UserService.php
Normal 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
20
src/views/_alert.php
Normal 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 ?>
|
||||
31
src/views/admin/_account.php
Normal file
31
src/views/admin/_account.php
Normal 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() ?>
|
||||
21
src/views/admin/_assignments.php
Normal file
21
src/views/admin/_assignments.php
Normal 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
57
src/views/admin/_form.php
Normal 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
52
src/views/admin/_info.php
Normal 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
13
src/views/admin/_menu.php
Normal 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>
|
||||
36
src/views/admin/_profile.php
Normal file
36
src/views/admin/_profile.php
Normal 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
11
src/views/admin/_user.php
Normal 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() ?>
|
||||
27
src/views/admin/create.php
Normal file
27
src/views/admin/create.php
Normal 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
130
src/views/admin/index.php
Normal 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>
|
||||
63
src/views/admin/update.php
Normal file
63
src/views/admin/update.php
Normal 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
60
src/views/gdpr/delete.php
Normal 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
54
src/views/gdpr/index.php
Normal 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>
|
||||
20
src/views/mail/confirmation-text.php
Normal file
20
src/views/mail/confirmation-text.php
Normal 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.') ?>
|
||||
37
src/views/mail/confirmation.php
Normal file
37
src/views/mail/confirmation.php
Normal 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>
|
||||
19
src/views/mail/generated_password-text.php
Normal file
19
src/views/mail/generated_password-text.php
Normal 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.') ?>
|
||||
27
src/views/mail/generated_password.php
Normal file
27
src/views/mail/generated_password.php
Normal 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>
|
||||
34
src/views/mail/layouts/html.php
Normal file
34
src/views/mail/layouts/html.php
Normal 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>© <?= date('Y') ?> <?= Html::encode(Yii::$app->name) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php $this->endBody() ?>
|
||||
</body>
|
||||
</html>
|
||||
<?php $this->endPage() ?>
|
||||
20
src/views/mail/recovery-text.php
Normal file
20
src/views/mail/recovery-text.php
Normal 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.') ?>
|
||||
37
src/views/mail/recovery.php
Normal file
37
src/views/mail/recovery.php
Normal 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>
|
||||
27
src/views/mail/welcome-text.php
Normal file
27
src/views/mail/welcome-text.php
Normal 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.') ?>
|
||||
43
src/views/mail/welcome.php
Normal file
43
src/views/mail/welcome.php
Normal 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
12
src/views/message.php
Normal 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]) ?>
|
||||
51
src/views/recovery/request.php
Normal file
51
src/views/recovery/request.php
Normal 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>
|
||||
46
src/views/recovery/reset.php
Normal file
46
src/views/recovery/reset.php
Normal 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>
|
||||
58
src/views/registration/register.php
Normal file
58
src/views/registration/register.php
Normal 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>
|
||||
51
src/views/registration/resend.php
Normal file
51
src/views/registration/resend.php
Normal 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>
|
||||
63
src/views/security/login.php
Normal file
63
src/views/security/login.php
Normal 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>
|
||||
31
src/views/settings/_menu.php
Normal file
31
src/views/settings/_menu.php
Normal 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>
|
||||
62
src/views/settings/account.php
Normal file
62
src/views/settings/account.php
Normal 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>
|
||||
93
src/views/settings/profile.php
Normal file
93
src/views/settings/profile.php
Normal 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
50
src/widgets/Login.php
Normal 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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user