Files
crmPRO/docs/plans/2026-02-26-permissions-design.md
Jacek Pyziak a4a35c8d62 feat: Implement module permissions system with database-driven access control
- Added `users_permissions` table for managing user permissions.
- Created `PermissionRepository` for handling permission logic.
- Refactored `controls\Users::permissions()` to utilize the new database structure.
- Introduced AJAX endpoint for saving user permissions.
- Enhanced user management UI with permission checkboxes.
- Added vacation management template for handling employee absences.
- Implemented tests for `PermissionRepository`.
2026-02-26 20:17:03 +01:00

14 KiB

Module Permissions Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace hardcoded user permissions with a database-driven module access system managed through the admin UI.

Architecture: New users_permissions table with boolean columns per module. PermissionRepository reads/writes permissions. Existing controls\Users::permissions() method is refactored to query DB instead of hardcoded array. Admin UI gets inline checkboxes on the user list with AJAX save.

Tech Stack: PHP 7.4, MySQL (Medoo query builder), jQuery AJAX, Bootstrap 3


Task 1: Database migration — create users_permissions table

Files:

  • Modify: index.php:58-64 (add migration block)

Step 1: Add auto-migration to index.php

In index.php, there is already a migration pattern at lines 58-64 (checking for remember_token column). Add a second migration block right after it (inside the same if ( empty( $_SESSION['_db_migrated'] ) ) check) that creates the users_permissions table if it doesn't exist:

