init commit

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

156
src/Bootstrap.php Normal file
View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace cgsmith\user;
use Yii;
use yii\base\Application;
use yii\base\BootstrapInterface;
use yii\console\Application as ConsoleApplication;
use yii\web\Application as WebApplication;
/**
* Bootstrap class for the user module.
*
* Registers URL rules and container bindings.
*/
class Bootstrap implements BootstrapInterface
{
/**
* {@inheritdoc}
*/
public function bootstrap($app): void
{
/** @var Module|null $module */
$module = $app->getModule('user');
if ($module === null) {
return;
}
if ($app instanceof ConsoleApplication) {
$this->bootstrapConsole($app, $module);
} elseif ($app instanceof WebApplication) {
$this->bootstrapWeb($app, $module);
}
$this->registerContainerBindings($module);
}
/**
* Bootstrap for web application.
*/
protected function bootstrapWeb(WebApplication $app, Module $module): void
{
$prefix = $module->urlPrefix;
$moduleId = $module->id;
$rules = [];
foreach ($this->getUrlRules($module) as $pattern => $route) {
$rules["{$prefix}/{$pattern}"] = "{$moduleId}/{$route}";
}
$app->urlManager->addRules($rules, false);
}
/**
* Bootstrap for console application.
*/
protected function bootstrapConsole(ConsoleApplication $app, Module $module): void
{
if (!isset($app->controllerMap['user'])) {
$app->controllerMap['user'] = [
'class' => 'cgsmith\user\commands\UserController',
'module' => $module,
];
}
if (!isset($app->controllerMap['migrate-from-dektrium'])) {
$app->controllerMap['migrate-from-dektrium'] = [
'class' => 'cgsmith\user\commands\MigrateFromDektriumController',
'module' => $module,
];
}
}
/**
* Register container bindings for dependency injection.
*/
protected function registerContainerBindings(Module $module): void
{
$container = Yii::$container;
// Bind services
$container->setSingleton('cgsmith\user\services\UserService', function () use ($module) {
return new \cgsmith\user\services\UserService($module);
});
$container->setSingleton('cgsmith\user\services\RegistrationService', function () use ($module) {
return new \cgsmith\user\services\RegistrationService($module);
});
$container->setSingleton('cgsmith\user\services\RecoveryService', function () use ($module) {
return new \cgsmith\user\services\RecoveryService($module);
});
$container->setSingleton('cgsmith\user\services\TokenService', function () use ($module) {
return new \cgsmith\user\services\TokenService($module);
});
$container->setSingleton('cgsmith\user\services\MailerService', function () use ($module) {
return new \cgsmith\user\services\MailerService($module);
});
// Bind module for injection
$container->setSingleton(Module::class, function () use ($module) {
return $module;
});
}
/**
* Get URL rules for the module.
*/
protected function getUrlRules(Module $module): array
{
$rules = [
// Security
'login' => 'security/login',
'logout' => 'security/logout',
// Registration
'register' => 'registration/register',
'confirm/<id:\d+>/<token:[A-Za-z0-9_-]+>' => 'registration/confirm',
'resend' => 'registration/resend',
// Password Recovery
'recovery' => 'recovery/request',
'recovery/<id:\d+>/<token:[A-Za-z0-9_-]+>' => 'recovery/reset',
// Settings
'settings' => 'settings/account',
'settings/account' => 'settings/account',
'settings/profile' => 'settings/profile',
// Admin
'admin' => 'admin/index',
'admin/index' => 'admin/index',
'admin/create' => 'admin/create',
'admin/update/<id:\d+>' => 'admin/update',
'admin/delete/<id:\d+>' => 'admin/delete',
'admin/block/<id:\d+>' => 'admin/block',
'admin/unblock/<id:\d+>' => 'admin/unblock',
'admin/confirm/<id:\d+>' => 'admin/confirm',
'admin/impersonate/<id:\d+>' => 'admin/impersonate',
];
// GDPR routes
if ($module->enableGdpr) {
$rules['gdpr'] = 'gdpr/index';
$rules['gdpr/export'] = 'gdpr/export';
$rules['gdpr/delete'] = 'gdpr/delete';
}
return $rules;
}
}

428
src/Module.php Normal file
View File

@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace cgsmith\user;
use Yii;
use yii\base\Application;
use yii\base\BootstrapInterface;
use yii\base\Module as BaseModule;
use yii\console\Application as ConsoleApplication;
use yii\web\Application as WebApplication;
/**
* User module for Yii2.
*
* @property-read string $version
*/
class Module extends BaseModule implements BootstrapInterface
{
public const VERSION = '1.0.0';
/**
* Email change strategies
*/
public const EMAIL_CHANGE_INSECURE = 0; // Change immediately
public const EMAIL_CHANGE_DEFAULT = 1; // Confirm new email only
public const EMAIL_CHANGE_SECURE = 2; // Confirm both old and new email
/**
* Whether to enable user registration.
*/
public bool $enableRegistration = true;
/**
* Whether to require email confirmation after registration.
*/
public bool $enableConfirmation = true;
/**
* Whether to allow login without email confirmation.
*/
public bool $enableUnconfirmedLogin = false;
/**
* Whether to enable password recovery.
*/
public bool $enablePasswordRecovery = true;
/**
* Whether to enable GDPR features (data export, account deletion).
* @todo GDPR is not yet fully implemented - planned for v2
*/
public bool $enableGdpr = false;
/**
* Whether to enable user impersonation by admins.
*/
public bool $enableImpersonation = true;
/**
* Whether to generate password automatically during registration.
*/
public bool $enableGeneratedPassword = false;
/**
* Whether to enable gravatar support for profile avatars.
*/
public bool $enableGravatar = true;
/**
* Whether to enable local avatar uploads.
*/
public bool $enableAvatarUpload = true;
/**
* Whether to show flash messages in module views.
*/
public bool $enableFlashMessages = true;
/**
* Whether to enable account deletion by users.
*/
public bool $enableAccountDelete = true;
/**
* Email change strategy.
*/
public int $emailChangeStrategy = self::EMAIL_CHANGE_DEFAULT;
/**
* Duration (in seconds) for "remember me" login. Default: 2 weeks.
*/
public int $rememberFor = 1209600;
/**
* Duration (in seconds) before confirmation token expires. Default: 24 hours.
*/
public int $confirmWithin = 86400;
/**
* Duration (in seconds) before recovery token expires. Default: 6 hours.
*/
public int $recoverWithin = 21600;
/**
* Minimum password length.
*/
public int $minPasswordLength = 8;
/**
* Maximum password length.
*/
public int $maxPasswordLength = 72;
/**
* Cost parameter for password hashing (bcrypt).
*/
public int $cost = 12;
/**
* Admin email addresses (for fallback admin check when RBAC is not configured).
*/
public array $admins = [];
/**
* RBAC permission name that grants admin access.
*/
public ?string $adminPermission = null;
/**
* RBAC permission name required to impersonate users.
*/
public ?string $impersonatePermission = null;
/**
* Mailer configuration.
*/
public array $mailer = [];
/**
* Model class map for overriding default models.
*/
public array $modelMap = [];
/**
* Identity class for user component (convenience property).
* If set, overrides modelMap['User'] for the identity class.
*/
public ?string $identityClass = null;
/**
* URL prefix for module routes.
*/
public string $urlPrefix = 'user';
/**
* Default controller namespace.
*/
public $controllerNamespace = 'cgsmith\user\controllers';
/**
* Path to avatar upload directory.
*/
public string $avatarPath = '@webroot/uploads/avatars';
/**
* URL to avatar directory.
*/
public string $avatarUrl = '@web/uploads/avatars';
/**
* Maximum avatar file size in bytes. Default: 2MB.
*/
public int $maxAvatarSize = 2097152;
/**
* Allowed avatar file extensions.
*/
public array $avatarExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
/**
* ActiveForm class to use in views.
* Change this to match your Bootstrap version:
* - 'yii\bootstrap\ActiveForm' for Bootstrap 3
* - 'yii\bootstrap4\ActiveForm' for Bootstrap 4
* - 'yii\bootstrap5\ActiveForm' for Bootstrap 5
* - 'yii\widgets\ActiveForm' for no Bootstrap dependency
*/
public string $activeFormClass = 'yii\widgets\ActiveForm';
/**
* Form field configuration for ActiveForm.
* Override this to customize field templates for your CSS framework.
*/
public array $formFieldConfig = [];
/**
* Default model classes.
*/
private array $defaultModelMap = [
'User' => 'cgsmith\user\models\User',
'Profile' => 'cgsmith\user\models\Profile',
'Token' => 'cgsmith\user\models\Token',
'LoginForm' => 'cgsmith\user\models\LoginForm',
'RegistrationForm' => 'cgsmith\user\models\RegistrationForm',
'RecoveryForm' => 'cgsmith\user\models\RecoveryForm',
'RecoveryResetForm' => 'cgsmith\user\models\RecoveryResetForm',
'SettingsForm' => 'cgsmith\user\models\SettingsForm',
'UserSearch' => 'cgsmith\user\models\UserSearch',
];
/**
* {@inheritdoc}
*/
public function init(): void
{
parent::init();
$this->registerTranslations();
if (Yii::$app instanceof ConsoleApplication) {
$this->controllerNamespace = 'cgsmith\user\commands';
}
}
/**
* {@inheritdoc}
*/
public function bootstrap($app): void
{
if ($app instanceof WebApplication) {
$this->bootstrapWeb($app);
} elseif ($app instanceof ConsoleApplication) {
$this->bootstrapConsole($app);
}
$this->registerContainerBindings();
}
/**
* Bootstrap for web application.
*/
protected function bootstrapWeb(WebApplication $app): void
{
$prefix = $this->urlPrefix;
$moduleId = $this->id;
$rules = [];
foreach ($this->getUrlRules() as $pattern => $route) {
$rules["{$prefix}/{$pattern}"] = "{$moduleId}/{$route}";
}
$app->urlManager->addRules($rules, false);
$this->configureUserComponent($app);
}
/**
* Configure the user component's identityClass if not already set.
*/
protected function configureUserComponent(WebApplication $app): void
{
$identityClass = $this->identityClass ?? $this->getModelClass('User');
if ($app->has('user', true)) {
$user = $app->get('user');
if ($user->identityClass === null) {
$user->identityClass = $identityClass;
}
} else {
$app->set('user', [
'class' => 'yii\web\User',
'identityClass' => $identityClass,
'enableAutoLogin' => true,
'loginUrl' => ['/' . $this->urlPrefix . '/login'],
]);
}
}
/**
* Bootstrap for console application.
*/
protected function bootstrapConsole(ConsoleApplication $app): void
{
if (!isset($app->controllerMap['user'])) {
$app->controllerMap['user'] = [
'class' => 'cgsmith\user\commands\UserController',
'module' => $this,
];
}
if (!isset($app->controllerMap['migrate-from-dektrium'])) {
$app->controllerMap['migrate-from-dektrium'] = [
'class' => 'cgsmith\user\commands\MigrateFromDektriumController',
'module' => $this,
];
}
}
/**
* Register container bindings for dependency injection.
*/
protected function registerContainerBindings(): void
{
$container = Yii::$container;
$container->setSingleton('cgsmith\user\services\UserService', function () {
return new \cgsmith\user\services\UserService($this);
});
$container->setSingleton('cgsmith\user\services\RegistrationService', function () {
return new \cgsmith\user\services\RegistrationService($this);
});
$container->setSingleton('cgsmith\user\services\RecoveryService', function () {
return new \cgsmith\user\services\RecoveryService($this);
});
$container->setSingleton('cgsmith\user\services\TokenService', function () {
return new \cgsmith\user\services\TokenService($this);
});
$container->setSingleton('cgsmith\user\services\MailerService', function () {
return new \cgsmith\user\services\MailerService($this);
});
$container->setSingleton(Module::class, function () {
return $this;
});
}
/**
* Get URL rules for the module.
*/
protected function getUrlRules(): array
{
$rules = [
'login' => 'security/login',
'logout' => 'security/logout',
'register' => 'registration/register',
'confirm/<id:\d+>/<token:[A-Za-z0-9_-]+>' => 'registration/confirm',
'resend' => 'registration/resend',
'recovery' => 'recovery/request',
'recovery/<id:\d+>/<token:[A-Za-z0-9_-]+>' => 'recovery/reset',
'settings' => 'settings/account',
'settings/account' => 'settings/account',
'settings/profile' => 'settings/profile',
'admin' => 'admin/index',
'admin/index' => 'admin/index',
'admin/create' => 'admin/create',
'admin/update/<id:\d+>' => 'admin/update',
'admin/delete/<id:\d+>' => 'admin/delete',
'admin/block/<id:\d+>' => 'admin/block',
'admin/unblock/<id:\d+>' => 'admin/unblock',
'admin/confirm/<id:\d+>' => 'admin/confirm',
'admin/impersonate/<id:\d+>' => 'admin/impersonate',
];
if ($this->enableGdpr) {
$rules['gdpr'] = 'gdpr/index';
$rules['gdpr/export'] = 'gdpr/export';
$rules['gdpr/delete'] = 'gdpr/delete';
}
return $rules;
}
/**
* Get version string.
*/
public function getVersion(): string
{
return self::VERSION;
}
/**
* Get model class from the model map.
*/
public function getModelClass(string $name): string
{
$map = array_merge($this->defaultModelMap, $this->modelMap);
if (!isset($map[$name])) {
throw new \InvalidArgumentException("Unknown model: {$name}");
}
return $map[$name];
}
/**
* Create a model instance.
*
* @template T of object
* @param string $name Model name from the model map
* @param array $config Configuration array
* @return T
*/
public function createModel(string $name, array $config = []): object
{
$class = $this->getModelClass($name);
$config['class'] = $class;
return Yii::createObject($config);
}
/**
* Get the mailer sender configuration.
*/
public function getMailerSender(): array
{
return $this->mailer['sender'] ?? [Yii::$app->params['adminEmail'] ?? 'noreply@example.com' => Yii::$app->name];
}
/**
* Register translation messages.
*/
protected function registerTranslations(): void
{
if (!isset(Yii::$app->i18n->translations['user'])) {
Yii::$app->i18n->translations['user'] = [
'class' => 'yii\i18n\PhpMessageSource',
'sourceLanguage' => 'en-US',
'basePath' => __DIR__ . '/messages',
];
}
}
}

View File

