Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f8ad86b4f |
@@ -5,6 +5,7 @@ namespace App\Controllers;
|
||||
use App\Models\LoginActivityModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\UserRoleModel;
|
||||
use App\Models\UserAccessProfileModel;
|
||||
use CodeIgniter\Events\Events;
|
||||
use App\Models\IpAttemptModel;
|
||||
use App\Models\PasswordResetModel;
|
||||
@@ -212,6 +213,7 @@ class AuthController extends BaseController
|
||||
|
||||
// Fetch roles
|
||||
$roleNames = $this->getUserRoleNames((int) $user['id']);
|
||||
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||
|
||||
// Build roles map (object with keys per example)
|
||||
$rolesMap = [];
|
||||
@@ -248,6 +250,10 @@ class AuthController extends BaseController
|
||||
'id' => (int) $user['id'],
|
||||
'name' => $payload['name'],
|
||||
'roles' => (object) $rolesMap,
|
||||
'primary_category' => $accessProfile['primary_category'],
|
||||
'is_admin' => $accessProfile['is_admin'],
|
||||
'is_teacher' => $accessProfile['is_teacher'],
|
||||
'is_parent' => $accessProfile['is_parent'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -274,6 +280,10 @@ class AuthController extends BaseController
|
||||
'type' => session()->get('user_type'),
|
||||
'roles' => $roles,
|
||||
'role' => $activeRole,
|
||||
'primary_category' => session()->get('primary_category'),
|
||||
'is_admin' => (bool) session()->get('is_admin'),
|
||||
'is_teacher' => (bool) session()->get('is_teacher'),
|
||||
'is_parent' => (bool) session()->get('is_parent'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -371,6 +381,7 @@ class AuthController extends BaseController
|
||||
'iat' => $now,
|
||||
'exp' => $exp,
|
||||
];
|
||||
$accessProfile = $this->accessProfileForUser((int) $userId, $payload['roles']);
|
||||
|
||||
$secret = require_env('JWT_SECRET');
|
||||
$token = jwt_encode($payload, $secret, 'HS256');
|
||||
@@ -384,6 +395,10 @@ class AuthController extends BaseController
|
||||
'name' => $payload['name'],
|
||||
'email' => $userData['email'],
|
||||
'roles' => $payload['roles'],
|
||||
'primary_category' => $accessProfile['primary_category'],
|
||||
'is_admin' => $accessProfile['is_admin'],
|
||||
'is_teacher' => $accessProfile['is_teacher'],
|
||||
'is_parent' => $accessProfile['is_parent'],
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
@@ -477,11 +492,17 @@ class AuthController extends BaseController
|
||||
protected function getUserRoleNames(int $userId): array
|
||||
{
|
||||
$userRoleModel = new UserRoleModel();
|
||||
$rolesRows = $userRoleModel->select('roles.name')
|
||||
$db = \Config\Database::connect();
|
||||
$builder = $userRoleModel->select('roles.name')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('user_roles.user_id', $userId)
|
||||
->get()
|
||||
->getResultArray();
|
||||
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||
|
||||
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||
$builder->where('user_roles.deleted_at', null);
|
||||
}
|
||||
|
||||
$rolesRows = $builder->get()->getResultArray();
|
||||
|
||||
return array_column($rolesRows, 'name');
|
||||
}
|
||||
@@ -565,11 +586,17 @@ class AuthController extends BaseController
|
||||
private function loginUser($user, ?string $redirectTo = null)
|
||||
{
|
||||
$userRoleModel = new UserRoleModel();
|
||||
$roles = $userRoleModel->select('roles.name')
|
||||
$db = \Config\Database::connect();
|
||||
$rolesBuilder = $userRoleModel->select('roles.name')
|
||||
->join('roles', 'roles.id = user_roles.role_id')
|
||||
->where('user_roles.user_id', $user['id'])
|
||||
->get()
|
||||
->getResultArray();
|
||||
->where('COALESCE(roles.is_active, 1) = 1', null, false);
|
||||
|
||||
if ($db->fieldExists('deleted_at', 'user_roles')) {
|
||||
$rolesBuilder->where('user_roles.deleted_at', null);
|
||||
}
|
||||
|
||||
$roles = $rolesBuilder->get()->getResultArray();
|
||||
|
||||
if (empty($roles)) {
|
||||
log_message('error', 'No roles found for user ID: ' . $user['id']);
|
||||
@@ -577,6 +604,7 @@ class AuthController extends BaseController
|
||||
}
|
||||
|
||||
$roleNames = array_column($roles, 'name');
|
||||
$accessProfile = $this->accessProfileForUser((int) $user['id'], $roleNames);
|
||||
|
||||
session()->regenerate(true);
|
||||
session()->set([
|
||||
@@ -588,6 +616,10 @@ class AuthController extends BaseController
|
||||
'login_time' => time(),
|
||||
'last_activity' => time(),
|
||||
'roles' => $roleNames,
|
||||
'primary_category' => $accessProfile['primary_category'],
|
||||
'is_admin' => $accessProfile['is_admin'],
|
||||
'is_teacher' => $accessProfile['is_teacher'],
|
||||
'is_parent' => $accessProfile['is_parent'],
|
||||
'semester' => $this->semester,
|
||||
'school_year' => $this->schoolYear,
|
||||
]);
|
||||
@@ -612,6 +644,32 @@ class AuthController extends BaseController
|
||||
|
||||
}
|
||||
|
||||
private function accessProfileForUser(int $userId, array $roleNames): array
|
||||
{
|
||||
try {
|
||||
$profile = model(UserAccessProfileModel::class)->getForUser($userId);
|
||||
if ($profile !== null) {
|
||||
return [
|
||||
'primary_category' => (string) ($profile['primary_category'] ?? UserAccessProfileModel::CATEGORY_GUEST),
|
||||
'is_admin' => (bool) ($profile['is_admin'] ?? false),
|
||||
'is_teacher' => (bool) ($profile['is_teacher'] ?? false),
|
||||
'is_parent' => (bool) ($profile['is_parent'] ?? false),
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Unable to load user access profile: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$flags = UserAccessProfileModel::flagsForRoles($roleNames);
|
||||
|
||||
return [
|
||||
'primary_category' => UserAccessProfileModel::primaryCategory($flags),
|
||||
'is_admin' => (bool) $flags['is_admin'],
|
||||
'is_teacher' => (bool) $flags['is_teacher'],
|
||||
'is_parent' => (bool) $flags['is_parent'],
|
||||
];
|
||||
}
|
||||
|
||||
private function applyStylePreferences(int $userId): void
|
||||
{
|
||||
if (!$userId) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
final class CreateUserAccessProfiles extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->db->tableExists('user_access_profiles')) {
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'user_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
],
|
||||
'primary_category' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 20,
|
||||
'default' => 'guest',
|
||||
],
|
||||
'is_admin' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
],
|
||||
'is_teacher' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
],
|
||||
'is_parent' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'default' => 0,
|
||||
],
|
||||
'role_names' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('user_id', false, true);
|
||||
$this->forge->addKey('primary_category');
|
||||
$this->forge->addKey('is_admin');
|
||||
$this->forge->addKey('is_teacher');
|
||||
$this->forge->addKey('is_parent');
|
||||
$this->forge->createTable('user_access_profiles', true);
|
||||
}
|
||||
|
||||
$this->backfillProfiles();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->forge->dropTable('user_access_profiles', true);
|
||||
}
|
||||
|
||||
private function backfillProfiles(): void
|
||||
{
|
||||
if (
|
||||
! $this->db->tableExists('users')
|
||||
|| ! $this->db->tableExists('roles')
|
||||
|| ! $this->db->tableExists('user_roles')
|
||||
|| ! $this->db->tableExists('user_access_profiles')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$deletedFilter = $this->db->fieldExists('deleted_at', 'user_roles')
|
||||
? 'AND ur.deleted_at IS NULL'
|
||||
: '';
|
||||
|
||||
$sql = "
|
||||
INSERT INTO user_access_profiles (
|
||||
user_id,
|
||||
primary_category,
|
||||
is_admin,
|
||||
is_teacher,
|
||||
is_parent,
|
||||
role_names,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
CASE
|
||||
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'admin'
|
||||
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) = 1 THEN 'teacher'
|
||||
WHEN MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) = 1 THEN 'parent'
|
||||
ELSE 'guest'
|
||||
END AS primary_category,
|
||||
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) NOT IN ('guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_admin,
|
||||
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) IN ('teacher', 'teacher_assistant', 'assistant_teacher', 'ta') THEN 1 ELSE 0 END) AS is_teacher,
|
||||
MAX(CASE WHEN LOWER(REPLACE(REPLACE(COALESCE(r.slug, r.name), ' ', '_'), '-', '_')) = 'parent' THEN 1 ELSE 0 END) AS is_parent,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY COALESCE(r.priority, 999), r.name SEPARATOR ', ') AS role_names,
|
||||
? AS created_at,
|
||||
? AS updated_at
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id {$deletedFilter}
|
||||
LEFT JOIN roles r ON r.id = ur.role_id AND COALESCE(r.is_active, 1) = 1
|
||||
GROUP BY u.id
|
||||
ON DUPLICATE KEY UPDATE
|
||||
primary_category = VALUES(primary_category),
|
||||
is_admin = VALUES(is_admin),
|
||||
is_teacher = VALUES(is_teacher),
|
||||
is_parent = VALUES(is_parent),
|
||||
role_names = VALUES(role_names),
|
||||
updated_at = VALUES(updated_at)
|
||||
";
|
||||
|
||||
$this->db->query($sql, [$now, $now]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class UserAccessProfileModel extends Model
|
||||
{
|
||||
public const CATEGORY_ADMIN = 'admin';
|
||||
public const CATEGORY_TEACHER = 'teacher';
|
||||
public const CATEGORY_PARENT = 'parent';
|
||||
public const CATEGORY_GUEST = 'guest';
|
||||
|
||||
private const TEACHER_ROLE_TOKENS = ['teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
|
||||
private const NON_ADMIN_ROLE_TOKENS = ['guest', 'parent', 'student', 'teacher', 'teacher_assistant', 'teacher assistant', 'assistant_teacher', 'ta'];
|
||||
|
||||
protected $table = 'user_access_profiles';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $allowedFields = [
|
||||
'user_id',
|
||||
'primary_category',
|
||||
'is_admin',
|
||||
'is_teacher',
|
||||
'is_parent',
|
||||
'role_names',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
protected $useTimestamps = true;
|
||||
protected $createdField = 'created_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
|
||||
public function syncUser(int $userId): ?array
|
||||
{
|
||||
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$roles = $this->rolesForUser($userId);
|
||||
$flags = self::flagsForRoles($roles);
|
||||
$now = utc_now();
|
||||
|
||||
$data = [
|
||||
'user_id' => $userId,
|
||||
'primary_category' => self::primaryCategory($flags),
|
||||
'is_admin' => $flags['is_admin'] ? 1 : 0,
|
||||
'is_teacher' => $flags['is_teacher'] ? 1 : 0,
|
||||
'is_parent' => $flags['is_parent'] ? 1 : 0,
|
||||
'role_names' => implode(', ', array_values(array_unique(array_map(
|
||||
static fn (array $role): string => (string) ($role['name'] ?? ''),
|
||||
$roles
|
||||
)))),
|
||||
'updated_at' => $now,
|
||||
];
|
||||
|
||||
$existing = $this->where('user_id', $userId)->first();
|
||||
if ($existing) {
|
||||
$this->update((int) $existing['id'], $data);
|
||||
return $this->find((int) $existing['id']);
|
||||
}
|
||||
|
||||
$data['created_at'] = $now;
|
||||
$id = $this->insert($data);
|
||||
|
||||
return $id ? $this->find((int) $id) : null;
|
||||
}
|
||||
|
||||
public function syncAll(): void
|
||||
{
|
||||
if (! $this->db->tableExists('users') || ! $this->db->tableExists($this->table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = $this->db->table('users')->select('id')->get()->getResultArray();
|
||||
foreach ($rows as $row) {
|
||||
$this->syncUser((int) ($row['id'] ?? 0));
|
||||
}
|
||||
}
|
||||
|
||||
public function getForUser(int $userId): ?array
|
||||
{
|
||||
if ($userId <= 0 || ! $this->db->tableExists($this->table)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$profile = $this->where('user_id', $userId)->first();
|
||||
|
||||
return $profile ?: $this->syncUser($userId);
|
||||
}
|
||||
|
||||
public function getUsersByCategory(string $category): array
|
||||
{
|
||||
$field = match (self::normalizeRoleToken($category)) {
|
||||
self::CATEGORY_ADMIN => 'is_admin',
|
||||
self::CATEGORY_TEACHER => 'is_teacher',
|
||||
self::CATEGORY_PARENT => 'is_parent',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($field === null || ! $this->db->tableExists($this->table)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->select('users.*, user_access_profiles.primary_category, user_access_profiles.role_names')
|
||||
->join('users', 'users.id = user_access_profiles.user_id', 'inner')
|
||||
->where($field, 1)
|
||||
->orderBy('users.lastname', 'ASC')
|
||||
->orderBy('users.firstname', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
public static function flagsForRoles(array $roles): array
|
||||
{
|
||||
$tokens = [];
|
||||
foreach ($roles as $role) {
|
||||
if (is_array($role)) {
|
||||
$tokens[] = self::normalizeRoleToken((string) ($role['slug'] ?? ''));
|
||||
$tokens[] = self::normalizeRoleToken((string) ($role['name'] ?? ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
$tokens[] = self::normalizeRoleToken((string) $role);
|
||||
}
|
||||
|
||||
$tokens = array_values(array_unique(array_filter($tokens)));
|
||||
$isParent = in_array('parent', $tokens, true);
|
||||
$isTeacher = count(array_intersect($tokens, array_map([self::class, 'normalizeRoleToken'], self::TEACHER_ROLE_TOKENS))) > 0;
|
||||
$isAdmin = false;
|
||||
|
||||
foreach ($tokens as $token) {
|
||||
if (! in_array($token, array_map([self::class, 'normalizeRoleToken'], self::NON_ADMIN_ROLE_TOKENS), true)) {
|
||||
$isAdmin = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'is_admin' => $isAdmin,
|
||||
'is_teacher' => $isTeacher,
|
||||
'is_parent' => $isParent,
|
||||
];
|
||||
}
|
||||
|
||||
public static function primaryCategory(array $flags): string
|
||||
{
|
||||
if (! empty($flags['is_admin'])) {
|
||||
return self::CATEGORY_ADMIN;
|
||||
}
|
||||
|
||||
if (! empty($flags['is_teacher'])) {
|
||||
return self::CATEGORY_TEACHER;
|
||||
}
|
||||
|
||||
if (! empty($flags['is_parent'])) {
|
||||
return self::CATEGORY_PARENT;
|
||||
}
|
||||
|
||||
return self::CATEGORY_GUEST;
|
||||
}
|
||||
|
||||
private function rolesForUser(int $userId): array
|
||||
{
|
||||
if (! $this->db->tableExists('user_roles') || ! $this->db->tableExists('roles')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$builder = $this->db->table('user_roles ur')
|
||||
->select('r.name, r.slug')
|
||||
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||
->where('ur.user_id', $userId)
|
||||
->where('COALESCE(r.is_active, 1) = 1', null, false);
|
||||
|
||||
if ($this->db->fieldExists('deleted_at', 'user_roles')) {
|
||||
$builder->where('ur.deleted_at', null);
|
||||
}
|
||||
|
||||
return $builder->get()->getResultArray();
|
||||
}
|
||||
|
||||
private static function normalizeRoleToken(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value));
|
||||
return str_replace([' ', '-'], '_', $value);
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,10 @@ class UserRoleModel extends Model
|
||||
$userId = (int) ($data['data']['user_id'] ?? 0);
|
||||
if ($userId > 0) {
|
||||
service('staffDirectorySync')->syncUser($userId);
|
||||
model(UserAccessProfileModel::class)->syncUser($userId);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'UserRoleModel staff directory sync failed: ' . $e->getMessage());
|
||||
log_message('error', 'UserRoleModel role-derived sync failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $data;
|
||||
@@ -45,8 +46,9 @@ class UserRoleModel extends Model
|
||||
{
|
||||
try {
|
||||
service('staffDirectorySync')->syncAll();
|
||||
model(UserAccessProfileModel::class)->syncAll();
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'UserRoleModel staff directory delete sync failed: ' . $e->getMessage());
|
||||
log_message('error', 'UserRoleModel role-derived delete sync failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return $data;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Models;
|
||||
|
||||
use App\Models\UserAccessProfileModel;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
final class UserAccessProfileModelTest extends CIUnitTestCase
|
||||
{
|
||||
public function testTeacherAssistantCountsAsTeacher(): void
|
||||
{
|
||||
$flags = UserAccessProfileModel::flagsForRoles([
|
||||
['name' => 'teacher_assistant', 'slug' => 'teacher_assistant'],
|
||||
]);
|
||||
|
||||
$this->assertFalse($flags['is_admin']);
|
||||
$this->assertTrue($flags['is_teacher']);
|
||||
$this->assertFalse($flags['is_parent']);
|
||||
$this->assertSame('teacher', UserAccessProfileModel::primaryCategory($flags));
|
||||
}
|
||||
|
||||
public function testAnyNonTeacherStaffRoleCountsAsAdmin(): void
|
||||
{
|
||||
$flags = UserAccessProfileModel::flagsForRoles([
|
||||
['name' => 'head of fa', 'slug' => 'head_of_fa'],
|
||||
]);
|
||||
|
||||
$this->assertTrue($flags['is_admin']);
|
||||
$this->assertFalse($flags['is_teacher']);
|
||||
$this->assertFalse($flags['is_parent']);
|
||||
$this->assertSame('admin', UserAccessProfileModel::primaryCategory($flags));
|
||||
}
|
||||
|
||||
public function testMultiRoleUserKeepsAllAccessFlags(): void
|
||||
{
|
||||
$flags = UserAccessProfileModel::flagsForRoles(['parent', 'teacher']);
|
||||
|
||||
$this->assertFalse($flags['is_admin']);
|
||||
$this->assertTrue($flags['is_teacher']);
|
||||
$this->assertTrue($flags['is_parent']);
|
||||
$this->assertSame('teacher', UserAccessProfileModel::primaryCategory($flags));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user