$tbl = $mdb -> query( "SHOW TABLES LIKE 'users_permissions'" ) -> fetch();
if ( !$tbl )
{
  $mdb -> pdo -> exec( "
    CREATE TABLE `users_permissions` (
      `user_id` INT UNSIGNED NOT NULL PRIMARY KEY,
      `tasks` TINYINT(1) NOT NULL DEFAULT 1,
      `projects` TINYINT(1) NOT NULL DEFAULT 1,
      `finances` TINYINT(1) NOT NULL DEFAULT 0,
      `wiki` TINYINT(1) NOT NULL DEFAULT 1,
      `crm` TINYINT(1) NOT NULL DEFAULT 0,
      `work_time` TINYINT(1) NOT NULL DEFAULT 1,
      `zaplecze` TINYINT(1) NOT NULL DEFAULT 0
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8
  " );
}

Note: No FOREIGN KEY constraint since we don't know if the users.id column is INT UNSIGNED. The application handles integrity.

Step 2: Commit

feat: Add users_permissions table auto-migration

Task 2: Create PermissionRepository

Files:

  • Create: autoload/Domain/Users/PermissionRepository.php

Step 1: Write the test

Create tests/Domain/Users/PermissionRepositoryTest.php:

<?php

require_once __DIR__ . '/../../../autoload/Domain/Users/PermissionRepository.php';

use Domain\Users\PermissionRepository;

function assert_perm( $expected, $actual, $message )
{
  if ( $expected !== $actual )
    throw new Exception( $message . " (expected " . var_export( $expected, true ) . ", got " . var_export( $actual, true ) . ")" );
}

function run_permission_repository_tests()
{
  // Test MODULES constant
  $modules = PermissionRepository::MODULES;
  assert_perm( true, in_array( 'tasks', $modules ), 'MODULES should contain tasks' );
  assert_perm( true, in_array( 'finances', $modules ), 'MODULES should contain finances' );
  assert_perm( 7, count( $modules ), 'MODULES should have 7 entries' );

  // Test DEFAULTS constant
  $defaults = PermissionRepository::DEFAULTS;
  assert_perm( 1, $defaults['tasks'], 'tasks should default to 1' );
  assert_perm( 0, $defaults['finances'], 'finances should default to 0' );
  assert_perm( 0, $defaults['crm'], 'crm should default to 0' );

  // Test defaults() returns full module array
  $result = PermissionRepository::defaults();
  assert_perm( 7, count( $result ), 'defaults() should return 7 modules' );
  assert_perm( 1, $result['tasks'], 'defaults() tasks should be 1' );
  assert_perm( 0, $result['zaplecze'], 'defaults() zaplecze should be 0' );
}

Step 2: Register test in run.php

Add to tests/run.php:

require_once __DIR__ . '/Domain/Users/PermissionRepositoryTest.php';

And add 'run_permission_repository_tests' to the $tests array.

Step 3: Run test to verify it fails

Run: php tests/run.php Expected: FAIL (class not found)

Step 4: Write PermissionRepository

Create autoload/Domain/Users/PermissionRepository.php:

<?php
namespace Domain\Users;

class PermissionRepository
{
  const MODULES = [ 'tasks', 'projects', 'finances', 'wiki', 'crm', 'work_time', 'zaplecze' ];

  const DEFAULTS = [
    'tasks'    => 1,
    'projects' => 1,
    'finances' => 0,
    'wiki'     => 1,
    'crm'      => 0,
    'work_time' => 1,
    'zaplecze' => 0
  ];

  private $mdb;

  public function __construct( $mdb = null )
  {
    if ( $mdb )
      $this -> mdb = $mdb;
    else if ( isset( $GLOBALS['mdb'] ) )
      $this -> mdb = $GLOBALS['mdb'];
    else
      $this -> mdb = null;
  }

  public static function defaults()
  {
    return self::DEFAULTS;
  }

  public function byUserId( $user_id )
  {
    if ( !$this -> mdb )
      return self::defaults();

    $row = $this -> mdb -> get( 'users_permissions', '*', [ 'user_id' => (int)$user_id ] );

    if ( !$row )
      return self::defaults();

    $result = [];
    foreach ( self::MODULES as $module )
      $result[ $module ] = isset( $row[ $module ] ) ? (int)$row[ $module ] : 0;

    return $result;
  }

  public function save( $user_id, array $modules )
  {
    if ( !$this -> mdb )
      return;

    $data = [];
    foreach ( self::MODULES as $module )
      $data[ $module ] = isset( $modules[ $module ] ) ? (int)(bool)$modules[ $module ] : 0;

    $existing = $this -> mdb -> get( 'users_permissions', 'user_id', [ 'user_id' => (int)$user_id ] );

    if ( $existing )
      $this -> mdb -> update( 'users_permissions', $data, [ 'user_id' => (int)$user_id ] );
    else
      $this -> mdb -> insert( 'users_permissions', array_merge( [ 'user_id' => (int)$user_id ], $data ) );
  }
}

Step 5: Run tests to verify they pass

Run: php tests/run.php Expected: All pass

Step 6: Commit

feat: Add PermissionRepository for module access management

Task 3: Refactor controls\Users::permissions() to use DB

Files:

  • Modify: autoload/controls/class.Users.php (method permissions, lines 7-44)

Step 1: Write the test

Add to tests/Controllers/UsersControllerTest.php (or create a dedicated test — but since permissions() is a static method that relies on global $mdb, we test the PermissionRepository integration logic separately). The existing test file pattern is suitable.

No new test file needed — the PermissionRepository tests cover the data layer, and the permissions() method is a thin wrapper. The integration is tested by checking the app in browser.

Step 2: Replace permissions() method body

Replace the entire permissions() method in autoload/controls/class.Users.php:

public static function permissions( $user_id, $module = '', $action = '' )
{
  // Superadmin has full access
  if ( (int)$user_id === 1 )
    return true;

  // Cache permissions per user to avoid repeated DB queries
  static $cache = [];

  if ( !isset( $cache[ $user_id ] ) )
  {
    $repo = new \Domain\Users\PermissionRepository();
    $cache[ $user_id ] = $repo -> byUserId( (int)$user_id );
  }

  if ( $module && isset( $cache[ $user_id ][ $module ] ) )
    return (bool)$cache[ $user_id ][ $module ];

  // If module not in permissions list, allow by default
  return true;
}

Step 3: Run tests

Run: php tests/run.php Expected: All pass

Step 4: Commit

feat: Refactor permissions() to read from users_permissions table

Task 4: Add AJAX endpoint for saving permissions

Files:

  • Modify: autoload/Controllers/UsersController.php (add permissionSave method)

Step 1: Add permissionSave() method to UsersController

Add after the vacationLimitSave() method:

public static function permissionSave()
{
  global $user, $mdb;
  header( 'Content-Type: application/json; charset=utf-8' );

  $response = [ 'status' => 'error', 'msg' => 'Wystapil blad podczas zapisywania uprawnien.' ];

  if ( !$user || !self::canManageUsers( $user, self::getImpersonatorUser() ) )
  {
    $response['msg'] = 'Brak uprawnien.';
    echo json_encode( $response );
    exit;
  }

  if ( !\S::csrf_verify() )
  {
    $response['msg'] = 'Nieprawidlowy token bezpieczenstwa. Odswiez strone.';
    echo json_encode( $response );
    exit;
  }

  $target_user_id = (int) \S::get( 'user_id' );
  $module = \S::get( 'module' );
  $value = (int) \S::get( 'value' );

  if ( !$target_user_id || !$module )
  {
    echo json_encode( $response );
    exit;
  }

  // Superadmin permissions cannot be changed
  if ( $target_user_id === self::ADMIN_USER_ID )
  {
    $response['msg'] = 'Nie mozna zmieniac uprawnien administratora.';
    echo json_encode( $response );
    exit;
  }

  // Whitelist check
  if ( !in_array( $module, \Domain\Users\PermissionRepository::MODULES, true ) )
  {
    $response['msg'] = 'Nieznany modul.';
    echo json_encode( $response );
    exit;
  }

  $repo = new \Domain\Users\PermissionRepository( $mdb );
  $current = $repo -> byUserId( $target_user_id );
  $current[ $module ] = $value ? 1 : 0;
  $repo -> save( $target_user_id, $current );

  $response = [ 'status' => 'success', 'msg' => 'Uprawnienia zostaly zapisane.' ];
  echo json_encode( $response );
  exit;
}

Step 2: Run tests

Run: php tests/run.php Expected: All pass (no existing tests broken)

Step 3: Commit

feat: Add AJAX endpoint for saving user module permissions

Task 5: Add permissions UI to user list template

Files:

  • Modify: templates/users/main-view.php
  • Modify: autoload/Controllers/UsersController.php (buildMainViewModel and mainView)

Step 1: Pass permissions data to the view

In UsersController::mainView(), before the return \Tpl::view(...) call, load permissions for all users:

$permission_repo = new \Domain\Users\PermissionRepository( $mdb );
$permissions_map = [];
foreach ( $users_repository -> all() as $u )
{
  if ( (int)$u['id'] !== self::ADMIN_USER_ID )
    $permissions_map[ (int)$u['id'] ] = $permission_repo -> byUserId( (int)$u['id'] );
}

Update buildMainViewModel to accept and include $permissions_map and the modules list:

Add parameters to the method signature and add to the returned array:

'permissions_map' => $permissions_map,
'modules' => \Domain\Users\PermissionRepository::MODULES,
'module_labels' => [
  'tasks' => 'Zadania',
  'projects' => 'Projekty',
  'work_time' => 'Czas pracy',
  'finances' => 'Finanse',
  'crm' => 'CRM',
  'wiki' => 'Wiki',
  'zaplecze' => 'Zaplecze'
]

Step 2: Update the template

In templates/users/main-view.php:

  1. Add "Uprawnienia" column header after "Email" th:
<th>Uprawnienia</th>
  1. Add permissions cell after the email td, inside the foreach loop:
<td class="left">
  <? if ( (int)$user_tmp['id'] === 1 ):?>
    <span class="label label-info">Pelny dostep</span>
  <? elseif ( isset( $this -> permissions_map[ (int)$user_tmp['id'] ] ) ):?>
    <? foreach ( $this -> modules as $mod ):?>
      <label style="margin-right: 10px; font-weight: normal; white-space: nowrap;">
        <input type="checkbox"
          class="permission-checkbox"
          data-user-id="<?= (int)$user_tmp['id'];?>"
          data-module="<?= $mod;?>"
          <?= $this -> permissions_map[ (int)$user_tmp['id'] ][ $mod ] ? 'checked' : '';?>
        >
        <?= htmlspecialchars( $this -> module_labels[ $mod ] );?>
      </label>
    <? endforeach;?>
  <? endif;?>
</td>
  1. Update colspan in the "Brak uzytkownikow" row from 4 to 5.

  2. Add JavaScript at the bottom of the template for AJAX handling:

<script>
$( document ).on( 'change', '.permission-checkbox', function()
{
  var $cb = $( this );
  $.ajax({
    url: '/users/permission_save/',
    type: 'POST',
    data: {
      user_id: $cb.data( 'user-id' ),
      module: $cb.data( 'module' ),
      value: $cb.is( ':checked' ) ? 1 : 0,
      csrf_token: '<?= \S::csrf_token();?>'
    },
    dataType: 'json',
    success: function( r )
    {
      if ( r.status !== 'success' )
      {
        alert( r.msg || 'Blad zapisu uprawnien.' );
        $cb.prop( 'checked', !$cb.is( ':checked' ) );
      }
    },
    error: function()
    {
      alert( 'Blad polaczenia z serwerem.' );
      $cb.prop( 'checked', !$cb.is( ':checked' ) );
    }
  });
});
</script>

Step 3: Update buildMainViewModel signature and callers

Update buildMainViewModel to accept $permissions_map as a 4th parameter:

public static function buildMainViewModel( $current_user, $impersonator_user, array $users, array $permissions_map = [] )
{
  return [
    'current_user' => $current_user,
    'impersonator_user' => $impersonator_user,
    'users' => $users,
    'active_tab' => 'users',
    'can_switch_back' => is_array( $impersonator_user ) and isset( $impersonator_user['id'] ) and (int)$impersonator_user['id'] === self::ADMIN_USER_ID,
    'permissions_map' => $permissions_map,
    'modules' => \Domain\Users\PermissionRepository::MODULES,
    'module_labels' => [
      'tasks' => 'Zadania',
      'projects' => 'Projekty',
      'work_time' => 'Czas pracy',
      'finances' => 'Finanse',
      'crm' => 'CRM',
      'wiki' => 'Wiki',
      'zaplecze' => 'Zaplecze'
    ]
  ];
}

Update the mainView() call to pass $permissions_map.

Step 4: Fix the test for buildMainViewModel

In tests/Controllers/UsersControllerTest.php, the test calls buildMainViewModel with 3 args. Update to pass a 4th arg [] (empty permissions_map):

$view_model = UsersController::buildMainViewModel( $target_user, $admin_user, [ $admin_user, $regular_user, $target_user ], [] );

Step 5: Run tests

Run: php tests/run.php Expected: All pass

Step 6: Commit

feat: Add permissions checkboxes to user management UI

Task 6: Manual testing checklist

  1. Log in as admin (ID=1) and go to /users/main_view/
  2. Verify you see checkboxes for all users except admin
  3. Uncheck "Finanse" for a user, verify AJAX returns success
  4. Log in as that user (via "Zaloguj jako"), verify "Finanse" is missing from sidebar
  5. Try accessing /finances/main_view/ directly — should get empty/blocked response
  6. Switch back to admin, re-check "Finanse", verify it reappears
  7. Verify admin always sees all modules regardless of DB state