@@ -0,0 +1,440 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\commands;
use cgsmith\user\Module;
use Yii;
use yii\console\Controller;
use yii\console\ExitCode;
use yii\db\Connection;
use yii\helpers\Console;
/**
* Migrate users from dektrium/yii2-user to cgsmith/yii2-user.
*
* Usage:
* yii migrate-from-dektrium/preview Preview migration changes
* yii migrate-from-dektrium/execute Execute migration
* yii migrate-from-dektrium/rollback Rollback migration
*/
class MigrateFromDektriumController extends Controller
{
/**
* @var Module
*/
public $module;
/**
* @var string Database component ID
*/
public string $db = 'db';
/**
* Preview migration - show what will be changed.
*/
public function actionPreview(): int
{
$this->stdout("=== Dektrium to cgsmith/yii2-user Migration Preview ===\n\n", Console::FG_CYAN);
$db = $this->getDb();
// Check if dektrium tables exist
$dektriumTables = $this->checkDektriumTables($db);
if (empty($dektriumTables)) {
$this->stdout("No dektrium tables found. Nothing to migrate.\n", Console::FG_YELLOW);
return ExitCode::OK;
}
$this->stdout("Found dektrium tables:\n", Console::FG_GREEN);
foreach ($dektriumTables as $table => $count) {
$this->stdout(" - {$table}: {$count} rows\n");
}
$this->stdout("\nMigration will:\n", Console::FG_CYAN);
$this->stdout(" 1. Create new tables (user_new, user_profile_new, user_token_new)\n");
$this->stdout(" 2. Transform and copy data with these conversions:\n");
$this->stdout(" - confirmed_at (int) -> email_confirmed_at (datetime)\n");
$this->stdout(" - blocked_at (int) -> blocked_at (datetime) + status='blocked'\n");
$this->stdout(" - created_at/updated_at (int) -> datetime\n");
$this->stdout(" - token.type (int) -> ENUM('confirmation','recovery','email_change')\n");
$this->stdout(" - profile table -> user_profile table\n");
$this->stdout(" 3. Backup original tables (user -> user_dektrium_backup, etc.)\n");
$this->stdout(" 4. Rename new tables to production names\n");
$this->stdout("\nRun 'yii migrate-from-dektrium/execute' to proceed.\n", Console::FG_YELLOW);
return ExitCode::OK;
}
/**
* Execute migration.
*/
public function actionExecute(): int
{
$this->stdout("=== Executing Dektrium Migration ===\n\n", Console::FG_CYAN);
if (!$this->confirm('This will modify your database. Have you backed up your data?')) {
$this->stdout("Migration cancelled.\n", Console::FG_YELLOW);
return ExitCode::OK;
}
$db = $this->getDb();
$transaction = $db->beginTransaction();
try {
// Step 1: Check dektrium tables exist
$dektriumTables = $this->checkDektriumTables($db);
if (empty($dektriumTables)) {
$this->stdout("No dektrium tables found. Nothing to migrate.\n", Console::FG_YELLOW);
$transaction->rollBack();
return ExitCode::OK;
}
// Step 2: Create new tables
$this->stdout("Creating new tables...\n");
$this->createNewTables($db);
// Step 3: Migrate users
$this->stdout("Migrating users...\n");
$userCount = $this->migrateUsers($db);
$this->stdout(" Migrated {$userCount} users\n", Console::FG_GREEN);
// Step 4: Migrate profiles
$this->stdout("Migrating profiles...\n");
$profileCount = $this->migrateProfiles($db);
$this->stdout(" Migrated {$profileCount} profiles\n", Console::FG_GREEN);
// Step 5: Migrate tokens
$this->stdout("Migrating tokens...\n");
$tokenCount = $this->migrateTokens($db);
$this->stdout(" Migrated {$tokenCount} tokens\n", Console::FG_GREEN);
// Step 6: Backup and swap tables
$this->stdout("Backing up original tables...\n");
$this->backupAndSwapTables($db);
$transaction->commit();
$this->stdout("\n=== Migration completed successfully! ===\n", Console::FG_GREEN);
$this->stdout("Original tables backed up as: user_dektrium_backup, profile_dektrium_backup, token_dektrium_backup\n");
$this->stdout("\nNext steps:\n");
$this->stdout(" 1. Update your config to use cgsmith\\user\\Module\n");
$this->stdout(" 2. Update model imports from dektrium\\user to cgsmith\\user\n");
$this->stdout(" 3. Test your application thoroughly\n");
$this->stdout(" 4. Once verified, you can drop the backup tables\n");
return ExitCode::OK;
} catch (\Exception $e) {
$transaction->rollBack();
$this->stderr("Migration failed: " . $e->getMessage() . "\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
}
/**
* Rollback migration.
*/
public function actionRollback(): int
{
$this->stdout("=== Rolling Back Migration ===\n\n", Console::FG_CYAN);
if (!$this->confirm('This will restore the original dektrium tables and remove cgsmith tables. Continue?')) {
$this->stdout("Rollback cancelled.\n", Console::FG_YELLOW);
return ExitCode::OK;
}
$db = $this->getDb();
$transaction = $db->beginTransaction();
try {
// Check if backup tables exist
$schema = $db->schema;
$hasUserBackup = $schema->getTableSchema('user_dektrium_backup') !== null;
$hasProfileBackup = $schema->getTableSchema('profile_dektrium_backup') !== null;
$hasTokenBackup = $schema->getTableSchema('token_dektrium_backup') !== null;
if (!$hasUserBackup) {
$this->stdout("No backup tables found. Cannot rollback.\n", Console::FG_YELLOW);
$transaction->rollBack();
return ExitCode::OK;
}
// Drop new tables
$this->stdout("Dropping new tables...\n");
$db->createCommand("DROP TABLE IF EXISTS {{%user_token}}")->execute();
$db->createCommand("DROP TABLE IF EXISTS {{%user_profile}}")->execute();
$db->createCommand("DROP TABLE IF EXISTS {{%user_social_account}}")->execute();
$db->createCommand("DROP TABLE IF EXISTS {{%user}}")->execute();
// Restore backup tables
$this->stdout("Restoring backup tables...\n");
$db->createCommand("RENAME TABLE {{%user_dektrium_backup}} TO {{%user}}")->execute();
if ($hasProfileBackup) {
$db->createCommand("RENAME TABLE {{%profile_dektrium_backup}} TO {{%profile}}")->execute();
}
if ($hasTokenBackup) {
$db->createCommand("RENAME TABLE {{%token_dektrium_backup}} TO {{%token}}")->execute();
}
$transaction->commit();
$this->stdout("\n=== Rollback completed successfully! ===\n", Console::FG_GREEN);
return ExitCode::OK;
} catch (\Exception $e) {
$transaction->rollBack();
$this->stderr("Rollback failed: " . $e->getMessage() . "\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
}
/**
* Check if dektrium tables exist.
*/
protected function checkDektriumTables(Connection $db): array
{
$tables = [];
$schema = $db->schema;
// Check user table with dektrium schema (has flags column)
$userTable = $schema->getTableSchema('user');
if ($userTable !== null && isset($userTable->columns['flags'])) {
$count = $db->createCommand("SELECT COUNT(*) FROM {{%user}}")->queryScalar();
$tables['user'] = (int) $count;
}
// Check profile table
$profileTable = $schema->getTableSchema('profile');
if ($profileTable !== null) {
$count = $db->createCommand("SELECT COUNT(*) FROM {{%profile}}")->queryScalar();
$tables['profile'] = (int) $count;
}
// Check token table (dektrium uses smallint type)
$tokenTable = $schema->getTableSchema('token');
if ($tokenTable !== null && isset($tokenTable->columns['type']) && $tokenTable->columns['type']->phpType === 'integer') {
$count = $db->createCommand("SELECT COUNT(*) FROM {{%token}}")->queryScalar();
$tables['token'] = (int) $count;
}
return $tables;
}
/**
* Create new tables for cgsmith/yii2-user.
*/
protected function createNewTables(Connection $db): void
{
$tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
// User table
$db->createCommand("
CREATE TABLE {{%user_new}} (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(255) UNIQUE,
password_hash VARCHAR(255) NOT NULL,
auth_key VARCHAR(32) NOT NULL,
status ENUM('pending', 'active', 'blocked') NOT NULL DEFAULT 'pending',
email_confirmed_at DATETIME NULL,
blocked_at DATETIME NULL,
last_login_at DATETIME NULL,
last_login_ip VARCHAR(45) NULL,
registration_ip VARCHAR(45) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
gdpr_consent_at DATETIME NULL,
gdpr_deleted_at DATETIME NULL,
INDEX idx_status (status),
INDEX idx_email_confirmed (email_confirmed_at)
) {$tableOptions}
")->execute();
// Profile table
$db->createCommand("
CREATE TABLE {{%user_profile_new}} (
user_id INT UNSIGNED PRIMARY KEY,
name VARCHAR(255) NULL,
bio TEXT NULL,
location VARCHAR(255) NULL,
website VARCHAR(255) NULL,
timezone VARCHAR(40) NULL,
avatar_path VARCHAR(255) NULL,
gravatar_email VARCHAR(255) NULL,
use_gravatar TINYINT(1) DEFAULT 1,
public_email VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) {$tableOptions}
")->execute();
// Token table
$db->createCommand("
CREATE TABLE {{%user_token_new}} (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NOT NULL,
type ENUM('confirmation', 'recovery', 'email_change') NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
data JSON NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_type (user_id, type),
INDEX idx_expires (expires_at)
) {$tableOptions}
")->execute();
}
/**
* Migrate users from dektrium to new schema.
*/
protected function migrateUsers(Connection $db): int
{
return (int) $db->createCommand("
INSERT INTO {{%user_new}} (
id, email, username, password_hash, auth_key,
status, email_confirmed_at, blocked_at,
last_login_at, registration_ip, created_at, updated_at
)
SELECT
id, email, username, password_hash, auth_key,
CASE
WHEN blocked_at IS NOT NULL THEN 'blocked'
WHEN confirmed_at IS NOT NULL THEN 'active'
ELSE 'pending'
END,
FROM_UNIXTIME(confirmed_at),
FROM_UNIXTIME(blocked_at),
FROM_UNIXTIME(last_login_at),
registration_ip,
FROM_UNIXTIME(created_at),
FROM_UNIXTIME(updated_at)
FROM {{%user}}
")->execute();
}
/**
* Migrate profiles from dektrium to new schema.
*/
protected function migrateProfiles(Connection $db): int
{
$schema = $db->schema;
if ($schema->getTableSchema('profile') === null) {
return 0;
}
return (int) $db->createCommand("
INSERT INTO {{%user_profile_new}} (
user_id, name, bio, location, website, timezone,
gravatar_email, public_email, use_gravatar
)
SELECT
user_id, name, bio, location, website, timezone,
gravatar_email, public_email, 1
FROM {{%profile}}
")->execute();
}
/**
* Migrate tokens from dektrium to new schema.
*/
protected function migrateTokens(Connection $db): int
{
$schema = $db->schema;
if ($schema->getTableSchema('token') === null) {
return 0;
}
// Dektrium token types: 0 = confirmation, 1 = recovery, 2 = confirm_new_email, 3 = confirm_old_email
// We map 2 and 3 to email_change
return (int) $db->createCommand("
INSERT INTO {{%user_token_new}} (
user_id, type, token, expires_at, created_at
)
SELECT
user_id,
CASE type
WHEN 0 THEN 'confirmation'
WHEN 1 THEN 'recovery'
ELSE 'email_change'
END,
CONCAT(code, SUBSTRING(MD5(RAND()), 1, 32)),
DATE_ADD(FROM_UNIXTIME(created_at), INTERVAL 24 HOUR),
FROM_UNIXTIME(created_at)
FROM {{%token}}
")->execute();
}
/**
* Backup original tables and swap with new ones.
*/
protected function backupAndSwapTables(Connection $db): void
{
$schema = $db->schema;
// Backup user table
$db->createCommand("RENAME TABLE {{%user}} TO {{%user_dektrium_backup}}")->execute();
// Backup profile table if exists
if ($schema->getTableSchema('profile') !== null) {
$db->createCommand("RENAME TABLE {{%profile}} TO {{%profile_dektrium_backup}}")->execute();
}
// Backup token table if exists
if ($schema->getTableSchema('token') !== null) {
$db->createCommand("RENAME TABLE {{%token}} TO {{%token_dektrium_backup}}")->execute();
}
// Rename new tables to production names
$db->createCommand("RENAME TABLE {{%user_new}} TO {{%user}}")->execute();
$db->createCommand("RENAME TABLE {{%user_profile_new}} TO {{%user_profile}}")->execute();
$db->createCommand("RENAME TABLE {{%user_token_new}} TO {{%user_token}}")->execute();
// Add foreign keys
$db->createCommand("
ALTER TABLE {{%user_profile}}
ADD CONSTRAINT fk_user_profile_user
FOREIGN KEY (user_id) REFERENCES {{%user}}(id) ON DELETE CASCADE
")->execute();
$db->createCommand("
ALTER TABLE {{%user_token}}
ADD CONSTRAINT fk_user_token_user
FOREIGN KEY (user_id) REFERENCES {{%user}}(id) ON DELETE CASCADE
")->execute();
// Create social account table (empty, for v2.0)
$tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
$db->createCommand("
CREATE TABLE {{%user_social_account}} (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id INT UNSIGNED NULL,
provider VARCHAR(50) NOT NULL,
provider_id VARCHAR(255) NOT NULL,
data JSON NULL,
email VARCHAR(255) NULL,
username VARCHAR(255) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE INDEX idx_provider_id (provider, provider_id),
INDEX idx_user (user_id),
CONSTRAINT fk_user_social_account_user
FOREIGN KEY (user_id) REFERENCES {{%user}}(id) ON DELETE CASCADE
) {$tableOptions}
")->execute();
}
/**
* Get database connection.
*/
protected function getDb(): Connection
{
return Yii::$app->get($this->db);
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\commands;
use cgsmith\user\helpers\Password;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\console\Controller;
use yii\console\ExitCode;
use yii\helpers\Console;
/**
* User management console commands.
*
* Usage:
* yii user/create <email> [password] Create a new user
* yii user/delete <email> Delete a user
* yii user/password <email> [password] Change user password
* yii user/confirm <email> Confirm user email
* yii user/block <email> Block a user
* yii user/unblock <email> Unblock a user
*/
class UserController extends Controller
{
/**
* @var Module
*/
public $module;
/**
* Create a new user.
*
* @param string $email User email
* @param string|null $password Password (auto-generated if not provided)
*/
public function actionCreate(string $email, ?string $password = null): int
{
if ($password === null) {
$password = Password::generate(12);
$this->stdout("Generated password: {$password}\n", Console::FG_YELLOW);
}
$user = new User();
$user->email = $email;
$user->password = $password;
$user->status = User::STATUS_ACTIVE;
$user->email_confirmed_at = date('Y-m-d H:i:s');
if (!$user->save()) {
$this->stderr("Failed to create user:\n", Console::FG_RED);
foreach ($user->errors as $attribute => $errors) {
foreach ($errors as $error) {
$this->stderr(" - {$attribute}: {$error}\n");
}
}
return ExitCode::UNSPECIFIED_ERROR;
}
$this->stdout("User created successfully!\n", Console::FG_GREEN);
$this->stdout(" ID: {$user->id}\n");
$this->stdout(" Email: {$user->email}\n");
return ExitCode::OK;
}
/**
* Delete a user.
*
* @param string $email User email
*/
public function actionDelete(string $email): int
{
$user = User::findByEmail($email);
if ($user === null) {
$this->stderr("User not found: {$email}\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
if (!$this->confirm("Are you sure you want to delete user {$email}?")) {
return ExitCode::OK;
}
if ($user->delete()) {
$this->stdout("User deleted successfully.\n", Console::FG_GREEN);
return ExitCode::OK;
}
$this->stderr("Failed to delete user.\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
/**
* Change user password.
*
* @param string $email User email
* @param string|null $password New password (auto-generated if not provided)
*/
public function actionPassword(string $email, ?string $password = null): int
{
$user = User::findByEmail($email);
if ($user === null) {
$this->stderr("User not found: {$email}\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
if ($password === null) {
$password = Password::generate(12);
$this->stdout("Generated password: {$password}\n", Console::FG_YELLOW);
}
if ($user->resetPassword($password)) {
$this->stdout("Password changed successfully.\n", Console::FG_GREEN);
return ExitCode::OK;
}
$this->stderr("Failed to change password.\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
/**
* Confirm user email.
*
* @param string $email User email
*/
public function actionConfirm(string $email): int
{
$user = User::findByEmail($email);
if ($user === null) {
$this->stderr("User not found: {$email}\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
if ($user->getIsConfirmed()) {
$this->stdout("User is already confirmed.\n", Console::FG_YELLOW);
return ExitCode::OK;
}
if ($user->confirm()) {
$this->stdout("User confirmed successfully.\n", Console::FG_GREEN);
return ExitCode::OK;
}
$this->stderr("Failed to confirm user.\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
/**
* Block a user.
*
* @param string $email User email
*/
public function actionBlock(string $email): int
{
$user = User::findByEmail($email);
if ($user === null) {
$this->stderr("User not found: {$email}\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
if ($user->getIsBlocked()) {
$this->stdout("User is already blocked.\n", Console::FG_YELLOW);
return ExitCode::OK;
}
if ($user->block()) {
$this->stdout("User blocked successfully.\n", Console::FG_GREEN);
return ExitCode::OK;
}
$this->stderr("Failed to block user.\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
/**
* Unblock a user.
*
* @param string $email User email
*/
public function actionUnblock(string $email): int
{
$user = User::findByEmail($email);
if ($user === null) {
$this->stderr("User not found: {$email}\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
if (!$user->getIsBlocked()) {
$this->stdout("User is not blocked.\n", Console::FG_YELLOW);
return ExitCode::OK;
}
if ($user->unblock()) {
$this->stdout("User unblocked successfully.\n", Console::FG_GREEN);
return ExitCode::OK;
}
$this->stderr("Failed to unblock user.\n", Console::FG_RED);
return ExitCode::UNSPECIFIED_ERROR;
}
}

View File

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

View File

@@ -0,0 +1,356 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\filters\AccessRule;
use cgsmith\user\models\User;
use cgsmith\user\models\UserSearch;
use cgsmith\user\Module;
use cgsmith\user\services\RegistrationService;
use cgsmith\user\services\MailerService;
use cgsmith\user\services\UserService;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* Admin controller for user management.
*/
class AdminController extends Controller
{
/**
* Session key for storing original user ID during impersonation.
*/
public const ORIGINAL_USER_SESSION_KEY = 'user.original_user_id';
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'class' => AccessControl::class,
'ruleConfig' => [
'class' => AccessRule::class,
],
'rules' => [
[
'allow' => true,
'actions' => ['stop-impersonate'],
'matchCallback' => function () {
return Yii::$app->session->has(self::ORIGINAL_USER_SESSION_KEY);
},
],
['allow' => true, 'roles' => ['admin']],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'delete' => ['post'],
'block' => ['post'],
'unblock' => ['post'],
'confirm' => ['post'],
'resend-password' => ['post'],
],
],
];
}
/**
* List all users.
*/
public function actionIndex(): string
{
/** @var Module $module */
$module = $this->module;
$searchModel = new UserSearch();
$dataProvider = $searchModel->search(Yii::$app->request->queryParams);
return $this->render('index', [
'searchModel' => $searchModel,
'dataProvider' => $dataProvider,
'module' => $module,
]);
}
/**
* Create a new user.
*/
public function actionCreate(): Response|string
{
/** @var Module $module */
$module = $this->module;
$model = new User(['scenario' => 'create']);
if ($model->load(Yii::$app->request->post())) {
// Handle AJAX validation
if (Yii::$app->request->isAjax) {
return $this->asJson(\yii\widgets\ActiveForm::validate($model));
}
if ($model->save()) {
Yii::$app->session->setFlash('success', Yii::t('user', 'User has been created.'));
return $this->redirect(['index']);
}
}
return $this->render('create', [
'model' => $model,
'module' => $module,
]);
}
/**
* Update user account details.
*/
public function actionUpdate(int $id): Response|string
{
/** @var Module $module */
$module = $this->module;
$user = $this->findUser($id);
$user->scenario = 'update';
if ($user->load(Yii::$app->request->post())) {
// Handle AJAX validation
if (Yii::$app->request->isAjax) {
return $this->asJson(\yii\widgets\ActiveForm::validate($user));
}
if ($user->save()) {
Yii::$app->session->setFlash('success', Yii::t('user', 'User has been updated.'));
return $this->redirect(['update', 'id' => $user->id]);
}
}
return $this->render('_account', [
'user' => $user,
'module' => $module,
]);
}
/**
* Update user profile.
*/
public function actionUpdateProfile(int $id): Response|string
{
/** @var Module $module */
$module = $this->module;
$user = $this->findUser($id);
$profile = $user->profile;
if ($profile === null) {
$profile = new \cgsmith\user\models\Profile(['user_id' => $user->id]);
}
if ($profile->load(Yii::$app->request->post())) {
// Handle AJAX validation
if (Yii::$app->request->isAjax) {
return $this->asJson(\yii\widgets\ActiveForm::validate($profile));
}
if ($profile->save()) {
Yii::$app->session->setFlash('success', Yii::t('user', 'Profile has been updated.'));
return $this->redirect(['update-profile', 'id' => $user->id]);
}
}
return $this->render('_profile', [
'user' => $user,
'profile' => $profile,
'module' => $module,
]);
}
/**
* Show user information.
*/
public function actionInfo(int $id): string
{
$user = $this->findUser($id);
return $this->render('_info', [
'user' => $user,
]);
}
/**
* Delete user.
*/
public function actionDelete(int $id): Response
{
$model = $this->findUser($id);
// Prevent self-deletion
if ($model->id === Yii::$app->user->id) {
Yii::$app->session->setFlash('danger', Yii::t('user', 'You cannot delete your own account.'));
return $this->redirect(['index']);
}
/** @var UserService $service */
$service = Yii::$container->get(UserService::class);
if ($service->delete($model)) {
Yii::$app->session->setFlash('success', Yii::t('user', 'User has been deleted.'));
} else {
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while deleting the user.'));
}
return $this->redirect(['index']);
}
/**
* Block user.
*/
public function actionBlock(int $id): Response
{
$model = $this->findUser($id);
// Prevent self-blocking
if ($model->id === Yii::$app->user->id) {
Yii::$app->session->setFlash('danger', Yii::t('user', 'You cannot block your own account.'));
return $this->redirect(['index']);
}
/** @var UserService $service */
$service = Yii::$container->get(UserService::class);
if ($service->block($model)) {
Yii::$app->session->setFlash('success', Yii::t('user', 'User has been blocked.'));
} else {
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while blocking the user.'));
}
return $this->redirect(['index']);
}
/**
* Unblock user.
*/
public function actionUnblock(int $id): Response
{
$model = $this->findUser($id);
/** @var UserService $service */
$service = Yii::$container->get(UserService::class);
if ($service->unblock($model)) {
Yii::$app->session->setFlash('success', Yii::t('user', 'User has been unblocked.'));
} else {
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while unblocking the user.'));
}
return $this->redirect(['index']);
}
/**
* Manually confirm user email.
*/
public function actionConfirm(int $id): Response
{
$model = $this->findUser($id);
/** @var UserService $service */
$service = Yii::$container->get(UserService::class);
if ($service->confirm($model)) {
Yii::$app->session->setFlash('success', Yii::t('user', 'User email has been confirmed.'));
} else {
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while confirming the user.'));
}
return $this->redirect(['index']);
}
/**
* Generate and send a new password to the user.
*/
public function actionResendPassword(int $id): Response
{
$model = $this->findUser($id);
/** @var UserService $service */
$service = Yii::$container->get(UserService::class);
/** @var MailerService $mailer */
$mailer = Yii::$container->get(MailerService::class);
try {
if ($service->resendPassword($model, $mailer)) {
Yii::$app->session->setFlash('success', Yii::t('user', 'New password has been generated and sent to user.'));
} else {
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while generating the password.'));
}
} catch (\yii\base\InvalidCallException $e) {
Yii::$app->session->setFlash('danger', $e->getMessage());
}
return $this->redirect(['index']);
}
/**
* Impersonate user.
*/
public function actionImpersonate(int $id): Response
{
/** @var Module $module */
$module = $this->module;
if (!$module->enableImpersonation) {
throw new NotFoundHttpException();
}
$model = $this->findUser($id);
/** @var UserService $service */
$service = Yii::$container->get(UserService::class);
if ($service->impersonate($model)) {
Yii::$app->session->setFlash('warning', Yii::t('user', 'You are now impersonating {user}. Click "Stop Impersonating" to return to your account.', ['user' => $model->email]));
return $this->goHome();
}
Yii::$app->session->setFlash('danger', Yii::t('user', 'You are not allowed to impersonate this user.'));
return $this->redirect(['index']);
}
/**
* Stop impersonating.
*/
public function actionStopImpersonate(): Response
{
/** @var UserService $service */
$service = Yii::$container->get(UserService::class);
if ($service->stopImpersonation()) {
Yii::$app->session->setFlash('success', Yii::t('user', 'You have returned to your account.'));
}
return $this->redirect(['index']);
}
/**
* Find user by ID.
*/
protected function findUser(int $id): User
{
$user = User::findOne($id);
if ($user === null) {
throw new NotFoundHttpException(Yii::t('user', 'User not found.'));
}
return $user;
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\db\Expression;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* GDPR controller for data export and deletion.
*/
class GdprController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'class' => AccessControl::class,
'rules' => [
['allow' => true, 'roles' => ['@']],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'delete' => ['post'],
],
],
];
}
/**
* {@inheritdoc}
*/
public function beforeAction($action): bool
{
/** @var Module $module */
$module = $this->module;
if (!$module->enableGdpr) {
throw new NotFoundHttpException();
}
return parent::beforeAction($action);
}
/**
* GDPR overview page.
*/
public function actionIndex(): string
{
return $this->render('index', [
'module' => $this->module,
]);
}
/**
* Export user data.
*/
public function actionExport(): Response
{
/** @var User $user */
$user = Yii::$app->user->identity;
$data = [
'user' => [
'id' => $user->id,
'email' => $user->email,
'username' => $user->username,
'status' => $user->status,
'email_confirmed_at' => $user->email_confirmed_at,
'last_login_at' => $user->last_login_at,
'last_login_ip' => $user->last_login_ip,
'registration_ip' => $user->registration_ip,
'created_at' => $user->created_at,
'gdpr_consent_at' => $user->gdpr_consent_at,
],
'profile' => null,
'exported_at' => date('Y-m-d H:i:s'),
];
if ($user->profile !== null) {
$data['profile'] = [
'name' => $user->profile->name,
'bio' => $user->profile->bio,
'location' => $user->profile->location,
'website' => $user->profile->website,
'timezone' => $user->profile->timezone,
'public_email' => $user->profile->public_email,
];
}
// Return as JSON download
Yii::$app->response->format = Response::FORMAT_JSON;
Yii::$app->response->headers->set('Content-Disposition', 'attachment; filename="user-data-' . $user->id . '.json"');
return $this->asJson($data);
}
/**
* Delete user account (GDPR right to be forgotten).
*/
public function actionDelete(): Response|string
{
/** @var User $user */
$user = Yii::$app->user->identity;
$model = new class extends \yii\base\Model {
public ?string $password = null;
public bool $confirm = false;
public function rules(): array
{
return [
['password', 'required'],
['confirm', 'required'],
['confirm', 'boolean'],
['confirm', 'compare', 'compareValue' => true, 'message' => Yii::t('user', 'You must confirm that you want to delete your account.')],
];
}
public function attributeLabels(): array
{
return [
'password' => Yii::t('user', 'Current Password'),
'confirm' => Yii::t('user', 'I understand this action cannot be undone'),
];
}
};
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
if (!$user->validatePassword($model->password)) {
$model->addError('password', Yii::t('user', 'Password is incorrect.'));
} else {
// Soft delete - anonymize data but keep record for audit
$user->email = 'deleted_' . $user->id . '@deleted.local';
$user->username = null;
$user->password_hash = '';
$user->auth_key = Yii::$app->security->generateRandomString(32);
$user->status = User::STATUS_BLOCKED;
$user->gdpr_deleted_at = new Expression('NOW()');
// Clear profile
if ($user->profile !== null) {
$user->profile->name = null;
$user->profile->bio = null;
$user->profile->location = null;
$user->profile->website = null;
$user->profile->public_email = null;
$user->profile->gravatar_email = null;
$user->profile->avatar_path = null;
$user->profile->save(false);
}
if ($user->save(false)) {
Yii::$app->user->logout();
Yii::$app->session->setFlash('success', Yii::t('user', 'Your account has been deleted.'));
return $this->goHome();
}
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while deleting your account.'));
}
}
return $this->render('delete', [
'model' => $model,
'module' => $this->module,
]);
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\RecoveryForm;
use cgsmith\user\models\RecoveryResetForm;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\RecoveryService;
use Yii;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* Password recovery controller.
*/
class RecoveryController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'class' => AccessControl::class,
'rules' => [
['allow' => true, 'actions' => ['request', 'reset'], 'roles' => ['?']],
],
],
];
}
/**
* Request password recovery.
*/
public function actionRequest(): Response|string
{
/** @var Module $module */
$module = $this->module;
if (!$module->enablePasswordRecovery) {
throw new NotFoundHttpException(Yii::t('user', 'Password recovery is disabled.'));
}
/** @var RecoveryForm $model */
$model = $module->createModel('RecoveryForm');
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
/** @var RecoveryService $service */
$service = Yii::$container->get(RecoveryService::class);
$service->sendRecoveryMessage($model);
Yii::$app->session->setFlash('success', Yii::t('user', 'If the email exists, we have sent password recovery instructions.'));
return $this->redirect(['/user/login']);
}
return $this->render('request', [
'model' => $model,
'module' => $module,
]);
}
/**
* Reset password with token.
*/
public function actionReset(int $id, string $token): Response|string
{
/** @var Module $module */
$module = $this->module;
if (!$module->enablePasswordRecovery) {
throw new NotFoundHttpException(Yii::t('user', 'Password recovery is disabled.'));
}
$user = User::findOne($id);
if ($user === null) {
throw new NotFoundHttpException(Yii::t('user', 'User not found.'));
}
/** @var RecoveryService $service */
$service = Yii::$container->get(RecoveryService::class);
if (!$service->validateToken($user, $token)) {
Yii::$app->session->setFlash('danger', Yii::t('user', 'The recovery link is invalid or has expired.'));
return $this->redirect(['/user/recovery/request']);
}
/** @var RecoveryResetForm $model */
$model = $module->createModel('RecoveryResetForm');
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
if ($service->resetPassword($user, $token, $model->password)) {
Yii::$app->session->setFlash('success', Yii::t('user', 'Your password has been reset. You can now sign in.'));
return $this->redirect(['/user/login']);
}
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while resetting your password.'));
}
return $this->render('reset', [
'model' => $model,
'module' => $module,
]);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\RegistrationForm;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\RegistrationService;
use Yii;
use yii\filters\AccessControl;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
/**
* Registration controller.
*/
class RegistrationController extends Controller
{
public const EVENT_BEFORE_REGISTER = 'beforeRegister';
public const EVENT_AFTER_REGISTER = 'afterRegister';
public const EVENT_BEFORE_CONFIRM = 'beforeConfirm';
public const EVENT_AFTER_CONFIRM = 'afterConfirm';
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'class' => AccessControl::class,
'rules' => [
['allow' => true, 'actions' => ['register', 'confirm', 'resend'], 'roles' => ['?']],
],
],
];
}
/**
* Display registration form.
*/
public function actionRegister(): Response|string
{
/** @var Module $module */
$module = $this->module;
if (!$module->enableRegistration) {
throw new NotFoundHttpException(Yii::t('user', 'Registration is disabled.'));
}
/** @var RegistrationForm $model */
$model = $module->createModel('RegistrationForm');
// Handle AJAX validation
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
return $this->asJson(\yii\widgets\ActiveForm::validate($model));
}
if ($model->load(Yii::$app->request->post())) {
/** @var RegistrationService $service */
$service = Yii::$container->get(RegistrationService::class);
$user = $service->register($model);
if ($user !== null) {
if ($module->enableConfirmation) {
Yii::$app->session->setFlash('success', Yii::t('user', 'Your account has been created. Please check your email for confirmation instructions.'));
} else {
Yii::$app->session->setFlash('success', Yii::t('user', 'Your account has been created and you can now sign in.'));
}
return $this->redirect(['/user/login']);
}
}
return $this->render('register', [
'model' => $model,
'module' => $module,
]);
}
/**
* Confirm email with token.
*/
public function actionConfirm(int $id, string $token): Response
{
/** @var Module $module */
$module = $this->module;
$user = User::findOne($id);
if ($user === null) {
throw new NotFoundHttpException(Yii::t('user', 'User not found.'));
}
if ($user->getIsConfirmed()) {
Yii::$app->session->setFlash('info', Yii::t('user', 'Your email has already been confirmed.'));
return $this->redirect(['/user/login']);
}
/** @var RegistrationService $service */
$service = Yii::$container->get(RegistrationService::class);
if ($service->confirm($user, $token)) {
Yii::$app->session->setFlash('success', Yii::t('user', 'Thank you! Your email has been confirmed.'));
// Auto-login after confirmation
Yii::$app->user->login($user, $module->rememberFor);
return $this->goHome();
}
Yii::$app->session->setFlash('danger', Yii::t('user', 'The confirmation link is invalid or has expired.'));
return $this->redirect(['/user/login']);
}
/**
* Resend confirmation email.
*/
public function actionResend(): Response|string
{
/** @var Module $module */
$module = $this->module;
if (!$module->enableConfirmation) {
throw new NotFoundHttpException();
}
$model = new class extends \yii\base\Model {
public ?string $email = null;
public function rules(): array
{
return [
['email', 'required'],
['email', 'email'],
];
}
public function attributeLabels(): array
{
return [
'email' => Yii::t('user', 'Email'),
];
}
};
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
$user = User::findByEmail($model->email);
if ($user !== null && !$user->getIsConfirmed()) {
/** @var RegistrationService $service */
$service = Yii::$container->get(RegistrationService::class);
$service->resendConfirmation($user);
}
// Always show success message to prevent email enumeration
Yii::$app->session->setFlash('success', Yii::t('user', 'If the email exists and is not confirmed, we have sent a new confirmation link.'));
return $this->redirect(['/user/login']);
}
return $this->render('resend', [
'model' => $model,
'module' => $module,
]);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\events\FormEvent;
use cgsmith\user\models\LoginForm;
use cgsmith\user\Module;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\Response;
/**
* Security controller for login/logout.
*/
class SecurityController extends Controller
{
public const EVENT_BEFORE_LOGIN = 'beforeLogin';
public const EVENT_AFTER_LOGIN = 'afterLogin';
public const EVENT_BEFORE_LOGOUT = 'beforeLogout';
public const EVENT_AFTER_LOGOUT = 'afterLogout';
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'class' => AccessControl::class,
'rules' => [
['allow' => true, 'actions' => ['login'], 'roles' => ['?']],
['allow' => true, 'actions' => ['login', 'logout'], 'roles' => ['@']],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'logout' => ['post'],
],
],
];
}
/**
* Display login page and handle login.
*/
public function actionLogin(): Response|string
{
if (!Yii::$app->user->isGuest) {
return $this->goHome();
}
/** @var Module $module */
$module = $this->module;
/** @var LoginForm $model */
$model = $module->createModel('LoginForm');
// Handle AJAX validation
if (Yii::$app->request->isAjax && $model->load(Yii::$app->request->post())) {
return $this->asJson(\yii\widgets\ActiveForm::validate($model));
}
// Trigger before login event
$event = new FormEvent(['form' => $model]);
$module->trigger(self::EVENT_BEFORE_LOGIN, $event);
if ($model->load(Yii::$app->request->post()) && $model->login()) {
// Trigger after login event
$event = new FormEvent(['form' => $model]);
$module->trigger(self::EVENT_AFTER_LOGIN, $event);
return $this->goBack();
}
return $this->render('login', [
'model' => $model,
'module' => $module,
]);
}
/**
* Logout user.
*/
public function actionLogout(): Response
{
/** @var Module $module */
$module = $this->module;
// Trigger before logout event
$event = new FormEvent(['form' => null]);
$module->trigger(self::EVENT_BEFORE_LOGOUT, $event);
Yii::$app->user->logout();
// Trigger after logout event
$event = new FormEvent(['form' => null]);
$module->trigger(self::EVENT_AFTER_LOGOUT, $event);
return $this->goHome();
}
}

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\controllers;
use cgsmith\user\models\Profile;
use cgsmith\user\models\SettingsForm;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use cgsmith\user\services\MailerService;
use cgsmith\user\services\TokenService;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\Response;
use yii\web\UploadedFile;
/**
* Settings controller for account and profile.
*/
class SettingsController extends Controller
{
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
'access' => [
'class' => AccessControl::class,
'rules' => [
['allow' => true, 'roles' => ['@']],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'delete-avatar' => ['post'],
],
],
];
}
/**
* Account settings (email, password).
*/
public function actionAccount(): Response|string
{
/** @var Module $module */
$module = $this->module;
/** @var User $user */
$user = Yii::$app->user->identity;
$model = new SettingsForm($user);
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
$transaction = Yii::$app->db->beginTransaction();
try {
// Handle email change
if ($model->isEmailChanged()) {
if ($module->emailChangeStrategy === Module::EMAIL_CHANGE_INSECURE) {
$user->email = $model->email;
} else {
/** @var TokenService $tokenService */
$tokenService = Yii::$container->get(TokenService::class);
$token = $tokenService->createEmailChangeToken($user, $model->email);
/** @var MailerService $mailer */
$mailer = Yii::$container->get(MailerService::class);
$mailer->sendEmailChangeMessage($user, $token, $model->email);
Yii::$app->session->setFlash('info', Yii::t('user', 'A confirmation email has been sent to your new email address.'));
}
}
// Handle username change
if ($model->username !== $user->username) {
$user->username = $model->username;
}
// Handle password change
if ($model->isPasswordChanged()) {
$user->password = $model->new_password;
}
if (!$user->save()) {
$transaction->rollBack();
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while saving your settings.'));
} else {
$transaction->commit();
Yii::$app->session->setFlash('success', Yii::t('user', 'Your settings have been updated.'));
}
} catch (\Exception $e) {
$transaction->rollBack();
Yii::error('Settings update failed: ' . $e->getMessage(), __METHOD__);
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while saving your settings.'));
}
return $this->refresh();
}
return $this->render('account', [
'model' => $model,
'module' => $module,
]);
}
/**
* Profile settings.
*/
public function actionProfile(): Response|string
{
/** @var Module $module */
$module = $this->module;
/** @var User $user */
$user = Yii::$app->user->identity;
$profile = $user->profile;
if ($profile->load(Yii::$app->request->post())) {
// Handle avatar upload
if ($module->enableAvatarUpload) {
$avatarFile = UploadedFile::getInstance($profile, 'avatar_path');
if ($avatarFile !== null) {
$uploadPath = Yii::getAlias($module->avatarPath);
if (!is_dir($uploadPath)) {
mkdir($uploadPath, 0755, true);
}
// Delete old avatar
if (!empty($profile->avatar_path)) {
$oldPath = $uploadPath . '/' . $profile->avatar_path;
if (file_exists($oldPath)) {
unlink($oldPath);
}
}
// Save new avatar
$filename = $user->id . '_' . time() . '.' . $avatarFile->extension;
$avatarFile->saveAs($uploadPath . '/' . $filename);
$profile->avatar_path = $filename;
}
}
if ($profile->save()) {
Yii::$app->session->setFlash('success', Yii::t('user', 'Your profile has been updated.'));
return $this->refresh();
}
}
return $this->render('profile', [
'model' => $profile,
'module' => $module,
]);
}
/**
* Delete avatar.
*/
public function actionDeleteAvatar(): Response
{
/** @var Module $module */
$module = $this->module;
/** @var User $user */
$user = Yii::$app->user->identity;
$profile = $user->profile;
if (!empty($profile->avatar_path)) {
$uploadPath = Yii::getAlias($module->avatarPath);
$path = $uploadPath . '/' . $profile->avatar_path;
if (file_exists($path)) {
unlink($path);
}
$profile->avatar_path = null;
$profile->save(false, ['avatar_path']);
Yii::$app->session->setFlash('success', Yii::t('user', 'Your avatar has been deleted.'));
}
return $this->redirect(['profile']);
}
/**
* Confirm email change.
*/
public function actionConfirmEmail(int $id, string $token): Response
{
/** @var User $user */
$user = Yii::$app->user->identity;
if ($user->id !== $id) {
Yii::$app->session->setFlash('danger', Yii::t('user', 'Invalid request.'));
return $this->redirect(['account']);
}
/** @var TokenService $tokenService */
$tokenService = Yii::$container->get(TokenService::class);
$tokenModel = $tokenService->findEmailChangeToken($token);
if ($tokenModel === null || $tokenModel->user_id !== $user->id) {
Yii::$app->session->setFlash('danger', Yii::t('user', 'The confirmation link is invalid or has expired.'));
return $this->redirect(['account']);
}
$newEmail = $tokenModel->data['new_email'] ?? null;
if ($newEmail === null) {
Yii::$app->session->setFlash('danger', Yii::t('user', 'Invalid email change request.'));
return $this->redirect(['account']);
}
$user->email = $newEmail;
if ($user->save(false, ['email'])) {
$tokenService->deleteToken($tokenModel);
Yii::$app->session->setFlash('success', Yii::t('user', 'Your email has been changed.'));
} else {
Yii::$app->session->setFlash('danger', Yii::t('user', 'An error occurred while changing your email.'));
}
return $this->redirect(['account']);
}
}

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

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

View File

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

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

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

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\filters;
use cgsmith\user\contracts\UserInterface;
use yii\filters\AccessRule as BaseAccessRule;
/**
* Access rule that supports the 'admin' role.
*
* This rule extends Yii's AccessRule to add support for checking
* if a user is an admin via the UserInterface::getIsAdmin() method.
*/
class AccessRule extends BaseAccessRule
{
/**
* {@inheritdoc}
*/
protected function matchRole($user): bool
{
if (empty($this->roles)) {
return true;
}
foreach ($this->roles as $role) {
if ($role === '?') {
if ($user->getIsGuest()) {
return true;
}
} elseif ($role === '@') {
if (!$user->getIsGuest()) {
return true;
}
} elseif ($role === 'admin') {
// Check if user is admin via UserInterface
if (!$user->getIsGuest()) {
$identity = $user->identity;
if ($identity instanceof UserInterface && $identity->getIsAdmin()) {
return true;
}
}
} elseif (!$user->getIsGuest() && $user->can($role)) {
return true;
}
}
return false;
}
}

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

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\helpers;
use Yii;
/**
* Password helper for hashing and validation.
*/
class Password
{
/**
* Hash a password.
*/
public static function hash(string $password, int $cost = 12): string
{
return Yii::$app->security->generatePasswordHash($password, $cost);
}
/**
* Validate a password against a hash.
*/
public static function validate(string $password, string $hash): bool
{
return Yii::$app->security->validatePassword($password, $hash);
}
/**
* Generate a random password.
*/
public static function generate(int $length = 12): string
{
$chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
$password = '';
for ($i = 0; $i < $length; $i++) {
$password .= $chars[random_int(0, strlen($chars) - 1)];
}
return $password;
}
/**
* Check password strength.
*
* @return array Array with 'score' (0-4) and 'feedback' messages
*/
public static function checkStrength(string $password): array
{
$score = 0;
$feedback = [];
// Length check
$length = strlen($password);
if ($length >= 8) $score++;
if ($length >= 12) $score++;
if ($length < 8) {
$feedback[] = Yii::t('user', 'Password should be at least 8 characters.');
}
// Lowercase
if (preg_match('/[a-z]/', $password)) {
$score += 0.5;
} else {
$feedback[] = Yii::t('user', 'Add lowercase letters.');
}
// Uppercase
if (preg_match('/[A-Z]/', $password)) {
$score += 0.5;
} else {
$feedback[] = Yii::t('user', 'Add uppercase letters.');
}
// Numbers
if (preg_match('/[0-9]/', $password)) {
$score += 0.5;
} else {
$feedback[] = Yii::t('user', 'Add numbers.');
}
// Special characters
if (preg_match('/[^a-zA-Z0-9]/', $password)) {
$score += 0.5;
} else {
$feedback[] = Yii::t('user', 'Add special characters.');
}
return [
'score' => min(4, (int) $score),
'feedback' => $feedback,
];
}
}

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

@@ -0,0 +1,193 @@
<?php
/**
* English translations for cgsmith/yii2-user.
*/
return [
// General
'ID' => 'ID',
'Email' => 'Email',
'Username' => 'Username',
'Password' => 'Password',
'Status' => 'Status',
'Created At' => 'Created At',
'Updated At' => 'Updated At',
// User statuses
'Pending' => 'Pending',
'Active' => 'Active',
'Blocked' => 'Blocked',
'Confirmed' => 'Confirmed',
'Unconfirmed' => 'Unconfirmed',
// Login
'Sign In' => 'Sign In',
'Email or Username' => 'Email or Username',
'Remember me' => 'Remember me',
'Forgot password?' => 'Forgot password?',
"Don't have an account?" => "Don't have an account?",
'Invalid login or password.' => 'Invalid login or password.',
'Your account has been blocked.' => 'Your account has been blocked.',
'You need to confirm your email address.' => 'You need to confirm your email address.',
// Registration
'Sign Up' => 'Sign Up',
'Sign up' => 'Sign up',
'Username (optional)' => 'Username (optional)',
'Already have an account?' => 'Already have an account?',
'Registration is disabled.' => 'Registration is disabled.',
'Your account has been created. Please check your email for confirmation instructions.' => 'Your account has been created. Please check your email for confirmation instructions.',
'Your account has been created and you can now sign in.' => 'Your account has been created and you can now sign in.',
'This email address has already been taken.' => 'This email address has already been taken.',
'This username has already been taken.' => 'This username has already been taken.',
'Username can only contain alphanumeric characters, underscores, hyphens, and dots.' => 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.',
// Confirmation
'Confirm your email on {app}' => 'Confirm your email on {app}',
'Resend Confirmation' => 'Resend Confirmation',
'Resend' => 'Resend',
'Enter your email address and we will send you a new confirmation link.' => 'Enter your email address and we will send you a new confirmation link.',
'If the email exists and is not confirmed, we have sent a new confirmation link.' => 'If the email exists and is not confirmed, we have sent a new confirmation link.',
'Your email has already been confirmed.' => 'Your email has already been confirmed.',
'Thank you! Your email has been confirmed.' => 'Thank you! Your email has been confirmed.',
'The confirmation link is invalid or has expired.' => 'The confirmation link is invalid or has expired.',
// Recovery
'Forgot Password' => 'Forgot Password',
'Send Reset Link' => 'Send Reset Link',
'Enter your email address and we will send you a link to reset your password.' => 'Enter your email address and we will send you a link to reset your password.',
'If the email exists, we have sent password recovery instructions.' => 'If the email exists, we have sent password recovery instructions.',
'Password recovery is disabled.' => 'Password recovery is disabled.',
'Reset Password' => 'Reset Password',
'New Password' => 'New Password',
'Confirm Password' => 'Confirm Password',
'Enter your new password below.' => 'Enter your new password below.',
'The recovery link is invalid or has expired.' => 'The recovery link is invalid or has expired.',
'Your password has been reset. You can now sign in.' => 'Your password has been reset. You can now sign in.',
'An error occurred while resetting your password.' => 'An error occurred while resetting your password.',
'Passwords do not match.' => 'Passwords do not match.',
'Password recovery on {app}' => 'Password recovery on {app}',
// Settings
'Account Settings' => 'Account Settings',
'Profile Settings' => 'Profile Settings',
'Account' => 'Account',
'Profile' => 'Profile',
'Privacy & Data' => 'Privacy & Data',
'Change Password' => 'Change Password',
'Confirm New Password' => 'Confirm New Password',
'Current Password' => 'Current Password',
'Required to change email or password.' => 'Required to change email or password.',
'Current password is required to change email or password.' => 'Current password is required to change email or password.',
'Current password is incorrect.' => 'Current password is incorrect.',
'Save Changes' => 'Save Changes',
'Your settings have been updated.' => 'Your settings have been updated.',
'Your profile has been updated.' => 'Your profile has been updated.',
'An error occurred while saving your settings.' => 'An error occurred while saving your settings.',
'A confirmation email has been sent to your new email address.' => 'A confirmation email has been sent to your new email address.',
'Your email has been changed.' => 'Your email has been changed.',
'An error occurred while changing your email.' => 'An error occurred while changing your email.',
'Confirm email change on {app}' => 'Confirm email change on {app}',
'Invalid request.' => 'Invalid request.',
'Invalid email change request.' => 'Invalid email change request.',
// Profile
'Name' => 'Name',
'Bio' => 'Bio',
'Location' => 'Location',
'Website' => 'Website',
'Timezone' => 'Timezone',
'Select timezone...' => 'Select timezone...',
'Avatar' => 'Avatar',
'Upload Avatar' => 'Upload Avatar',
'Delete Avatar' => 'Delete Avatar',
'Are you sure you want to delete your avatar?' => 'Are you sure you want to delete your avatar?',
'Your avatar has been deleted.' => 'Your avatar has been deleted.',
'Gravatar Email' => 'Gravatar Email',
'Use Gravatar' => 'Use Gravatar',
'Leave empty to use your account email for Gravatar.' => 'Leave empty to use your account email for Gravatar.',
'Public Email' => 'Public Email',
// Admin
'Manage Users' => 'Manage Users',
'Create User' => 'Create User',
'Update User: {email}' => 'Update User: {email}',
'Update' => 'Update',
'Create' => 'Create',
'Cancel' => 'Cancel',
'Delete' => 'Delete',
'Block' => 'Block',
'Unblock' => 'Unblock',
'Confirm' => 'Confirm',
'Impersonate' => 'Impersonate',
'User' => 'User',
'Email Confirmed' => 'Email Confirmed',
'Last Login' => 'Last Login',
'Registration IP' => 'Registration IP',
'Blocked At' => 'Blocked At',
'User not found.' => 'User not found.',
'User has been created.' => 'User has been created.',
'User has been updated.' => 'User has been updated.',
'User has been deleted.' => 'User has been deleted.',
'User has been blocked.' => 'User has been blocked.',
'User has been unblocked.' => 'User has been unblocked.',
'User email has been confirmed.' => 'User email has been confirmed.',
'You cannot delete your own account.' => 'You cannot delete your own account.',
'You cannot block your own account.' => 'You cannot block your own account.',
'Are you sure you want to delete this user?' => 'Are you sure you want to delete this user?',
'Are you sure you want to block this user?' => 'Are you sure you want to block this user?',
'Are you sure you want to unblock this user?' => 'Are you sure you want to unblock this user?',
'Leave empty to keep current password.' => 'Leave empty to keep current password.',
// Impersonation
'You are now impersonating {user}. Click "Stop Impersonating" to return to your account.' => 'You are now impersonating {user}. Click "Stop Impersonating" to return to your account.',
'You are not allowed to impersonate this user.' => 'You are not allowed to impersonate this user.',
'You have returned to your account.' => 'You have returned to your account.',
// GDPR
'Export Your Data' => 'Export Your Data',
'Download a copy of your personal data in JSON format.' => 'Download a copy of your personal data in JSON format.',
'Export Data' => 'Export Data',
'Delete Account' => 'Delete Account',
'Delete My Account' => 'Delete My Account',
'Permanently Delete My Account' => 'Permanently Delete My Account',
'This action is permanent and cannot be undone. All your data will be deleted.' => 'This action is permanent and cannot be undone. All your data will be deleted.',
'Warning:' => 'Warning:',
'This action is irreversible!' => 'This action is irreversible!',
'Deleting your account will:' => 'Deleting your account will:',
'Remove all your personal information' => 'Remove all your personal information',
'Delete your profile and settings' => 'Delete your profile and settings',
'Log you out immediately' => 'Log you out immediately',
'Enter your current password' => 'Enter your current password',
'I understand this action cannot be undone' => 'I understand this action cannot be undone',
'You must confirm that you want to delete your account.' => 'You must confirm that you want to delete your account.',
'Password is incorrect.' => 'Password is incorrect.',
'Your account has been deleted.' => 'Your account has been deleted.',
'An error occurred while deleting your account.' => 'An error occurred while deleting your account.',
// Email templates
'Welcome to {app}!' => 'Welcome to {app}!',
'Welcome to {app}' => 'Welcome to {app}',
'Thank you for registering.' => 'Thank you for registering.',
'Please click the button below to confirm your email address:' => 'Please click the button below to confirm your email address:',
'Please click the link below to confirm your email address:' => 'Please click the link below to confirm your email address:',
'Confirm Email' => 'Confirm Email',
'If the button above does not work, copy and paste this URL into your browser:' => 'If the button above does not work, copy and paste this URL into your browser:',
'This link will expire in {hours} hours.' => 'This link will expire in {hours} hours.',
'If you did not create an account, please ignore this email.' => 'If you did not create an account, please ignore this email.',
'If you did not request this email, please ignore it.' => 'If you did not request this email, please ignore it.',
'You can now sign in to your account.' => 'You can now sign in to your account.',
'Confirm Your Email' => 'Confirm Your Email',
'Reset Your Password' => 'Reset Your Password',
'We received a request to reset your password. Click the button below to create a new password:' => 'We received a request to reset your password. Click the button below to create a new password:',
'We received a request to reset your password. Click the link below to create a new password:' => 'We received a request to reset your password. Click the link below to create a new password:',
'If you did not request a password reset, please ignore this email. Your password will not be changed.' => 'If you did not request a password reset, please ignore this email. Your password will not be changed.',
'Your account on {app}' => 'Your account on {app}',
// Password strength
'Password should be at least 8 characters.' => 'Password should be at least 8 characters.',
'Add lowercase letters.' => 'Add lowercase letters.',
'Add uppercase letters.' => 'Add uppercase letters.',
'Add numbers.' => 'Add numbers.',
'Add special characters.' => 'Add special characters.',
];

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use yii\db\Migration;
/**
* Create user table.
*/
class m250115_000001_create_user_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp(): void
{
$tableOptions = null;
if ($this->db->driverName === 'mysql') {
$tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
}
$this->createTable('{{%user}}', [
'id' => $this->primaryKey()->unsigned(),
'email' => $this->string(255)->notNull()->unique(),
'username' => $this->string(255)->unique(),
'password_hash' => $this->string(255)->notNull(),
'auth_key' => $this->string(32)->notNull(),
'status' => "ENUM('pending', 'active', 'blocked') NOT NULL DEFAULT 'pending'",
'email_confirmed_at' => $this->dateTime(),
'blocked_at' => $this->dateTime(),
'last_login_at' => $this->dateTime(),
'last_login_ip' => $this->string(45),
'registration_ip' => $this->string(45),
'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'),
'updated_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
'gdpr_consent_at' => $this->dateTime(),
'gdpr_deleted_at' => $this->dateTime(),
], $tableOptions);
$this->createIndex('idx_user_status', '{{%user}}', 'status');
$this->createIndex('idx_user_email_confirmed', '{{%user}}', 'email_confirmed_at');
}
/**
* {@inheritdoc}
*/
public function safeDown(): void
{
$this->dropTable('{{%user}}');
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
use yii\db\Migration;
/**
* Create user_profile table.
*/
class m250115_000002_create_profile_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp(): void
{
$tableOptions = null;
if ($this->db->driverName === 'mysql') {
$tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
}
$this->createTable('{{%user_profile}}', [
'user_id' => $this->primaryKey()->unsigned(),
'name' => $this->string(255),
'bio' => $this->text(),
'location' => $this->string(255),
'website' => $this->string(255),
'timezone' => $this->string(40),
'avatar_path' => $this->string(255),
'gravatar_email' => $this->string(255),
'use_gravatar' => $this->boolean()->defaultValue(true),
'public_email' => $this->string(255),
'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'),
'updated_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
], $tableOptions);
$this->addForeignKey(
'fk_user_profile_user',
'{{%user_profile}}',
'user_id',
'{{%user}}',
'id',
'CASCADE',
'CASCADE'
);
}
/**
* {@inheritdoc}
*/
public function safeDown(): void
{
$this->dropForeignKey('fk_user_profile_user', '{{%user_profile}}');
$this->dropTable('{{%user_profile}}');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use yii\db\Migration;
/**
* Create user_token table.
*/
class m250115_000003_create_token_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp(): void
{
$tableOptions = null;
if ($this->db->driverName === 'mysql') {
$tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
}
$this->createTable('{{%user_token}}', [
'id' => $this->primaryKey()->unsigned(),
'user_id' => $this->integer()->unsigned()->notNull(),
'type' => "ENUM('confirmation', 'recovery', 'email_change') NOT NULL",
'token' => $this->string(64)->notNull()->unique(),
'data' => $this->json(),
'expires_at' => $this->dateTime()->notNull(),
'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'),
], $tableOptions);
$this->createIndex('idx_user_token_user_type', '{{%user_token}}', ['user_id', 'type']);
$this->createIndex('idx_user_token_expires', '{{%user_token}}', 'expires_at');
$this->addForeignKey(
'fk_user_token_user',
'{{%user_token}}',
'user_id',
'{{%user}}',
'id',
'CASCADE',
'CASCADE'
);
}
/**
* {@inheritdoc}
*/
public function safeDown(): void
{
$this->dropForeignKey('fk_user_token_user', '{{%user_token}}');
$this->dropTable('{{%user_token}}');
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use yii\db\Migration;
/**
* Create user_social_account table (placeholder for v2.0 social login).
*/
class m250115_000004_create_social_account_table extends Migration
{
/**
* {@inheritdoc}
*/
public function safeUp(): void
{
$tableOptions = null;
if ($this->db->driverName === 'mysql') {
$tableOptions = 'CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE=InnoDB';
}
$this->createTable('{{%user_social_account}}', [
'id' => $this->primaryKey()->unsigned(),
'user_id' => $this->integer()->unsigned(),
'provider' => $this->string(50)->notNull(),
'provider_id' => $this->string(255)->notNull(),
'data' => $this->json(),
'email' => $this->string(255),
'username' => $this->string(255),
'created_at' => $this->dateTime()->notNull()->defaultExpression('CURRENT_TIMESTAMP'),
], $tableOptions);
$this->createIndex('idx_user_social_provider', '{{%user_social_account}}', ['provider', 'provider_id'], true);
$this->createIndex('idx_user_social_user', '{{%user_social_account}}', 'user_id');
$this->addForeignKey(
'fk_user_social_account_user',
'{{%user_social_account}}',
'user_id',
'{{%user}}',
'id',
'CASCADE',
'CASCADE'
);
}
/**
* {@inheritdoc}
*/
public function safeDown(): void
{
$this->dropForeignKey('fk_user_social_account_user', '{{%user_social_account}}');
$this->dropTable('{{%user_social_account}}');
}
}

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

@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\Module;
use Yii;
use yii\base\Model;
/**
* Login form model.
*/
class LoginForm extends Model
{
public ?string $login = null;
public ?string $password = null;
public bool $rememberMe = false;
private ?User $_user = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['login', 'password'], 'required'],
[['login'], 'string'],
[['password'], 'string'],
[['rememberMe'], 'boolean'],
[['password'], 'validatePassword'],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'login' => Yii::t('user', 'Email or Username'),
'password' => Yii::t('user', 'Password'),
'rememberMe' => Yii::t('user', 'Remember me'),
];
}
/**
* Validate the password.
*/
public function validatePassword(string $attribute): void
{
if ($this->hasErrors()) {
return;
}
$user = $this->getUser();
if ($user === null) {
$this->addError($attribute, Yii::t('user', 'Invalid login or password.'));
return;
}
if ($user->getIsBlocked()) {
$this->addError($attribute, Yii::t('user', 'Your account has been blocked.'));
return;
}
$module = $this->getModule();
if (!$module->enableUnconfirmedLogin && !$user->getIsConfirmed()) {
$this->addError($attribute, Yii::t('user', 'You need to confirm your email address.'));
return;
}
if (!$user->validatePassword($this->password)) {
$this->addError($attribute, Yii::t('user', 'Invalid login or password.'));
}
}
/**
* Attempt to log in the user.
*/
public function login(): bool
{
if (!$this->validate()) {
return false;
}
$user = $this->getUser();
$module = $this->getModule();
$duration = $this->rememberMe ? $module->rememberFor : 0;
if (Yii::$app->user->login($user, $duration)) {
$user->updateLastLogin();
return true;
}
return false;
}
/**
* Get the user by login (email or username).
*/
public function getUser(): ?User
{
if ($this->_user === null && $this->login !== null) {
$this->_user = User::findByEmailOrUsername($this->login);
}
return $this->_user;
}
/**
* Get the user module.
*/
protected function getModule(): Module
{
/** @var Module $module */
$module = Yii::$app->getModule('user');
return $module;
}
}

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

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\models\query\ProfileQuery;
use Yii;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
use yii\db\Expression;
/**
* Profile ActiveRecord model.
*
* @property int $user_id
* @property string|null $name
* @property string|null $bio
* @property string|null $location
* @property string|null $website
* @property string|null $timezone
* @property string|null $avatar_path
* @property string|null $gravatar_email
* @property bool $use_gravatar
* @property string|null $public_email
* @property string $created_at
* @property string $updated_at
*
* @property-read User $user
* @property-read string|null $avatarUrl
*/
class Profile extends ActiveRecord
{
/**
* {@inheritdoc}
*/
public static function tableName(): string
{
return '{{%user_profile}}';
}
/**
* {@inheritdoc}
* @return ProfileQuery
*/
public static function find(): ProfileQuery
{
return new ProfileQuery(static::class);
}
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
[
'class' => TimestampBehavior::class,
'value' => new Expression('NOW()'),
],
];
}
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['name', 'location', 'public_email', 'gravatar_email'], 'string', 'max' => 255],
[['website'], 'url'],
[['bio'], 'string'],
[['timezone'], 'string', 'max' => 40],
[['timezone'], 'in', 'range' => \DateTimeZone::listIdentifiers()],
[['use_gravatar'], 'boolean'],
[['use_gravatar'], 'default', 'value' => true],
[['public_email', 'gravatar_email'], 'email'],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'user_id' => Yii::t('user', 'User'),
'name' => Yii::t('user', 'Name'),
'bio' => Yii::t('user', 'Bio'),
'location' => Yii::t('user', 'Location'),
'website' => Yii::t('user', 'Website'),
'timezone' => Yii::t('user', 'Timezone'),
'avatar_path' => Yii::t('user', 'Avatar'),
'gravatar_email' => Yii::t('user', 'Gravatar Email'),
'use_gravatar' => Yii::t('user', 'Use Gravatar'),
'public_email' => Yii::t('user', 'Public Email'),
];
}
/**
* Get user relation.
*/
public function getUser(): ActiveQuery
{
return $this->hasOne(User::class, ['id' => 'user_id']);
}
/**
* Get avatar URL.
*/
public function getAvatarUrl(int $size = 200): ?string
{
// Local avatar takes precedence
if (!empty($this->avatar_path)) {
$module = Yii::$app->getModule('user');
return Yii::getAlias($module->avatarUrl) . '/' . $this->avatar_path;
}
// Gravatar fallback
if ($this->use_gravatar) {
$email = $this->gravatar_email ?? $this->user->email ?? '';
return $this->getGravatarUrl($email, $size);
}
return null;
}
/**
* Generate Gravatar URL.
*/
public function getGravatarUrl(string $email, int $size = 200): string
{
$hash = md5(strtolower(trim($email)));
return "https://www.gravatar.com/avatar/{$hash}?s={$size}&d=identicon";
}
/**
* Get timezone list for dropdown.
*/
public static function getTimezoneList(): array
{
$timezones = [];
$identifiers = \DateTimeZone::listIdentifiers();
foreach ($identifiers as $identifier) {
$timezones[$identifier] = str_replace('_', ' ', $identifier);
}
return $timezones;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use Yii;
use yii\base\Model;
/**
* Password recovery request form.
*/
class RecoveryForm extends Model
{
public ?string $email = null;
private ?User $_user = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
['email', 'trim'],
['email', 'required'],
['email', 'email'],
['email', 'validateEmail'],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'email' => Yii::t('user', 'Email'),
];
}
/**
* Validate that the email exists.
*/
public function validateEmail(string $attribute): void
{
$user = $this->getUser();
if ($user === null) {
// Don't reveal that the email doesn't exist (security)
return;
}
if ($user->getIsBlocked()) {
$this->addError($attribute, Yii::t('user', 'Your account has been blocked.'));
}
}
/**
* Get the user by email.
*/
public function getUser(): ?User
{
if ($this->_user === null && $this->email !== null) {
$this->_user = User::findByEmail($this->email);
}
return $this->_user;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\Module;
use Yii;
use yii\base\Model;
/**
* Password reset form (after recovery).
*/
class RecoveryResetForm extends Model
{
public ?string $password = null;
public ?string $password_confirm = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
$module = $this->getModule();
return [
[['password', 'password_confirm'], 'required'],
['password', 'string', 'min' => $module->minPasswordLength, 'max' => $module->maxPasswordLength],
['password_confirm', 'compare', 'compareAttribute' => 'password', 'message' => Yii::t('user', 'Passwords do not match.')],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'password' => Yii::t('user', 'New Password'),
'password_confirm' => Yii::t('user', 'Confirm Password'),
];
}
/**
* Get the user module.
*/
protected function getModule(): Module
{
/** @var Module $module */
$module = Yii::$app->getModule('user');
return $module;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\Module;
use Yii;
use yii\base\Model;
/**
* Registration form model.
*/
class RegistrationForm extends Model
{
public ?string $email = null;
public ?string $username = null;
public ?string $password = null;
/**
* {@inheritdoc}
*/
public function rules()
{
$module = $this->getModule();
$rules = [
// Email
['email', 'trim'],
['email', 'required'],
['email', 'email'],
['email', 'string', 'max' => 255],
['email', 'unique', 'targetClass' => User::class, 'message' => Yii::t('user', 'This email address has already been taken.')],
// Username (optional)
['username', 'trim'],
['username', 'string', 'min' => 3, 'max' => 255],
['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.]+$/', 'message' => Yii::t('user', 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.')],
['username', 'unique', 'targetClass' => User::class, 'message' => Yii::t('user', 'This username has already been taken.')],
];
// Password rules (unless generated)
if (!$module->enableGeneratedPassword) {
$rules[] = ['password', 'required'];
$rules[] = ['password', 'string', 'min' => $module->minPasswordLength, 'max' => $module->maxPasswordLength];
}
return $rules;
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'email' => Yii::t('user', 'Email'),
'username' => Yii::t('user', 'Username'),
'password' => Yii::t('user', 'Password'),
];
}
/**
* Get the user module.
*/
protected function getModule(): Module
{
/** @var Module $module */
$module = Yii::$app->getModule('user');
return $module;
}
}

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

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\Module;
use Yii;
use yii\base\Model;
/**
* Account settings form.
*/
class SettingsForm extends Model
{
public ?string $email = null;
public ?string $username = null;
public ?string $new_password = null;
public ?string $new_password_confirm = null;
public ?string $current_password = null;
private User $_user;
public function __construct(User $user, array $config = [])
{
$this->_user = $user;
$this->email = $user->email;
$this->username = $user->username;
parent::__construct($config);
}
/**
* {@inheritdoc}
*/
public function rules(): array
{
$module = $this->getModule();
return [
// Email
['email', 'trim'],
['email', 'required'],
['email', 'email'],
['email', 'string', 'max' => 255],
['email', 'unique', 'targetClass' => User::class, 'filter' => ['!=', 'id', $this->_user->id], 'message' => Yii::t('user', 'This email address has already been taken.')],
// Username
['username', 'trim'],
['username', 'string', 'min' => 3, 'max' => 255],
['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.]+$/', 'message' => Yii::t('user', 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.')],
['username', 'unique', 'targetClass' => User::class, 'filter' => ['!=', 'id', $this->_user->id], 'message' => Yii::t('user', 'This username has already been taken.')],
// New password
['new_password', 'string', 'min' => $module->minPasswordLength, 'max' => $module->maxPasswordLength],
['new_password_confirm', 'compare', 'compareAttribute' => 'new_password', 'message' => Yii::t('user', 'Passwords do not match.')],
// Current password (required when changing email or password)
['current_password', 'required', 'when' => function ($model) {
return $model->email !== $this->_user->email || !empty($model->new_password);
}, 'message' => Yii::t('user', 'Current password is required to change email or password.')],
['current_password', 'validateCurrentPassword'],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'email' => Yii::t('user', 'Email'),
'username' => Yii::t('user', 'Username'),
'new_password' => Yii::t('user', 'New Password'),
'new_password_confirm' => Yii::t('user', 'Confirm New Password'),
'current_password' => Yii::t('user', 'Current Password'),
];
}
/**
* Validate current password.
*/
public function validateCurrentPassword(string $attribute): void
{
if ($this->hasErrors()) {
return;
}
if (!empty($this->current_password) && !$this->_user->validatePassword($this->current_password)) {
$this->addError($attribute, Yii::t('user', 'Current password is incorrect.'));
}
}
/**
* Check if email has changed.
*/
public function isEmailChanged(): bool
{
return $this->email !== $this->_user->email;
}
/**
* Check if password should be changed.
*/
public function isPasswordChanged(): bool
{
return !empty($this->new_password);
}
/**
* Get the associated user.
*/
public function getUser(): User
{
return $this->_user;
}
/**
* Get the user module.
*/
protected function getModule(): Module
{
/** @var Module $module */
$module = Yii::$app->getModule('user');
return $module;
}
}

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

@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\models\query\TokenQuery;
use Yii;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
use yii\db\Expression;
/**
* Token ActiveRecord model.
*
* @property int $id
* @property int $user_id
* @property string $type
* @property string $token
* @property array|null $data
* @property string $expires_at
* @property string $created_at
*
* @property-read User $user
* @property-read bool $isExpired
*/
class Token extends ActiveRecord
{
public const TYPE_CONFIRMATION = 'confirmation';
public const TYPE_RECOVERY = 'recovery';
public const TYPE_EMAIL_CHANGE = 'email_change';
/**
* {@inheritdoc}
*/
public static function tableName(): string
{
return '{{%user_token}}';
}
/**
* {@inheritdoc}
* @return TokenQuery
*/
public static function find(): TokenQuery
{
return new TokenQuery(static::class);
}
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['user_id', 'type', 'token', 'expires_at'], 'required'],
[['type'], 'in', 'range' => [self::TYPE_CONFIRMATION, self::TYPE_RECOVERY, self::TYPE_EMAIL_CHANGE]],
[['token'], 'string', 'max' => 64],
[['token'], 'unique'],
[['data'], 'safe'],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels(): array
{
return [
'id' => Yii::t('user', 'ID'),
'user_id' => Yii::t('user', 'User'),
'type' => Yii::t('user', 'Type'),
'token' => Yii::t('user', 'Token'),
'data' => Yii::t('user', 'Data'),
'expires_at' => Yii::t('user', 'Expires At'),
'created_at' => Yii::t('user', 'Created At'),
];
}
/**
* {@inheritdoc}
*/
public function beforeSave($insert): bool
{
if (!parent::beforeSave($insert)) {
return false;
}
if ($insert) {
$this->created_at = new Expression('NOW()');
}
// Serialize data as JSON if it's an array
if (is_array($this->data)) {
$this->data = json_encode($this->data);
}
return true;
}
/**
* {@inheritdoc}
*/
public function afterFind(): void
{
parent::afterFind();
// Deserialize JSON data
if (is_string($this->data)) {
$this->data = json_decode($this->data, true);
}
}
/**
* Get user relation.
*/
public function getUser(): ActiveQuery
{
return $this->hasOne(User::class, ['id' => 'user_id']);
}
/**
* Check if token is expired.
*/
public function getIsExpired(): bool
{
return strtotime($this->expires_at) < time();
}
/**
* Generate a new secure token.
*/
public static function generateToken(): string
{
return Yii::$app->security->generateRandomString(64);
}
/**
* Create a confirmation token for a user.
*/
public static function createConfirmationToken(User $user): static
{
$module = Yii::$app->getModule('user');
$token = new static();
$token->user_id = $user->id;
$token->type = self::TYPE_CONFIRMATION;
$token->token = self::generateToken();
$token->expires_at = date('Y-m-d H:i:s', time() + $module->confirmWithin);
return $token;
}
/**
* Create a recovery token for a user.
*/
public static function createRecoveryToken(User $user): static
{
$module = Yii::$app->getModule('user');
$token = new static();
$token->user_id = $user->id;
$token->type = self::TYPE_RECOVERY;
$token->token = self::generateToken();
$token->expires_at = date('Y-m-d H:i:s', time() + $module->recoverWithin);
return $token;
}
/**
* Create an email change token for a user.
*/
public static function createEmailChangeToken(User $user, string $newEmail): static
{
$module = Yii::$app->getModule('user');
$token = new static();
$token->user_id = $user->id;
$token->type = self::TYPE_EMAIL_CHANGE;
$token->token = self::generateToken();
$token->expires_at = date('Y-m-d H:i:s', time() + $module->confirmWithin);
$token->data = ['new_email' => $newEmail];
return $token;
}
/**
* Find token by token string and type.
*/
public static function findByToken(string $token, string $type): ?static
{
return static::find()
->where(['token' => $token, 'type' => $type])
->notExpired()
->one();
}
/**
* Delete all tokens for a user of a specific type.
*/
public static function deleteAllForUser(int $userId, ?string $type = null): int
{
$condition = ['user_id' => $userId];
if ($type !== null) {
$condition['type'] = $type;
}
return static::deleteAll($condition);
}
/**
* Delete expired tokens.
*/
public static function deleteExpired(): int
{
return static::deleteAll(['<', 'expires_at', new Expression('NOW()')]);
}
}

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

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use cgsmith\user\contracts\UserInterface;
use cgsmith\user\helpers\Password;
use cgsmith\user\models\query\UserQuery;
use cgsmith\user\Module;
use Yii;
use yii\base\NotSupportedException;
use yii\behaviors\TimestampBehavior;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;
use yii\db\Expression;
use yii\web\IdentityInterface;
/**
* User ActiveRecord model.
*
* @property int $id
* @property string $email
* @property string|null $username
* @property string $password_hash
* @property string $auth_key
* @property string $status
* @property string|null $email_confirmed_at
* @property string|null $blocked_at
* @property string|null $last_login_at
* @property string|null $last_login_ip
* @property string|null $registration_ip
* @property string $created_at
* @property string $updated_at
* @property string|null $gdpr_consent_at
* @property string|null $gdpr_deleted_at
*
* @property-read bool $isAdmin
* @property-read bool $isBlocked
* @property-read bool $isConfirmed
* @property-read Profile $profile
* @property-read Token[] $tokens
*/
class User extends ActiveRecord implements IdentityInterface, UserInterface
{
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_BLOCKED = 'blocked';
/**
* Plain password for validation and hashing.
*/
public ?string $password = null;
/**
* {@inheritdoc}
*/
public static function tableName(): string
{
return '{{%user}}';
}
/**
* {@inheritdoc}
* @return UserQuery
*/
public static function find(): UserQuery
{
return new UserQuery(static::class);
}
/**
* {@inheritdoc}
*/
public function behaviors(): array
{
return [
[
'class' => TimestampBehavior::class,
'value' => new Expression('NOW()'),
],
];
}
/**
* {@inheritdoc}
*/
public function rules()
{
return [
// Email
['email', 'trim'],
['email', 'required'],
['email', 'email'],
['email', 'string', 'max' => 255],
['email', 'unique', 'message' => Yii::t('user', 'This email address has already been taken.')],
// Username
['username', 'trim'],
['username', 'string', 'min' => 3, 'max' => 255],
['username', 'match', 'pattern' => '/^[-a-zA-Z0-9_\.]+$/', 'message' => Yii::t('user', 'Username can only contain alphanumeric characters, underscores, hyphens, and dots.')],
['username', 'unique', 'message' => Yii::t('user', 'This username has already been taken.')],
// Password
['password', 'string', 'min' => $this->getModule()->minPasswordLength, 'max' => $this->getModule()->maxPasswordLength],
// Status
['status', 'in', 'range' => [self::STATUS_PENDING, self::STATUS_ACTIVE, self::STATUS_BLOCKED]],
['status', 'default', 'value' => self::STATUS_PENDING],
];
}
/**
* {@inheritdoc}
*/
public function attributeLabels()
{
return [
'id' => Yii::t('user', 'ID'),
'email' => Yii::t('user', 'Email'),
'username' => Yii::t('user', 'Username'),
'password' => Yii::t('user', 'Password'),
'status' => Yii::t('user', 'Status'),
'email_confirmed_at' => Yii::t('user', 'Email Confirmed'),
'blocked_at' => Yii::t('user', 'Blocked At'),
'last_login_at' => Yii::t('user', 'Last Login'),
'registration_ip' => Yii::t('user', 'Registration IP'),
'created_at' => Yii::t('user', 'Created At'),
'updated_at' => Yii::t('user', 'Updated At'),
];
}
/**
* {@inheritdoc}
*/
public function beforeSave($insert): bool
{
if (!parent::beforeSave($insert)) {
return false;
}
if ($insert) {
$this->auth_key = Yii::$app->security->generateRandomString(32);
if (Yii::$app->request instanceof \yii\web\Request) {
$this->registration_ip = Yii::$app->request->userIP;
}
}
if (!empty($this->password)) {
$this->password_hash = Password::hash($this->password, $this->getModule()->cost);
}
return true;
}
/**
* {@inheritdoc}
*/
public function afterSave($insert, $changedAttributes): void
{
parent::afterSave($insert, $changedAttributes);
if ($insert) {
$profile = new Profile(['user_id' => $this->id]);
$profile->save(false);
}
}
// IdentityInterface implementation
/**
* {@inheritdoc}
*/
public static function findIdentity($id): ?static
{
return static::find()->active()->andWhere(['id' => $id])->one();
}
/**
* {@inheritdoc}
*/
public static function findIdentityByAccessToken($token, $type = null): ?static
{
throw new NotSupportedException('findIdentityByAccessToken is not implemented.');
}
/**
* {@inheritdoc}
*/
public function getId(): int
{
return $this->id;
}
/**
* {@inheritdoc}
*/
public function getAuthKey(): string
{
return $this->auth_key;
}
/**
* {@inheritdoc}
*/
public function validateAuthKey($authKey): bool
{
return $this->auth_key === $authKey;
}
// UserInterface implementation
/**
* {@inheritdoc}
*/
public function getIsAdmin(): bool
{
$module = $this->getModule();
// Check RBAC permission first
if ($module->adminPermission !== null && Yii::$app->authManager !== null) {
if (Yii::$app->authManager->checkAccess($this->id, $module->adminPermission)) {
return true;
}
}
// Fallback to admins array (check by email)
return in_array($this->email, $module->admins, true);
}
/**
* {@inheritdoc}
*/
public function getIsBlocked(): bool
{
return $this->status === self::STATUS_BLOCKED || $this->blocked_at !== null;
}
/**
* {@inheritdoc}
*/
public function getIsConfirmed(): bool
{
return $this->email_confirmed_at !== null;
}
// Relations
/**
* Get user profile relation.
*/
public function getProfile(): ActiveQuery
{
return $this->hasOne(Profile::class, ['user_id' => 'id']);
}
/**
* Get user tokens relation.
*/
public function getTokens(): ActiveQuery
{
return $this->hasMany(Token::class, ['user_id' => 'id']);
}
// Helper methods
/**
* Validate password against stored hash.
*/
public function validatePassword(string $password): bool
{
return Password::validate($password, $this->password_hash);
}
/**
* Find user by email.
*/
public static function findByEmail(string $email): ?static
{
return static::find()->where(['email' => $email])->one();
}
/**
* Find user by username.
*/
public static function findByUsername(string $username): ?static
{
return static::find()->where(['username' => $username])->one();
}
/**
* Find user by email or username.
*/
public static function findByEmailOrUsername(string $login): ?static
{
return static::find()
->where(['or', ['email' => $login], ['username' => $login]])
->one();
}
/**
* Confirm user email.
*/
public function confirm(): bool
{
$this->status = self::STATUS_ACTIVE;
$this->email_confirmed_at = new Expression('NOW()');
return $this->save(false, ['status', 'email_confirmed_at']);
}
/**
* Block user.
*/
public function block(): bool
{
$this->status = self::STATUS_BLOCKED;
$this->blocked_at = new Expression('NOW()');
$this->auth_key = Yii::$app->security->generateRandomString(32);
return $this->save(false, ['status', 'blocked_at', 'auth_key']);
}
/**
* Unblock user.
*/
public function unblock(): bool
{
$this->status = $this->email_confirmed_at !== null ? self::STATUS_ACTIVE : self::STATUS_PENDING;
$this->blocked_at = null;
return $this->save(false, ['status', 'blocked_at']);
}
/**
* Update last login information.
*/
public function updateLastLogin(): bool
{
$this->last_login_at = new Expression('NOW()');
if (Yii::$app->request instanceof \yii\web\Request) {
$this->last_login_ip = Yii::$app->request->userIP;
}
return $this->save(false, ['last_login_at', 'last_login_ip']);
}
/**
* Reset password.
*/
public function resetPassword(string $password): bool
{
$this->password_hash = Password::hash($password, $this->getModule()->cost);
return $this->save(false, ['password_hash']);
}
/**
* Get the user module instance.
*/
protected function getModule(): Module
{
/** @var Module $module */
$module = Yii::$app->getModule('user');
return $module;
}
}

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

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models;
use yii\base\Model;
use yii\data\ActiveDataProvider;
/**
* User search model for admin grid.
*/
class UserSearch extends Model
{
public ?int $id = null;
public ?string $email = null;
public ?string $username = null;
public ?string $status = null;
public ?string $created_at = null;
public ?string $last_login_at = null;
/**
* {@inheritdoc}
*/
public function rules(): array
{
return [
[['id'], 'integer'],
[['email', 'username', 'status', 'created_at', 'last_login_at'], 'safe'],
];
}
/**
* Search users.
*/
public function search(array $params): ActiveDataProvider
{
$query = User::find();
$dataProvider = new ActiveDataProvider([
'query' => $query,
'sort' => [
'defaultOrder' => ['id' => SORT_DESC],
],
'pagination' => [
'pageSize' => 20,
],
]);
$this->load($params);
if (!$this->validate()) {
return $dataProvider;
}
$query->andFilterWhere([
'id' => $this->id,
'status' => $this->status,
]);
$query
->andFilterWhere(['like', 'email', $this->email])
->andFilterWhere(['like', 'username', $this->username]);
if (!empty($this->created_at)) {
$query->andFilterWhere(['DATE(created_at)' => $this->created_at]);
}
if (!empty($this->last_login_at)) {
$query->andFilterWhere(['DATE(last_login_at)' => $this->last_login_at]);
}
return $dataProvider;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models\query;
use cgsmith\user\models\Profile;
use yii\db\ActiveQuery;
/**
* Profile query class.
*
* @method Profile|null one($db = null)
* @method Profile[] all($db = null)
*/
class ProfileQuery extends ActiveQuery
{
/**
* Filter by user ID.
*/
public function byUserId(int $userId): static
{
return $this->andWhere(['user_id' => $userId]);
}
/**
* Filter profiles with avatar.
*/
public function withAvatar(): static
{
return $this->andWhere(['not', ['avatar_path' => null]]);
}
/**
* Filter profiles using gravatar.
*/
public function usingGravatar(): static
{
return $this->andWhere(['use_gravatar' => true]);
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models\query;
use cgsmith\user\models\Token;
use yii\db\ActiveQuery;
use yii\db\Expression;
/**
* Token query class.
*
* @method Token|null one($db = null)
* @method Token[] all($db = null)
*/
class TokenQuery extends ActiveQuery
{
/**
* Filter by token type.
*/
public function byType(string $type): static
{
return $this->andWhere(['type' => $type]);
}
/**
* Filter by user ID.
*/
public function byUserId(int $userId): static
{
return $this->andWhere(['user_id' => $userId]);
}
/**
* Filter by token string.
*/
public function byToken(string $token): static
{
return $this->andWhere(['token' => $token]);
}
/**
* Filter tokens that are not expired.
*/
public function notExpired(): static
{
return $this->andWhere(['>', 'expires_at', new Expression('NOW()')]);
}
/**
* Filter tokens that are expired.
*/
public function expired(): static
{
return $this->andWhere(['<', 'expires_at', new Expression('NOW()')]);
}
/**
* Filter confirmation tokens.
*/
public function confirmation(): static
{
return $this->byType(Token::TYPE_CONFIRMATION);
}
/**
* Filter recovery tokens.
*/
public function recovery(): static
{
return $this->byType(Token::TYPE_RECOVERY);
}
/**
* Filter email change tokens.
*/
public function emailChange(): static
{
return $this->byType(Token::TYPE_EMAIL_CHANGE);
}
}

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\models\query;
use cgsmith\user\models\User;
use yii\db\ActiveQuery;
/**
* User query class.
*
* @method User|null one($db = null)
* @method User[] all($db = null)
*/
class UserQuery extends ActiveQuery
{
/**
* Filter by active status (not blocked, not soft deleted).
*/
public function active(): static
{
return $this
->andWhere(['!=', 'status', User::STATUS_BLOCKED])
->andWhere(['gdpr_deleted_at' => null]);
}
/**
* Filter by confirmed users.
*/
public function confirmed(): static
{
return $this->andWhere(['not', ['email_confirmed_at' => null]]);
}
/**
* Filter by unconfirmed users.
*/
public function unconfirmed(): static
{
return $this->andWhere(['email_confirmed_at' => null]);
}
/**
* Filter by blocked users.
*/
public function blocked(): static
{
return $this->andWhere(['status' => User::STATUS_BLOCKED]);
}
/**
* Filter by pending users.
*/
public function pending(): static
{
return $this->andWhere(['status' => User::STATUS_PENDING]);
}
/**
* Filter users that can log in.
*/
public function canLogin(): static
{
return $this
->active()
->confirmed();
}
/**
* Filter by email.
*/
public function byEmail(string $email): static
{
return $this->andWhere(['email' => $email]);
}
/**
* Filter by username.
*/
public function byUsername(string $username): static
{
return $this->andWhere(['username' => $username]);
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\helpers\Url;
use yii\mail\MailerInterface;
/**
* Email service for user-related emails.
*/
class MailerService
{
public function __construct(
protected Module $module
) {}
/**
* Send welcome/confirmation email.
*/
public function sendWelcomeMessage(User $user, ?Token $token = null): bool
{
if ($token !== null) {
$url = Url::to(['/user/registration/confirm', 'id' => $user->id, 'token' => $token->token], true);
} else {
$url = null;
}
return $this->sendMessage(
$user->email,
Yii::t('user', 'Welcome to {app}', ['app' => Yii::$app->name]),
'welcome',
[
'user' => $user,
'token' => $token,
'url' => $url,
'module' => $this->module,
]
);
}
/**
* Send confirmation email.
*/
public function sendConfirmationMessage(User $user, Token $token): bool
{
$url = Url::to(['/user/registration/confirm', 'id' => $user->id, 'token' => $token->token], true);
return $this->sendMessage(
$user->email,
Yii::t('user', 'Confirm your email on {app}', ['app' => Yii::$app->name]),
'confirmation',
[
'user' => $user,
'token' => $token,
'url' => $url,
'module' => $this->module,
]
);
}
/**
* Send password recovery email.
*/
public function sendRecoveryMessage(User $user, Token $token): bool
{
$url = Url::to(['/user/recovery/reset', 'id' => $user->id, 'token' => $token->token], true);
return $this->sendMessage(
$user->email,
Yii::t('user', 'Password recovery on {app}', ['app' => Yii::$app->name]),
'recovery',
[
'user' => $user,
'token' => $token,
'url' => $url,
'module' => $this->module,
]
);
}
/**
* Send email change confirmation.
*/
public function sendEmailChangeMessage(User $user, Token $token, string $newEmail): bool
{
$url = Url::to(['/user/settings/confirm-email', 'id' => $user->id, 'token' => $token->token], true);
return $this->sendMessage(
$newEmail,
Yii::t('user', 'Confirm email change on {app}', ['app' => Yii::$app->name]),
'email_change',
[
'user' => $user,
'token' => $token,
'url' => $url,
'newEmail' => $newEmail,
'module' => $this->module,
]
);
}
/**
* Send generated password email.
*/
public function sendGeneratedPasswordMessage(User $user, string $password): bool
{
return $this->sendMessage(
$user->email,
Yii::t('user', 'Your account on {app}', ['app' => Yii::$app->name]),
'generated_password',
[
'user' => $user,
'password' => $password,
'module' => $this->module,
]
);
}
/**
* Send email message.
*/
protected function sendMessage(string $to, string $subject, string $view, array $params = []): bool
{
$mailer = $this->getMailer();
$sender = $this->module->getMailerSender();
$viewPath = $this->module->mailer['viewPath'] ?? '@cgsmith/user/views/mail';
$message = $mailer->compose([
'html' => "{$viewPath}/{$view}",
'text' => "{$viewPath}/{$view}-text",
], $params)
->setTo($to)
->setFrom($sender)
->setSubject($subject);
return $message->send();
}
/**
* Get the mailer component.
*/
protected function getMailer(): MailerInterface
{
$mailerId = $this->module->mailer['mailer'] ?? 'mailer';
return Yii::$app->get($mailerId);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\models\RecoveryForm;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
/**
* Password recovery service.
*/
class RecoveryService
{
public function __construct(
protected Module $module
) {}
/**
* Send recovery email.
*
* @return bool Always returns true to prevent email enumeration attacks
*/
public function sendRecoveryMessage(RecoveryForm $form): bool
{
if (!$form->validate()) {
return true; // Don't reveal validation errors for security
}
$user = $form->getUser();
if ($user === null || $user->getIsBlocked()) {
// User not found or blocked - return true to prevent enumeration
return true;
}
$tokenService = $this->getTokenService();
$token = $tokenService->createRecoveryToken($user);
if ($token === null) {
Yii::error('Failed to create recovery token for user: ' . $user->id, __METHOD__);
return true;
}
$mailer = $this->getMailerService();
$mailer->sendRecoveryMessage($user, $token);
return true;
}
/**
* Reset password with token.
*/
public function resetPassword(User $user, string $tokenString, string $password): bool
{
$tokenService = $this->getTokenService();
$token = $tokenService->findRecoveryToken($tokenString);
if ($token === null || $token->user_id !== $user->id) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
try {
// Reset password
if (!$user->resetPassword($password)) {
$transaction->rollBack();
return false;
}
// Delete token
$tokenService->deleteToken($token);
// Delete all other recovery tokens for this user
Token::deleteAllForUser($user->id, Token::TYPE_RECOVERY);
$transaction->commit();
return true;
} catch (\Exception $e) {
$transaction->rollBack();
Yii::error('Password reset failed: ' . $e->getMessage(), __METHOD__);
throw $e;
}
}
/**
* Validate recovery token.
*/
public function validateToken(User $user, string $tokenString): bool
{
$tokenService = $this->getTokenService();
$token = $tokenService->findRecoveryToken($tokenString);
return $token !== null && $token->user_id === $user->id;
}
/**
* Get mailer service.
*/
protected function getMailerService(): MailerService
{
return Yii::$container->get(MailerService::class);
}
/**
* Get token service.
*/
protected function getTokenService(): TokenService
{
return Yii::$container->get(TokenService::class);
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\events\RegistrationEvent;
use cgsmith\user\helpers\Password;
use cgsmith\user\models\RegistrationForm;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\db\Transaction;
/**
* Registration service.
*/
class RegistrationService
{
public const EVENT_BEFORE_REGISTER = 'beforeRegister';
public const EVENT_AFTER_REGISTER = 'afterRegister';
public const EVENT_BEFORE_CONFIRM = 'beforeConfirm';
public const EVENT_AFTER_CONFIRM = 'afterConfirm';
public function __construct(
protected Module $module
) {}
/**
* Register a new user.
*/
public function register(RegistrationForm $form): ?User
{
if (!$form->validate()) {
return null;
}
$transaction = Yii::$app->db->beginTransaction(Transaction::SERIALIZABLE);
try {
$user = new User();
$user->email = $form->email;
$user->username = $form->username;
// Handle password
if ($this->module->enableGeneratedPassword) {
$password = Password::generate();
$user->password = $password;
} else {
$user->password = $form->password;
}
// Set confirmation status
if (!$this->module->enableConfirmation) {
$user->status = User::STATUS_ACTIVE;
$user->email_confirmed_at = date('Y-m-d H:i:s');
}
// Trigger before event
$event = new RegistrationEvent(['user' => $user, 'form' => $form]);
$this->module->trigger(self::EVENT_BEFORE_REGISTER, $event);
if (!$user->save()) {
$transaction->rollBack();
Yii::error('Failed to save user: ' . json_encode($user->errors), __METHOD__);
return null;
}
// Create confirmation token if needed
$token = null;
if ($this->module->enableConfirmation) {
$token = Token::createConfirmationToken($user);
if (!$token->save()) {
$transaction->rollBack();
Yii::error('Failed to save confirmation token: ' . json_encode($token->errors), __METHOD__);
return null;
}
}
// Send welcome email
$mailer = $this->getMailerService();
if ($this->module->enableGeneratedPassword) {
$mailer->sendGeneratedPasswordMessage($user, $password);
} else {
$mailer->sendWelcomeMessage($user, $token);
}
// Trigger after event
$event = new RegistrationEvent(['user' => $user, 'form' => $form, 'token' => $token]);
$this->module->trigger(self::EVENT_AFTER_REGISTER, $event);
$transaction->commit();
return $user;
} catch (\Exception $e) {
$transaction->rollBack();
Yii::error('Registration failed: ' . $e->getMessage(), __METHOD__);
throw $e;
}
}
/**
* Confirm user email with token.
*/
public function confirm(User $user, string $tokenString): bool
{
$tokenService = $this->getTokenService();
$token = $tokenService->findConfirmationToken($tokenString);
if ($token === null || $token->user_id !== $user->id) {
return false;
}
$transaction = Yii::$app->db->beginTransaction();
try {
// Trigger before event
$event = new RegistrationEvent(['user' => $user, 'token' => $token]);
$this->module->trigger(self::EVENT_BEFORE_CONFIRM, $event);
// Confirm user
if (!$user->confirm()) {
$transaction->rollBack();
return false;
}
// Delete token
$tokenService->deleteToken($token);
// Trigger after event
$event = new RegistrationEvent(['user' => $user]);
$this->module->trigger(self::EVENT_AFTER_CONFIRM, $event);
$transaction->commit();
return true;
} catch (\Exception $e) {
$transaction->rollBack();
Yii::error('Confirmation failed: ' . $e->getMessage(), __METHOD__);
throw $e;
}
}
/**
* Resend confirmation email.
*/
public function resendConfirmation(User $user): bool
{
if ($user->getIsConfirmed()) {
return false;
}
$tokenService = $this->getTokenService();
$token = $tokenService->createConfirmationToken($user);
if ($token === null) {
return false;
}
$mailer = $this->getMailerService();
return $mailer->sendConfirmationMessage($user, $token);
}
/**
* Get mailer service.
*/
protected function getMailerService(): MailerService
{
return Yii::$container->get(MailerService::class);
}
/**
* Get token service.
*/
protected function getTokenService(): TokenService
{
return Yii::$container->get(TokenService::class);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\models\Token;
use cgsmith\user\models\User;
use cgsmith\user\Module;
/**
* Token management service.
*/
class TokenService
{
public function __construct(
protected Module $module
) {}
/**
* Create a confirmation token.
*/
public function createConfirmationToken(User $user): ?Token
{
// Delete existing confirmation tokens
Token::deleteAllForUser($user->id, Token::TYPE_CONFIRMATION);
$token = Token::createConfirmationToken($user);
return $token->save() ? $token : null;
}
/**
* Create a recovery token.
*/
public function createRecoveryToken(User $user): ?Token
{
// Delete existing recovery tokens
Token::deleteAllForUser($user->id, Token::TYPE_RECOVERY);
$token = Token::createRecoveryToken($user);
return $token->save() ? $token : null;
}
/**
* Create an email change token.
*/
public function createEmailChangeToken(User $user, string $newEmail): ?Token
{
// Delete existing email change tokens
Token::deleteAllForUser($user->id, Token::TYPE_EMAIL_CHANGE);
$token = Token::createEmailChangeToken($user, $newEmail);
return $token->save() ? $token : null;
}
/**
* Find and validate a confirmation token.
*/
public function findConfirmationToken(string $tokenString): ?Token
{
return Token::findByToken($tokenString, Token::TYPE_CONFIRMATION);
}
/**
* Find and validate a recovery token.
*/
public function findRecoveryToken(string $tokenString): ?Token
{
return Token::findByToken($tokenString, Token::TYPE_RECOVERY);
}
/**
* Find and validate an email change token.
*/
public function findEmailChangeToken(string $tokenString): ?Token
{
return Token::findByToken($tokenString, Token::TYPE_EMAIL_CHANGE);
}
/**
* Delete a token.
*/
public function deleteToken(Token $token): bool
{
return $token->delete() !== false;
}
/**
* Delete all tokens for a user.
*/
public function deleteAllUserTokens(User $user): int
{
return Token::deleteAllForUser($user->id);
}
/**
* Cleanup expired tokens.
*/
public function cleanupExpiredTokens(): int
{
return Token::deleteExpired();
}
}

View File

@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace cgsmith\user\services;
use cgsmith\user\controllers\AdminController;
use cgsmith\user\helpers\Password;
use cgsmith\user\models\User;
use cgsmith\user\Module;
use Yii;
use yii\base\InvalidCallException;
/**
* User management service.
*/
class UserService
{
public function __construct(
protected Module $module
) {}
/**
* Create a new user (admin creation).
*/
public function create(string $email, string $password, bool $confirmed = true): ?User
{
$user = new User();
$user->email = $email;
$user->password = $password;
if ($confirmed) {
$user->status = User::STATUS_ACTIVE;
$user->email_confirmed_at = date('Y-m-d H:i:s');
}
if (!$user->save()) {
Yii::error('Failed to create user: ' . json_encode($user->errors), __METHOD__);
return null;
}
return $user;
}
/**
* Update user.
*/
public function update(User $user, array $attributes): bool
{
$user->setAttributes($attributes);
if (!$user->save()) {
Yii::error('Failed to update user: ' . json_encode($user->errors), __METHOD__);
return false;
}
return true;
}
/**
* Delete user.
*/
public function delete(User $user): bool
{
return $user->delete() !== false;
}
/**
* Block user.
*/
public function block(User $user): bool
{
return $user->block();
}
/**
* Unblock user.
*/
public function unblock(User $user): bool
{
return $user->unblock();
}
/**
* Confirm user email.
*/
public function confirm(User $user): bool
{
return $user->confirm();
}
/**
* Reset user password.
*/
public function resetPassword(User $user, string $password): bool
{
return $user->resetPassword($password);
}
/**
* Generate a new password and send it to the user.
*
* @throws InvalidCallException if user is an admin
*/
public function resendPassword(User $user, MailerService $mailer): bool
{
if ($user->getIsAdmin()) {
throw new InvalidCallException(Yii::t('user', 'Password generation is not allowed for admin users.'));
}
$password = Password::generate($this->module->minPasswordLength);
if (!$user->resetPassword($password)) {
return false;
}
return $mailer->sendGeneratedPasswordMessage($user, $password);
}
/**
* Find user by ID.
*/
public function findById(int $id): ?User
{
return User::findOne($id);
}
/**
* Find user by email.
*/
public function findByEmail(string $email): ?User
{
return User::findByEmail($email);
}
/**
* Find user by username.
*/
public function findByUsername(string $username): ?User
{
return User::findByUsername($username);
}
/**
* Check if user can be impersonated by current user.
*/
public function canImpersonate(User $targetUser): bool
{
if (!$this->module->enableImpersonation) {
return false;
}
$currentUser = Yii::$app->user->identity;
if (!$currentUser instanceof User) {
return false;
}
// Can't impersonate yourself
if ($currentUser->id === $targetUser->id) {
return false;
}
// Check admin permission
if (!$currentUser->getIsAdmin()) {
return false;
}
// Check impersonate permission if configured
if ($this->module->impersonatePermission !== null && Yii::$app->authManager !== null) {
return Yii::$app->authManager->checkAccess($currentUser->id, $this->module->impersonatePermission);
}
return true;
}
/**
* Impersonate a user.
*
* @return string|null Previous user auth key for reverting, or null on failure
*/
public function impersonate(User $targetUser): ?string
{
if (!$this->canImpersonate($targetUser)) {
return null;
}
$currentUser = Yii::$app->user->identity;
$previousAuthKey = $currentUser->auth_key;
// Store original user for reverting
Yii::$app->session->set(AdminController::ORIGINAL_USER_SESSION_KEY, $currentUser->id);
// Login as target user
Yii::$app->user->login($targetUser);
return $previousAuthKey;
}
/**
* Stop impersonating and return to original user.
*/
public function stopImpersonation(): bool
{
$originalUserId = Yii::$app->session->get(AdminController::ORIGINAL_USER_SESSION_KEY);
if ($originalUserId === null) {
return false;
}
$originalUser = $this->findById($originalUserId);
if ($originalUser === null) {
return false;
}
Yii::$app->session->remove(AdminController::ORIGINAL_USER_SESSION_KEY);
Yii::$app->user->login($originalUser);
return true;
}
/**
* Check if current user is impersonating.
*/
public function isImpersonating(): bool
{
return Yii::$app->session->has(AdminController::ORIGINAL_USER_SESSION_KEY);
}
}

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

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

View File

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

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

@@ -0,0 +1,130 @@
<?php
/**
* @var yii\web\View $this
* @var cgsmith\user\models\UserSearch $searchModel
* @var yii\data\ActiveDataProvider $dataProvider
* @var cgsmith\user\Module $module
*/
use cgsmith\user\models\User;
use yii\helpers\Html;
use yii\grid\GridView;
$this->title = Yii::t('user', 'Manage Users');
$this->params['breadcrumbs'][] = $this->title;
?>
<?= $this->render('/_alert', ['module' => Yii::$app->getModule('user')]) ?>
<?= $this->render('_menu') ?>
<div class="user-admin-index">
<div class="user-admin-header">
<h1><?= Html::encode($this->title) ?></h1>
<?= Html::a(Yii::t('user', 'Create User'), ['create'], ['class' => 'user-btn user-btn-primary']) ?>
</div>
<div class="user-card">
<div class="user-card-body">
<?= GridView::widget([
'dataProvider' => $dataProvider,
'filterModel' => $searchModel,
'tableOptions' => ['class' => 'user-table'],
'columns' => [
'id',
'email:email',
'username',
[
'attribute' => 'status',
'format' => 'raw',
'filter' => [
User::STATUS_PENDING => Yii::t('user', 'Pending'),
User::STATUS_ACTIVE => Yii::t('user', 'Active'),
User::STATUS_BLOCKED => Yii::t('user', 'Blocked'),
],
'value' => function (User $model) {
$classes = [
User::STATUS_PENDING => 'user-badge user-badge-warning',
User::STATUS_ACTIVE => 'user-badge user-badge-success',
User::STATUS_BLOCKED => 'user-badge user-badge-danger',
];
$class = $classes[$model->status] ?? 'user-badge';
return Html::tag('span', Html::encode($model->status), ['class' => $class]);
},
],
[
'attribute' => 'email_confirmed_at',
'format' => 'raw',
'value' => function (User $model) {
if ($model->getIsConfirmed()) {
return '<span class="user-badge user-badge-success">' . Yii::t('user', 'Confirmed') . '</span>';
}
return '<span class="user-badge user-badge-warning">' . Yii::t('user', 'Unconfirmed') . '</span>';
},
],
'created_at:datetime',
'last_login_at:datetime',
[
'class' => 'yii\grid\ActionColumn',
'template' => '{update} {block} {confirm} {impersonate} {delete}',
'buttons' => [
'block' => function ($url, User $model) use ($module) {
if ($model->id === Yii::$app->user->id) {
return '';
}
if ($model->getIsBlocked()) {
return Html::a(Yii::t('user', 'Unblock'), ['unblock', 'id' => $model->id], [
'class' => 'user-btn user-btn-sm user-btn-success',
'title' => Yii::t('user', 'Unblock'),
'data' => ['method' => 'post', 'confirm' => Yii::t('user', 'Are you sure you want to unblock this user?')],
]);
}
return Html::a(Yii::t('user', 'Block'), ['block', 'id' => $model->id], [
'class' => 'user-btn user-btn-sm user-btn-warning',
'title' => Yii::t('user', 'Block'),
'data' => ['method' => 'post', 'confirm' => Yii::t('user', 'Are you sure you want to block this user?')],
]);
},
'confirm' => function ($url, User $model) {
if ($model->getIsConfirmed()) {
return '';
}
return Html::a(Yii::t('user', 'Confirm'), ['confirm', 'id' => $model->id], [
'class' => 'user-btn user-btn-sm user-btn-info',
'title' => Yii::t('user', 'Confirm'),
'data' => ['method' => 'post'],
]);
},
'impersonate' => function ($url, User $model) use ($module) {
if (!$module->enableImpersonation || $model->id === Yii::$app->user->id) {
return '';
}
return Html::a(Yii::t('user', 'Impersonate'), ['impersonate', 'id' => $model->id], [
'class' => 'user-btn user-btn-sm user-btn-secondary',
'title' => Yii::t('user', 'Impersonate'),
]);
},
'update' => function ($url, User $model) {
return Html::a(Yii::t('user', 'Update'), ['update', 'id' => $model->id], [
'class' => 'user-btn user-btn-sm user-btn-primary',
'title' => Yii::t('user', 'Update'),
]);
},
'delete' => function ($url, User $model) {
if ($model->id === Yii::$app->user->id) {
return '';
}
return Html::a(Yii::t('user', 'Delete'), ['delete', 'id' => $model->id], [
'class' => 'user-btn user-btn-sm user-btn-danger',
'title' => Yii::t('user', 'Delete'),
'data' => ['method' => 'post', 'confirm' => Yii::t('user', 'Are you sure you want to delete this user?')],
]);
},
],
],
],
]); ?>
</div>
</div>
</div>

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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