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`.
This commit is contained in:
491
docs/plans/2026-02-26-permissions-design.md
Normal file
491
docs/plans/2026-02-26-permissions-design.md
Normal file
@@ -0,0 +1,491 @@
|
||||
# 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:
|
||||
|
||||
```php
|
||||
$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
|
||||
<?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`:
|
||||
```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
|
||||
<?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`:
|
||||
|
||||
```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:
|
||||
|
||||
```php
|
||||
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:
|
||||
|
||||
```php
|
||||
$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:
|
||||
```php
|
||||
'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:
|
||||
```html
|
||||
<th>Uprawnienia</th>
|
||||
```
|
||||
|
||||
2. Add permissions cell after the email td, inside the foreach loop:
|
||||
```php
|
||||
<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>
|
||||
```
|
||||
|
||||
3. Update colspan in the "Brak uzytkownikow" row from 4 to 5.
|
||||
|
||||
4. Add JavaScript at the bottom of the template for AJAX handling:
|
||||
```html
|
||||
<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:
|
||||
|
||||
```php
|
||||
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):
|
||||
|
||||
```php
|
||||
$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
|
||||
Reference in New Issue
Block a user