From 6174bed49a77dda683d8fb1ac076bedc2293e15e Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Wed, 25 Mar 2026 16:44:45 +0200 Subject: [PATCH] feat(admin): split role permissions for each section --- lib/Controller/AdminController.php | 9 +- lib/Controller/BBCodeController.php | 10 +- lib/Controller/CategoryController.php | 3 +- lib/Controller/ForumUserController.php | 25 +- lib/Controller/RoleController.php | 31 +- lib/Controller/ServerAdminController.php | 81 +++++ lib/Db/Role.php | 10 + lib/Migration/SeedHelper.php | 10 + lib/Migration/Version27Date20260325000000.php | 73 ++++ openapi-administration.json | 291 ++++++++++++++++ openapi-full.json | 313 ++++++++++++++++++ openapi.json | 22 ++ src/AdminSettings.vue | 4 +- .../AppNavigation/AppNavigation.vue | 32 +- src/composables/useCurrentUser.ts | 27 +- src/composables/useUserRole.ts | 56 ++-- src/router.ts | 8 +- src/types/models.ts | 2 + src/views/admin/AdminRoleEdit.vue | 67 +++- .../Controller/ForumUserControllerTest.php | 19 +- tests/unit/Controller/RoleControllerTest.php | 38 ++- .../Controller/ServerAdminControllerTest.php | 15 + .../Controller/UserRoleControllerTest.php | 2 + .../Middleware/PermissionMiddlewareTest.php | 76 +++++ tests/unit/Service/PermissionServiceTest.php | 41 ++- 25 files changed, 1152 insertions(+), 113 deletions(-) create mode 100644 lib/Migration/Version27Date20260325000000.php diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 4596e58..cf24da5 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -125,8 +125,7 @@ class AdminController extends OCSController { * 200: Users list returned */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools', orGroup: 'access')] - #[RequirePermission('canEditRoles', orGroup: 'access')] + #[RequirePermission('canManageUsers')] #[ApiRoute(verb: 'GET', url: '/api/admin/users')] public function users(): DataResponse { try { @@ -246,7 +245,7 @@ class AdminController extends OCSController { * 200: Roles list returned */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools', orGroup: 'access')] + #[RequirePermission('canManageUsers', orGroup: 'access')] #[RequirePermission('canEditRoles', orGroup: 'access')] #[ApiRoute(verb: 'GET', url: '/api/admin/roles')] public function getRoles(): DataResponse { @@ -274,7 +273,7 @@ class AdminController extends OCSController { * 200: Role assigned successfully */ #[NoAdminRequired] - #[RequirePermission('canEditRoles')] + #[RequirePermission('canManageUsers')] #[ApiRoute(verb: 'POST', url: '/api/admin/users/{userId}/roles')] public function assignRole(string $userId, int $roleId): DataResponse { try { @@ -332,7 +331,7 @@ class AdminController extends OCSController { * 200: Role removed successfully */ #[NoAdminRequired] - #[RequirePermission('canEditRoles')] + #[RequirePermission('canManageUsers')] #[ApiRoute(verb: 'DELETE', url: '/api/admin/users/{userId}/roles/{roleId}')] public function removeRole(string $userId, int $roleId): DataResponse { try { diff --git a/lib/Controller/BBCodeController.php b/lib/Controller/BBCodeController.php index 56c08f3..8789e83 100644 --- a/lib/Controller/BBCodeController.php +++ b/lib/Controller/BBCodeController.php @@ -38,7 +38,7 @@ class BBCodeController extends OCSController { * 200: BBCodes returned */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools')] + #[RequirePermission('canEditBbcodes')] #[ApiRoute(verb: 'GET', url: '/api/bbcodes')] public function index(int $limit = 100, int $offset = 0): DataResponse { try { @@ -101,7 +101,7 @@ class BBCodeController extends OCSController { * 200: BBCode returned */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools')] + #[RequirePermission('canEditBbcodes')] #[ApiRoute(verb: 'GET', url: '/api/bbcodes/{id}')] public function show(int $id): DataResponse { try { @@ -135,7 +135,7 @@ class BBCodeController extends OCSController { * 201: BBCode created */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools')] + #[RequirePermission('canEditBbcodes')] #[ApiRoute(verb: 'POST', url: '/api/bbcodes')] public function create(string $tag, string $replacement, string $example, ?string $description = null, bool $enabled = true, bool $parseInner = true): DataResponse { try { @@ -173,7 +173,7 @@ class BBCodeController extends OCSController { * 200: BBCode updated */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools')] + #[RequirePermission('canEditBbcodes')] #[ApiRoute(verb: 'PUT', url: '/api/bbcodes/{id}')] public function update(int $id, ?string $tag = null, ?string $replacement = null, ?string $example = null, ?string $description = null, ?bool $enabled = null, ?bool $parseInner = null): DataResponse { try { @@ -223,7 +223,7 @@ class BBCodeController extends OCSController { * 200: BBCode deleted */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools')] + #[RequirePermission('canEditBbcodes')] #[ApiRoute(verb: 'DELETE', url: '/api/bbcodes/{id}')] public function destroy(int $id): DataResponse { try { diff --git a/lib/Controller/CategoryController.php b/lib/Controller/CategoryController.php index ea2676a..8314547 100644 --- a/lib/Controller/CategoryController.php +++ b/lib/Controller/CategoryController.php @@ -394,8 +394,7 @@ class CategoryController extends OCSController { * 200: Permissions returned */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools', orGroup: 'access')] - #[RequirePermission('canEditCategories', orGroup: 'access')] + #[RequirePermission('canEditCategories')] #[ApiRoute(verb: 'GET', url: '/api/categories/{id}/permissions')] public function getPermissions(int $id, int $limit = 100, int $offset = 0): DataResponse { try { diff --git a/lib/Controller/ForumUserController.php b/lib/Controller/ForumUserController.php index 9af047c..a30f3b2 100644 --- a/lib/Controller/ForumUserController.php +++ b/lib/Controller/ForumUserController.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace OCA\Forum\Controller; use OCA\Forum\Db\ForumUserMapper; +use OCA\Forum\Db\RoleMapper; use OCA\Forum\Service\UserService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -29,6 +30,7 @@ class ForumUserController extends OCSController { string $appName, IRequest $request, private ForumUserMapper $forumUserMapper, + private RoleMapper $roleMapper, private UserService $userService, private IUserSession $userSession, private LoggerInterface $logger, @@ -98,8 +100,10 @@ class ForumUserController extends OCSController { #[ApiRoute(verb: 'GET', url: '/api/users/{userId}')] public function show(string $userId): DataResponse { try { + $isMe = $userId === 'me'; + // Handle "me" as special case for current user - if ($userId === 'me') { + if ($isMe) { $currentUser = $this->userSession->getUser(); if (!$currentUser) { // Guest users have no forum profile - return 404 like a user who hasn't posted yet @@ -108,10 +112,21 @@ class ForumUserController extends OCSController { $userId = $currentUser->getUID(); } - $forumUser = $this->forumUserMapper->find($userId); - return new DataResponse($forumUser->jsonSerialize()); - } catch (DoesNotExistException $e) { - return new DataResponse(['error' => 'Forum user not found'], Http::STATUS_NOT_FOUND); + try { + $forumUser = $this->forumUserMapper->find($userId); + $data = $forumUser->jsonSerialize(); + } catch (DoesNotExistException $e) { + if (!$isMe) { + return new DataResponse(['error' => 'Forum user not found'], Http::STATUS_NOT_FOUND); + } + // For "me", return a minimal response so roles are still included + $data = ['userId' => $userId]; + } + + $roles = $this->roleMapper->findByUserId($userId); + $data['roles'] = array_map(fn ($role) => $role->jsonSerialize(), $roles); + + return new DataResponse($data); } catch (\Exception $e) { $this->logger->error('Error fetching forum user: ' . $e->getMessage()); return new DataResponse(['error' => 'Failed to fetch forum user'], Http::STATUS_INTERNAL_SERVER_ERROR); diff --git a/lib/Controller/RoleController.php b/lib/Controller/RoleController.php index 1523fef..ef3ae63 100644 --- a/lib/Controller/RoleController.php +++ b/lib/Controller/RoleController.php @@ -42,7 +42,7 @@ class RoleController extends OCSController { * 200: Roles returned */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools', orGroup: 'access')] + #[RequirePermission('canManageUsers', orGroup: 'access')] #[RequirePermission('canEditRoles', orGroup: 'access')] #[ApiRoute(verb: 'GET', url: '/api/roles')] public function index(int $limit = 100, int $offset = 0): DataResponse { @@ -64,7 +64,7 @@ class RoleController extends OCSController { * 200: Role returned */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools', orGroup: 'access')] + #[RequirePermission('canManageUsers', orGroup: 'access')] #[RequirePermission('canEditRoles', orGroup: 'access')] #[ApiRoute(verb: 'GET', url: '/api/roles/{id}')] public function show(int $id): DataResponse { @@ -87,8 +87,10 @@ class RoleController extends OCSController { * @param string|null $colorLight Light mode color * @param string|null $colorDark Dark mode color * @param bool $canAccessAdminTools Can access admin tools + * @param bool $canManageUsers Can manage users * @param bool $canEditRoles Can edit roles * @param bool $canEditCategories Can edit categories + * @param bool $canEditBbcodes Can edit BBCodes * @return DataResponse, array{}> * * 201: Role created @@ -102,8 +104,10 @@ class RoleController extends OCSController { ?string $colorLight = null, ?string $colorDark = null, bool $canAccessAdminTools = false, + bool $canManageUsers = false, bool $canEditRoles = false, bool $canEditCategories = false, + bool $canEditBbcodes = false, ): DataResponse { try { $role = new \OCA\Forum\Db\Role(); @@ -112,8 +116,10 @@ class RoleController extends OCSController { $role->setColorLight($colorLight); $role->setColorDark($colorDark); $role->setCanAccessAdminTools($canAccessAdminTools); + $role->setCanManageUsers($canManageUsers); $role->setCanEditRoles($canEditRoles); $role->setCanEditCategories($canEditCategories); + $role->setCanEditBbcodes($canEditBbcodes); $role->setCreatedAt(time()); /** @var \OCA\Forum\Db\Role */ @@ -134,8 +140,10 @@ class RoleController extends OCSController { * @param string|null $colorLight Light mode color * @param string|null $colorDark Dark mode color * @param bool|null $canAccessAdminTools Can access admin tools + * @param bool|null $canManageUsers Can manage users * @param bool|null $canEditRoles Can edit roles * @param bool|null $canEditCategories Can edit categories + * @param bool|null $canEditBbcodes Can edit BBCodes * @return DataResponse, array{}> * * 200: Role updated @@ -150,8 +158,10 @@ class RoleController extends OCSController { ?string $colorLight = null, ?string $colorDark = null, ?bool $canAccessAdminTools = null, + ?bool $canManageUsers = null, ?bool $canEditRoles = null, ?bool $canEditCategories = null, + ?bool $canEditBbcodes = null, ): DataResponse { try { $role = $this->roleMapper->find($id); @@ -169,26 +179,36 @@ class RoleController extends OCSController { $role->setColorDark($colorDark); } - // Admin role always has all permissions - cannot be changed + // Admin role always has all permissions — cannot be changed if ($role->getRoleType() === Role::ROLE_TYPE_ADMIN) { $role->setCanAccessAdminTools(true); + $role->setCanManageUsers(true); $role->setCanEditRoles(true); $role->setCanEditCategories(true); + $role->setCanEditBbcodes(true); } elseif ($role->getRoleType() === Role::ROLE_TYPE_GUEST) { - // Guest role never has admin permissions - cannot be changed + // Guest role never has management permissions — cannot be changed $role->setCanAccessAdminTools(false); + $role->setCanManageUsers(false); $role->setCanEditRoles(false); $role->setCanEditCategories(false); + $role->setCanEditBbcodes(false); } else { if ($canAccessAdminTools !== null) { $role->setCanAccessAdminTools($canAccessAdminTools); } + if ($canManageUsers !== null) { + $role->setCanManageUsers($canManageUsers); + } if ($canEditRoles !== null) { $role->setCanEditRoles($canEditRoles); } if ($canEditCategories !== null) { $role->setCanEditCategories($canEditCategories); } + if ($canEditBbcodes !== null) { + $role->setCanEditBbcodes($canEditBbcodes); + } } /** @var \OCA\Forum\Db\Role */ @@ -247,8 +267,7 @@ class RoleController extends OCSController { * 200: Permissions returned */ #[NoAdminRequired] - #[RequirePermission('canAccessAdminTools', orGroup: 'access')] - #[RequirePermission('canEditRoles', orGroup: 'access')] + #[RequirePermission('canEditRoles')] #[ApiRoute(verb: 'GET', url: '/api/roles/{id}/permissions')] public function getPermissions(int $id, int $limit = 100, int $offset = 0): DataResponse { try { diff --git a/lib/Controller/ServerAdminController.php b/lib/Controller/ServerAdminController.php index ef73362..f804353 100644 --- a/lib/Controller/ServerAdminController.php +++ b/lib/Controller/ServerAdminController.php @@ -7,13 +7,16 @@ declare(strict_types=1); namespace OCA\Forum\Controller; +use OCA\Forum\Db\RoleMapper; use OCA\Forum\Migration\SeedHelper; use OCA\Forum\Service\StatsService; +use OCA\Forum\Service\UserRoleService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCSController; use OCP\IRequest; +use OCP\IUserManager; use OCP\Migration\IOutput; use Psr\Log\LoggerInterface; @@ -22,12 +25,90 @@ class ServerAdminController extends OCSController { public function __construct( string $appName, IRequest $request, + private RoleMapper $roleMapper, + private UserRoleService $userRoleService, + private IUserManager $userManager, private StatsService $statsService, private LoggerInterface $logger, ) { parent::__construct($appName, $request); } + /** + * Get all available roles (for server admin panel) + * + * @return DataResponse>}, array{}> + * + * 200: Roles list returned + */ + #[ApiRoute(verb: 'GET', url: '/api/server-admin/roles')] + public function getRoles(): DataResponse { + try { + $roles = $this->roleMapper->findAll(); + $rolesData = array_map(fn ($role) => [ + 'id' => $role->getId(), + 'name' => $role->getName(), + 'roleType' => $role->getRoleType(), + ], $roles); + return new DataResponse(['roles' => $rolesData]); + } catch (\Exception $e) { + $this->logger->error('Error fetching roles: ' . $e->getMessage()); + return new DataResponse(['error' => 'Failed to fetch roles'], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Assign a role to a user (from server admin panel) + * + * @param string $userId The user ID + * @param int $roleId The role ID to assign + * @return DataResponse + * + * 200: Role assigned successfully + */ + #[ApiRoute(verb: 'POST', url: '/api/server-admin/users/{userId}/roles')] + public function assignRole(string $userId, int $roleId): DataResponse { + try { + $user = $this->userManager->get($userId); + if ($user === null) { + return new DataResponse([ + 'success' => false, + 'message' => "User '$userId' does not exist.", + ], Http::STATUS_NOT_FOUND); + } + + try { + $role = $this->roleMapper->find($roleId); + } catch (\OCP\AppFramework\Db\DoesNotExistException $e) { + return new DataResponse([ + 'success' => false, + 'message' => "Role with ID '$roleId' does not exist.", + ], Http::STATUS_NOT_FOUND); + } + + if ($this->userRoleService->hasRole($userId, $roleId)) { + return new DataResponse([ + 'success' => true, + 'message' => "User '$userId' already has the role '{$role->getName()}'.", + ]); + } + + $this->userRoleService->assignRole($userId, $roleId, skipIfExists: false); + $this->logger->info("Assigned role '{$role->getName()}' to user '$userId'"); + + return new DataResponse([ + 'success' => true, + 'message' => "Successfully assigned role '{$role->getName()}' to user '$userId'.", + ]); + } catch (\Exception $e) { + $this->logger->error('Error assigning role: ' . $e->getMessage()); + return new DataResponse([ + 'success' => false, + 'message' => 'Failed to assign role: ' . $e->getMessage(), + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + /** * Run the repair seeds command to restore default forum data * diff --git a/lib/Db/Role.php b/lib/Db/Role.php index 4dff8f8..6fcc58a 100644 --- a/lib/Db/Role.php +++ b/lib/Db/Role.php @@ -24,10 +24,14 @@ use OCP\AppFramework\Db\Entity; * @method void setColorDark(?string $value) * @method bool getCanAccessAdminTools() * @method void setCanAccessAdminTools(bool $value) + * @method bool getCanManageUsers() + * @method void setCanManageUsers(bool $value) * @method bool getCanEditRoles() * @method void setCanEditRoles(bool $value) * @method bool getCanEditCategories() * @method void setCanEditCategories(bool $value) + * @method bool getCanEditBbcodes() + * @method void setCanEditBbcodes(bool $value) * @method bool getIsSystemRole() * @method void setIsSystemRole(bool $value) * @method string getRoleType() @@ -48,8 +52,10 @@ class Role extends Entity implements JsonSerializable { protected $colorLight; protected $colorDark; protected $canAccessAdminTools; + protected $canManageUsers; protected $canEditRoles; protected $canEditCategories; + protected $canEditBbcodes; protected $isSystemRole; protected $roleType; protected $createdAt; @@ -61,8 +67,10 @@ class Role extends Entity implements JsonSerializable { $this->addType('colorLight', 'string'); $this->addType('colorDark', 'string'); $this->addType('canAccessAdminTools', 'boolean'); + $this->addType('canManageUsers', 'boolean'); $this->addType('canEditRoles', 'boolean'); $this->addType('canEditCategories', 'boolean'); + $this->addType('canEditBbcodes', 'boolean'); $this->addType('isSystemRole', 'boolean'); $this->addType('roleType', 'string'); $this->addType('createdAt', 'integer'); @@ -87,8 +95,10 @@ class Role extends Entity implements JsonSerializable { 'colorLight' => $this->getColorLight(), 'colorDark' => $this->getColorDark(), 'canAccessAdminTools' => $this->getCanAccessAdminTools(), + 'canManageUsers' => $this->getCanManageUsers(), 'canEditRoles' => $this->getCanEditRoles(), 'canEditCategories' => $this->getCanEditCategories(), + 'canEditBbcodes' => $this->getCanEditBbcodes(), 'isSystemRole' => $this->getIsSystemRole(), 'roleType' => $this->getRoleType(), 'createdAt' => $this->getCreatedAt(), diff --git a/lib/Migration/SeedHelper.php b/lib/Migration/SeedHelper.php index 03db0b6..bc62b26 100644 --- a/lib/Migration/SeedHelper.php +++ b/lib/Migration/SeedHelper.php @@ -363,8 +363,10 @@ class SeedHelper { 'color_light' => '#dc2626', 'color_dark' => '#f87171', 'can_access_admin_tools' => true, + 'can_manage_users' => true, 'can_edit_roles' => true, 'can_edit_categories' => true, + 'can_edit_bbcodes' => true, 'is_system_role' => true, 'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_ADMIN, ], @@ -374,8 +376,10 @@ class SeedHelper { 'color_light' => '#2563eb', 'color_dark' => '#60a5fa', 'can_access_admin_tools' => true, + 'can_manage_users' => true, 'can_edit_roles' => false, 'can_edit_categories' => false, + 'can_edit_bbcodes' => true, 'is_system_role' => true, 'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_MODERATOR, ], @@ -385,8 +389,10 @@ class SeedHelper { 'color_light' => '#059669', 'color_dark' => '#34d399', 'can_access_admin_tools' => false, + 'can_manage_users' => false, 'can_edit_roles' => false, 'can_edit_categories' => false, + 'can_edit_bbcodes' => false, 'is_system_role' => true, 'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_DEFAULT, ], @@ -396,8 +402,10 @@ class SeedHelper { 'color_light' => '#868e96', 'color_dark' => '#adb5bd', 'can_access_admin_tools' => false, + 'can_manage_users' => false, 'can_edit_roles' => false, 'can_edit_categories' => false, + 'can_edit_bbcodes' => false, 'is_system_role' => true, 'role_type' => \OCA\Forum\Db\Role::ROLE_TYPE_GUEST, ], @@ -414,8 +422,10 @@ class SeedHelper { 'color_light' => $qb->createNamedParameter($roleData['color_light'] ?? null), 'color_dark' => $qb->createNamedParameter($roleData['color_dark'] ?? null), 'can_access_admin_tools' => $qb->createNamedParameter($roleData['can_access_admin_tools'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL), + 'can_manage_users' => $qb->createNamedParameter($roleData['can_manage_users'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL), 'can_edit_roles' => $qb->createNamedParameter($roleData['can_edit_roles'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL), 'can_edit_categories' => $qb->createNamedParameter($roleData['can_edit_categories'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL), + 'can_edit_bbcodes' => $qb->createNamedParameter($roleData['can_edit_bbcodes'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL), 'is_system_role' => $qb->createNamedParameter($roleData['is_system_role'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL), 'role_type' => $qb->createNamedParameter($roleData['role_type'], \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR), 'created_at' => $qb->createNamedParameter($timestamp, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT), diff --git a/lib/Migration/Version27Date20260325000000.php b/lib/Migration/Version27Date20260325000000.php new file mode 100644 index 0000000..4f055b0 --- /dev/null +++ b/lib/Migration/Version27Date20260325000000.php @@ -0,0 +1,73 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Forum\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\IDBConnection; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Version 10 Migration: + * - Add can_manage_users and can_edit_bbcodes columns to forum_roles + * - Backfill: can_manage_users = can_access_admin_tools OR can_edit_roles + * - Backfill: can_edit_bbcodes = can_access_admin_tools + */ +class Version27Date20260325000000 extends SimpleMigrationStep { + public function __construct( + private IDBConnection $db, + ) { + } + + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable('forum_roles')) { + $table = $schema->getTable('forum_roles'); + + if (!$table->hasColumn('can_manage_users')) { + $table->addColumn('can_manage_users', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + } + + if (!$table->hasColumn('can_edit_bbcodes')) { + $table->addColumn('can_edit_bbcodes', 'boolean', [ + 'notnull' => false, + 'default' => false, + ]); + } + } + + return $schema; + } + + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + // Backfill can_manage_users: anyone who had canAccessAdminTools OR canEditRoles + $qb = $this->db->getQueryBuilder(); + $qb->update('forum_roles') + ->set('can_manage_users', $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)) + ->where($qb->expr()->orX( + $qb->expr()->eq('can_access_admin_tools', $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)), + $qb->expr()->eq('can_edit_roles', $qb->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)), + )); + $qb->executeStatement(); + + // Backfill can_edit_bbcodes: anyone who had canAccessAdminTools + $qb2 = $this->db->getQueryBuilder(); + $qb2->update('forum_roles') + ->set('can_edit_bbcodes', $qb2->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL)) + ->where($qb2->expr()->eq('can_access_admin_tools', $qb2->createNamedParameter(true, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_BOOL))); + $qb2->executeStatement(); + + $output->info('Backfilled can_manage_users and can_edit_bbcodes from existing permissions'); + } +} diff --git a/openapi-administration.json b/openapi-administration.json index bdf1294..8e3ee3a 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -463,6 +463,297 @@ } } }, + "/ocs/v2.php/apps/forum/api/server-admin/roles": { + "get": { + "operationId": "server_admin-get-roles", + "summary": "Get all available roles (for server admin panel)", + "description": "This endpoint requires admin access", + "tags": [ + "server_admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Roles list returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "roles" + ], + "properties": { + "roles": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Logged in account must be an admin", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forum/api/server-admin/users/{userId}/roles": { + "post": { + "operationId": "server_admin-assign-role", + "summary": "Assign a role to a user (from server admin panel)", + "description": "This endpoint requires admin access", + "tags": [ + "server_admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "roleId" + ], + "properties": { + "roleId": { + "type": "integer", + "format": "int64", + "description": "The role ID to assign" + } + } + } + } + } + }, + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "The user ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Role assigned successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Logged in account must be an admin", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/server-admin/repair-seeds": { "post": { "operationId": "server_admin-repair-seeds", diff --git a/openapi-full.json b/openapi-full.json index ed9bc29..dceab41 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -7445,6 +7445,11 @@ "default": false, "description": "Can access admin tools" }, + "canManageUsers": { + "type": "boolean", + "default": false, + "description": "Can manage users" + }, "canEditRoles": { "type": "boolean", "default": false, @@ -7454,6 +7459,11 @@ "type": "boolean", "default": false, "description": "Can edit categories" + }, + "canEditBbcodes": { + "type": "boolean", + "default": false, + "description": "Can edit BBCodes" } } } @@ -7689,6 +7699,12 @@ "default": null, "description": "Can access admin tools" }, + "canManageUsers": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Can manage users" + }, "canEditRoles": { "type": "boolean", "nullable": true, @@ -7700,6 +7716,12 @@ "nullable": true, "default": null, "description": "Can edit categories" + }, + "canEditBbcodes": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Can edit BBCodes" } } } @@ -12383,6 +12405,297 @@ } } }, + "/ocs/v2.php/apps/forum/api/server-admin/roles": { + "get": { + "operationId": "server_admin-get-roles", + "summary": "Get all available roles (for server admin panel)", + "description": "This endpoint requires admin access", + "tags": [ + "server_admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Roles list returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "roles" + ], + "properties": { + "roles": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Logged in account must be an admin", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/forum/api/server-admin/users/{userId}/roles": { + "post": { + "operationId": "server_admin-assign-role", + "summary": "Assign a role to a user (from server admin panel)", + "description": "This endpoint requires admin access", + "tags": [ + "server_admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "roleId" + ], + "properties": { + "roleId": { + "type": "integer", + "format": "int64", + "description": "The role ID to assign" + } + } + } + } + } + }, + "parameters": [ + { + "name": "userId", + "in": "path", + "description": "The user ID", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Role assigned successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "success", + "message" + ], + "properties": { + "success": { + "type": "boolean" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "403": { + "description": "Logged in account must be an admin", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/forum/api/server-admin/repair-seeds": { "post": { "operationId": "server_admin-repair-seeds", diff --git a/openapi.json b/openapi.json index f1ed292..348dc2e 100644 --- a/openapi.json +++ b/openapi.json @@ -7445,6 +7445,11 @@ "default": false, "description": "Can access admin tools" }, + "canManageUsers": { + "type": "boolean", + "default": false, + "description": "Can manage users" + }, "canEditRoles": { "type": "boolean", "default": false, @@ -7454,6 +7459,11 @@ "type": "boolean", "default": false, "description": "Can edit categories" + }, + "canEditBbcodes": { + "type": "boolean", + "default": false, + "description": "Can edit BBCodes" } } } @@ -7689,6 +7699,12 @@ "default": null, "description": "Can access admin tools" }, + "canManageUsers": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Can manage users" + }, "canEditRoles": { "type": "boolean", "nullable": true, @@ -7700,6 +7716,12 @@ "nullable": true, "default": null, "description": "Can edit categories" + }, + "canEditBbcodes": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Can edit BBCodes" } } } diff --git a/src/AdminSettings.vue b/src/AdminSettings.vue index 40ab404..97898e0 100644 --- a/src/AdminSettings.vue +++ b/src/AdminSettings.vue @@ -208,7 +208,7 @@ export default { try { this.rolesLoading = true this.rolesError = null - const resp = await ocs.get('/admin/roles') + const resp = await ocs.get('/server-admin/roles') this.roles = resp.data.roles } catch (e) { console.error('Failed to fetch roles', e) @@ -248,7 +248,7 @@ export default { this.assignRole, async (task) => { const resp = await ocs.post( - `/admin/users/${encodeURIComponent(this.userId.trim())}/roles`, + `/server-admin/users/${encodeURIComponent(this.userId.trim())}/roles`, { roleId: this.selectedRole.id }, ) task.success = resp.data.success diff --git a/src/components/AppNavigation/AppNavigation.vue b/src/components/AppNavigation/AppNavigation.vue index fe81cbc..3adf245 100644 --- a/src/components/AppNavigation/AppNavigation.vue +++ b/src/components/AppNavigation/AppNavigation.vue @@ -140,7 +140,7 @@ { - console.error('Failed to load user roles:', e) - }) - } else if (this.isGuest) { + // Roles are included in the /users/me response and populated automatically + if (userResult.status !== 'fulfilled' && this.isGuest) { // Fetch guest identity for sidebar display await this.fetchGuestIdentity().catch((e) => { console.error('Failed to load guest identity:', e) @@ -465,10 +467,14 @@ export default defineComponent({ // Navigate to the first available management item if (this.canAccessAdminTools) { this.$router.push({ path: '/admin' }) - } else if (this.canEditRoles) { + } else if (this.canManageUsers) { this.$router.push({ path: '/admin/users' }) + } else if (this.canEditRoles) { + this.$router.push({ path: '/admin/roles' }) } else if (this.canEditCategories) { this.$router.push({ path: '/admin/categories' }) + } else if (this.canEditBbcodes) { + this.$router.push({ path: '/admin/bbcodes' }) } }, diff --git a/src/composables/useCurrentUser.ts b/src/composables/useCurrentUser.ts index c6580ee..16a7b03 100644 --- a/src/composables/useCurrentUser.ts +++ b/src/composables/useCurrentUser.ts @@ -1,7 +1,8 @@ import { ref, computed, type Ref } from 'vue' import { ocs } from '@/axios' -import type { ForumUser } from '@/types' +import type { ForumUser, Role } from '@/types' import { getCurrentUser } from '@nextcloud/auth' +import { useUserRole } from '@/composables/useUserRole' const currentForumUser = ref(null) const loading = ref(false) @@ -9,6 +10,8 @@ const error = ref(null) const loaded = ref(false) export function useCurrentUser() { + const { setRoles } = useUserRole() + const fetchCurrentUser = async (force = false): Promise => { // Don't refetch if already loaded unless forced if (loaded.value && !force) { @@ -19,19 +22,21 @@ export function useCurrentUser() { loading.value = true error.value = null - const response = await ocs.get('/users/me') - currentForumUser.value = response.data + const response = await ocs.get('/users/me') + const { roles, ...forumUser } = response.data ?? {} + currentForumUser.value = forumUser as ForumUser loaded.value = true + + // Extract roles from the response and populate the role composable + if (roles) { + const uid = nextcloudUser.value?.uid + if (uid) { + setRoles(uid, roles) + } + } + return currentForumUser.value } catch (e: unknown) { - // If 404, forum user doesn't exist yet (user hasn't posted) - this is OK - const err = e as { response?: { status?: number } } - if (err?.response?.status === 404) { - console.debug('Forum user not found - user has not posted yet') - currentForumUser.value = null - loaded.value = true - return null - } console.error('Failed to fetch current forum user', e) error.value = (e as Error).message || 'Failed to load user information' return null diff --git a/src/composables/useUserRole.ts b/src/composables/useUserRole.ts index 4f140f7..a09fd4d 100644 --- a/src/composables/useUserRole.ts +++ b/src/composables/useUserRole.ts @@ -1,5 +1,4 @@ import { ref, computed } from 'vue' -import { ocs } from '@/axios' import { isAdminRole, isModeratorRole } from '@/constants' import type { Role } from '@/types' @@ -10,26 +9,13 @@ const loaded = ref(false) const currentUserId = ref(null) export function useUserRole() { - const fetchUserRoles = async (userId: string, force = false): Promise => { - if (loaded.value && !force && currentUserId.value === userId) { - return userRoles.value - } - - try { - loading.value = true - error.value = null - const response = await ocs.get(`/users/${userId}/roles`) - userRoles.value = response.data || [] - currentUserId.value = userId - loaded.value = true - return userRoles.value - } catch (e) { - error.value = (e as Error).message || 'Failed to fetch user roles' - console.error('Failed to fetch user roles:', e) - return [] - } finally { - loading.value = false - } + /** + * Set roles directly (called by useCurrentUser after fetching /users/me) + */ + const setRoles = (userId: string, roles: Role[]): void => { + userRoles.value = roles + currentUserId.value = userId + loaded.value = true } const isAdmin = computed(() => { @@ -44,8 +30,8 @@ export function useUserRole() { return userRoles.value.some((role) => role.canAccessAdminTools) }) - const canAccessAdmin = computed(() => { - return canAccessAdminTools.value || canEditRoles.value || canEditCategories.value + const canManageUsers = computed(() => { + return userRoles.value.some((role) => role.canManageUsers) }) const canEditRoles = computed(() => { @@ -56,12 +42,19 @@ export function useUserRole() { return userRoles.value.some((role) => role.canEditCategories) }) - const refresh = () => { - if (currentUserId.value) { - loaded.value = false - return fetchUserRoles(currentUserId.value, true) - } - } + const canEditBbcodes = computed(() => { + return userRoles.value.some((role) => role.canEditBbcodes) + }) + + const canAccessAdmin = computed(() => { + return ( + canAccessAdminTools.value || + canManageUsers.value || + canEditRoles.value || + canEditCategories.value || + canEditBbcodes.value + ) + }) const clear = () => { userRoles.value = [] @@ -79,10 +72,11 @@ export function useUserRole() { isModerator, canAccessAdmin, canAccessAdminTools, + canManageUsers, canEditRoles, canEditCategories, - fetchUserRoles, - refresh, + canEditBbcodes, + setRoles, clear, } } diff --git a/src/router.ts b/src/router.ts index d236b8f..59f3a4f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -83,11 +83,13 @@ router.beforeEach(async (to, from, next) => { // Check if the route is an admin route if (to.path.startsWith('/admin')) { - const { canAccessAdmin, fetchUserRoles, loaded } = useUserRole() + const { canAccessAdmin, loaded } = useUserRole() - // Fetch user and roles if not already loaded + // Roles are populated by fetchCurrentUser (/users/me includes roles). + // On direct page load the guard may run before AppNavigation, so fetch now. if (!loaded.value && userId.value) { - await fetchUserRoles(userId.value) + const { fetchCurrentUser } = useCurrentUser() + await fetchCurrentUser() } // Redirect users without admin access to home diff --git a/src/types/models.ts b/src/types/models.ts index 4d9b267..92b0354 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -139,8 +139,10 @@ export interface Role { colorLight: string | null colorDark: string | null canAccessAdminTools: boolean + canManageUsers: boolean canEditRoles: boolean canEditCategories: boolean + canEditBbcodes: boolean isSystemRole: boolean roleType: 'admin' | 'moderator' | 'default' | 'guest' | 'custom' createdAt: number diff --git a/src/views/admin/AdminRoleEdit.vue b/src/views/admin/AdminRoleEdit.vue index 2cdd432..e0dc91f 100644 --- a/src/views/admin/AdminRoleEdit.vue +++ b/src/views/admin/AdminRoleEdit.vue @@ -121,6 +121,15 @@ {{ strings.canAccessAdminToolsDesc }} + + {{ strings.canManageUsers }} + {{ strings.canManageUsersDesc }} + + {{ strings.canEditCategories }} {{ strings.canEditCategoriesDesc }} + + + {{ strings.canEditBbcodes }} + {{ strings.canEditBbcodesDesc }} + @@ -245,8 +263,10 @@ export default defineComponent({ colorLight: '#000000', colorDark: '#ffffff', canAccessAdminTools: false, + canManageUsers: false, canEditRoles: false, canEditCategories: false, + canEditBbcodes: false, }, darkColorModified: false, permissions: {} as Record, @@ -274,15 +294,22 @@ export default defineComponent({ reset: t('forum', 'Reset'), rolePermissions: t('forum', 'Role permissions'), rolePermissionsDesc: t('forum', 'Set global permissions for this role'), - canAccessAdminTools: t('forum', 'Can access management tools'), + canAccessAdminTools: t('forum', 'Dashboard and forum settings'), canAccessAdminToolsDesc: t( 'forum', - 'Allow access to the management dashboard, forum settings, and BBCode management', + 'Allow access to the management dashboard and forum settings', ), - canEditRoles: t('forum', 'Can edit roles'), - canEditRolesDesc: t('forum', 'Allow creating, editing and deleting roles'), - canEditCategories: t('forum', 'Can edit categories'), + canManageUsers: t('forum', 'Account management'), + canManageUsersDesc: t('forum', 'Allow viewing accounts and assigning roles'), + canEditRoles: t('forum', 'Roles and teams management'), + canEditRolesDesc: t( + 'forum', + 'Allow creating, editing and deleting roles and team permissions', + ), + canEditCategories: t('forum', 'Category management'), canEditCategoriesDesc: t('forum', 'Allow creating, editing and deleting categories'), + canEditBbcodes: t('forum', 'BBCode management'), + canEditBbcodesDesc: t('forum', 'Allow creating, editing and deleting custom BBCodes'), categoryPermissions: t('forum', 'Category permissions'), categoryPermissionsDesc: t('forum', 'Set which categories this role can access'), adminAllRolePermissions: t('forum', 'Admin role must have all permissions enabled'), @@ -411,28 +438,36 @@ export default defineComponent({ this.formData.colorLight = this.role.colorLight || fallback?.light || '#000000' this.formData.colorDark = this.role.colorDark || fallback?.dark || '#ffffff' this.formData.canAccessAdminTools = this.role.canAccessAdminTools || false + this.formData.canManageUsers = this.role.canManageUsers || false this.formData.canEditRoles = this.role.canEditRoles || false this.formData.canEditCategories = this.role.canEditCategories || false + this.formData.canEditBbcodes = this.role.canEditBbcodes || false // Admin role always has all permissions if (this.isAdmin) { this.formData.canAccessAdminTools = true + this.formData.canManageUsers = true this.formData.canEditRoles = true this.formData.canEditCategories = true + this.formData.canEditBbcodes = true } - // Guest role never has admin permissions + // Guest role never has management permissions if (this.isGuest) { this.formData.canAccessAdminTools = false + this.formData.canManageUsers = false this.formData.canEditRoles = false this.formData.canEditCategories = false + this.formData.canEditBbcodes = false } - // Default role never has admin permissions (same as guest) + // Default role never has management permissions (same as guest) if (this.isDefault) { this.formData.canAccessAdminTools = false + this.formData.canManageUsers = false this.formData.canEditRoles = false this.formData.canEditCategories = false + this.formData.canEditBbcodes = false } // If colors are different, mark dark as modified @@ -521,22 +556,18 @@ export default defineComponent({ try { this.submitting = true + const perm = (value: boolean) => (this.isAdmin ? true : this.isGuest ? false : value) + const roleData = { name: this.formData.name.trim(), description: this.formData.description.trim() || null, colorLight: this.formData.colorLight || null, colorDark: this.formData.colorDark || null, - canAccessAdminTools: this.isAdmin - ? true - : this.isGuest - ? false - : this.formData.canAccessAdminTools, - canEditRoles: this.isAdmin ? true : this.isGuest ? false : this.formData.canEditRoles, - canEditCategories: this.isAdmin - ? true - : this.isGuest - ? false - : this.formData.canEditCategories, + canAccessAdminTools: perm(this.formData.canAccessAdminTools), + canManageUsers: perm(this.formData.canManageUsers), + canEditRoles: perm(this.formData.canEditRoles), + canEditCategories: perm(this.formData.canEditCategories), + canEditBbcodes: perm(this.formData.canEditBbcodes), } let roleId: number diff --git a/tests/unit/Controller/ForumUserControllerTest.php b/tests/unit/Controller/ForumUserControllerTest.php index 6444586..4192bb6 100644 --- a/tests/unit/Controller/ForumUserControllerTest.php +++ b/tests/unit/Controller/ForumUserControllerTest.php @@ -8,6 +8,7 @@ use OCA\Forum\AppInfo\Application; use OCA\Forum\Controller\ForumUserController; use OCA\Forum\Db\ForumUser; use OCA\Forum\Db\ForumUserMapper; +use OCA\Forum\Db\RoleMapper; use OCA\Forum\Service\UserService; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; @@ -22,6 +23,8 @@ class ForumUserControllerTest extends TestCase { private ForumUserController $controller; /** @var ForumUserMapper&MockObject */ private ForumUserMapper $forumUserMapper; + /** @var RoleMapper&MockObject */ + private RoleMapper $roleMapper; /** @var UserService&MockObject */ private UserService $userService; /** @var IUserSession&MockObject */ @@ -34,6 +37,8 @@ class ForumUserControllerTest extends TestCase { protected function setUp(): void { $this->request = $this->createMock(IRequest::class); $this->forumUserMapper = $this->createMock(ForumUserMapper::class); + $this->roleMapper = $this->createMock(RoleMapper::class); + $this->roleMapper->method('findByUserId')->willReturn([]); $this->userService = $this->createMock(UserService::class); $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); @@ -42,6 +47,7 @@ class ForumUserControllerTest extends TestCase { Application::APP_ID, $this->request, $this->forumUserMapper, + $this->roleMapper, $this->userService, $this->userSession, $this->logger @@ -124,7 +130,7 @@ class ForumUserControllerTest extends TestCase { $this->assertEquals(['error' => 'Forum user not found'], $response->getData()); } - public function testShowWithMeReturnsNotFoundWhenForumUserDoesNotExist(): void { + public function testShowWithMeReturnsMinimalResponseWhenForumUserDoesNotExist(): void { $nextcloudUserId = 'user-without-forum-profile'; $user = $this->createMock(IUser::class); @@ -136,10 +142,17 @@ class ForumUserControllerTest extends TestCase { ->with($nextcloudUserId) ->willThrowException(new DoesNotExistException('Forum user not found')); + $this->roleMapper->expects($this->once()) + ->method('findByUserId') + ->with($nextcloudUserId) + ->willReturn([]); + $response = $this->controller->show('me'); - $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); - $this->assertEquals(['error' => 'Forum user not found'], $response->getData()); + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals($nextcloudUserId, $data['userId']); + $this->assertEquals([], $data['roles']); } public function testCreateForumUserSuccessfully(): void { diff --git a/tests/unit/Controller/RoleControllerTest.php b/tests/unit/Controller/RoleControllerTest.php index b116df0..98c0c24 100644 --- a/tests/unit/Controller/RoleControllerTest.php +++ b/tests/unit/Controller/RoleControllerTest.php @@ -57,8 +57,10 @@ class RoleControllerTest extends TestCase { // Verify Admin role always has all permissions $this->assertEquals($adminRoleId, $role->getId()); $this->assertTrue($role->getCanAccessAdminTools()); + $this->assertTrue($role->getCanManageUsers()); $this->assertTrue($role->getCanEditRoles()); $this->assertTrue($role->getCanEditCategories()); + $this->assertTrue($role->getCanEditBbcodes()); return $role; }); @@ -71,14 +73,18 @@ class RoleControllerTest extends TestCase { '#ff0000', false, // Try to disable - should be forced to true false, // Try to disable - should be forced to true - false // Try to disable - should be forced to true + false, // Try to disable - should be forced to true + false, // Try to disable - should be forced to true + false, // Try to disable - should be forced to true ); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertTrue($data['canAccessAdminTools']); + $this->assertTrue($data['canManageUsers']); $this->assertTrue($data['canEditRoles']); $this->assertTrue($data['canEditCategories']); + $this->assertTrue($data['canEditBbcodes']); } public function testUpdateNonAdminRoleAllowsPermissionChanges(): void { @@ -107,9 +113,11 @@ class RoleControllerTest extends TestCase { 'Moderator role', null, null, - false, // Changed from true - true, // Kept true - false // Changed from true + false, // canAccessAdminTools — changed from true + null, // canManageUsers — unchanged + true, // canEditRoles — kept true + false, // canEditCategories — changed from true + null, // canEditBbcodes — unchanged ); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); @@ -250,8 +258,10 @@ class RoleControllerTest extends TestCase { $this->assertEquals($roleId, $data['id']); $this->assertEquals('Moderator', $data['name']); $this->assertTrue($data['canAccessAdminTools']); + $this->assertFalse($data['canManageUsers']); $this->assertFalse($data['canEditRoles']); $this->assertTrue($data['canEditCategories']); + $this->assertFalse($data['canEditBbcodes']); } public function testShowReturnsNotFoundWhenRoleDoesNotExist(): void { @@ -283,8 +293,10 @@ class RoleControllerTest extends TestCase { $this->assertEquals($colorLight, $role->getColorLight()); $this->assertEquals($colorDark, $role->getColorDark()); $this->assertTrue($role->getCanAccessAdminTools()); + $this->assertFalse($role->getCanManageUsers()); $this->assertFalse($role->getCanEditRoles()); $this->assertTrue($role->getCanEditCategories()); + $this->assertFalse($role->getCanEditBbcodes()); // Simulate DB setting ID $role->setId(4); @@ -297,8 +309,10 @@ class RoleControllerTest extends TestCase { $colorLight, $colorDark, true, // canAccessAdminTools + false, // canManageUsers false, // canEditRoles - true // canEditCategories + true, // canEditCategories + false, // canEditBbcodes ); $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); @@ -478,11 +492,13 @@ class RoleControllerTest extends TestCase { $this->roleMapper->expects($this->once()) ->method('update') ->willReturnCallback(function ($role) use ($guestRoleId) { - // Verify Guest role never has admin permissions + // Verify Guest role never has management permissions $this->assertEquals($guestRoleId, $role->getId()); $this->assertFalse($role->getCanAccessAdminTools()); + $this->assertFalse($role->getCanManageUsers()); $this->assertFalse($role->getCanEditRoles()); $this->assertFalse($role->getCanEditCategories()); + $this->assertFalse($role->getCanEditBbcodes()); return $role; }); @@ -495,14 +511,18 @@ class RoleControllerTest extends TestCase { '#cccccc', true, // Try to enable - should be forced to false true, // Try to enable - should be forced to false - true // Try to enable - should be forced to false + true, // Try to enable - should be forced to false + true, // Try to enable - should be forced to false + true, // Try to enable - should be forced to false ); $this->assertEquals(Http::STATUS_OK, $response->getStatus()); $data = $response->getData(); $this->assertFalse($data['canAccessAdminTools']); + $this->assertFalse($data['canManageUsers']); $this->assertFalse($data['canEditRoles']); $this->assertFalse($data['canEditCategories']); + $this->assertFalse($data['canEditBbcodes']); } public function testUpdateGuestPermissionsEnforcesNoModerate(): void { @@ -573,13 +593,15 @@ class RoleControllerTest extends TestCase { $this->assertTrue($data['success']); } - private function createRole(int $id, string $name, bool $canAccessAdminTools, bool $canEditRoles, bool $canEditCategories, bool $isSystemRole = false, string $roleType = Role::ROLE_TYPE_CUSTOM): Role { + private function createRole(int $id, string $name, bool $canAccessAdminTools, bool $canEditRoles, bool $canEditCategories, bool $isSystemRole = false, string $roleType = Role::ROLE_TYPE_CUSTOM, bool $canManageUsers = false, bool $canEditBbcodes = false): Role { $role = new Role(); $role->setId($id); $role->setName($name); $role->setCanAccessAdminTools($canAccessAdminTools); + $role->setCanManageUsers($canManageUsers); $role->setCanEditRoles($canEditRoles); $role->setCanEditCategories($canEditCategories); + $role->setCanEditBbcodes($canEditBbcodes); $role->setIsSystemRole($isSystemRole); $role->setRoleType($roleType); $role->setCreatedAt(time()); diff --git a/tests/unit/Controller/ServerAdminControllerTest.php b/tests/unit/Controller/ServerAdminControllerTest.php index 5f24ff9..be0ed90 100644 --- a/tests/unit/Controller/ServerAdminControllerTest.php +++ b/tests/unit/Controller/ServerAdminControllerTest.php @@ -6,8 +6,11 @@ namespace OCA\Forum\Tests\Controller; use OCA\Forum\AppInfo\Application; use OCA\Forum\Controller\ServerAdminController; +use OCA\Forum\Db\RoleMapper; use OCA\Forum\Service\StatsService; +use OCA\Forum\Service\UserRoleService; use OCP\IRequest; +use OCP\IUserManager; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -15,6 +18,12 @@ use Psr\Log\LoggerInterface; class ServerAdminControllerTest extends TestCase { private ServerAdminController $controller; + /** @var RoleMapper&MockObject */ + private RoleMapper $roleMapper; + /** @var UserRoleService&MockObject */ + private UserRoleService $userRoleService; + /** @var IUserManager&MockObject */ + private IUserManager $userManager; /** @var StatsService&MockObject */ private StatsService $statsService; /** @var LoggerInterface&MockObject */ @@ -24,12 +33,18 @@ class ServerAdminControllerTest extends TestCase { protected function setUp(): void { $this->request = $this->createMock(IRequest::class); + $this->roleMapper = $this->createMock(RoleMapper::class); + $this->userRoleService = $this->createMock(UserRoleService::class); + $this->userManager = $this->createMock(IUserManager::class); $this->statsService = $this->createMock(StatsService::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new ServerAdminController( Application::APP_ID, $this->request, + $this->roleMapper, + $this->userRoleService, + $this->userManager, $this->statsService, $this->logger ); diff --git a/tests/unit/Controller/UserRoleControllerTest.php b/tests/unit/Controller/UserRoleControllerTest.php index 56cd919..bbff711 100644 --- a/tests/unit/Controller/UserRoleControllerTest.php +++ b/tests/unit/Controller/UserRoleControllerTest.php @@ -328,8 +328,10 @@ class UserRoleControllerTest extends TestCase { $role->setRoleType($roleType); $role->setIsSystemRole($roleType !== Role::ROLE_TYPE_CUSTOM); $role->setCanAccessAdminTools($roleType === Role::ROLE_TYPE_ADMIN); + $role->setCanManageUsers($roleType === Role::ROLE_TYPE_ADMIN); $role->setCanEditRoles($roleType === Role::ROLE_TYPE_ADMIN); $role->setCanEditCategories($roleType === Role::ROLE_TYPE_ADMIN); + $role->setCanEditBbcodes($roleType === Role::ROLE_TYPE_ADMIN); $role->setCreatedAt(time()); return $role; } diff --git a/tests/unit/Middleware/PermissionMiddlewareTest.php b/tests/unit/Middleware/PermissionMiddlewareTest.php index 56a80fa..705d19b 100644 --- a/tests/unit/Middleware/PermissionMiddlewareTest.php +++ b/tests/unit/Middleware/PermissionMiddlewareTest.php @@ -58,6 +58,14 @@ class TestPermissionController extends Controller { #[RequirePermission('canEditCategories')] public function methodWithOrGroupAndUngrouped(): void { } + + #[RequirePermission('canManageUsers')] + public function methodWithManageUsersPermission(): void { + } + + #[RequirePermission('canEditBbcodes')] + public function methodWithEditBBCodesPermission(): void { + } } class PermissionMiddlewareTest extends TestCase { @@ -532,6 +540,74 @@ class PermissionMiddlewareTest extends TestCase { $this->assertTrue(true); } + public function testCanManageUsersPermissionAllowsAccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user1'); + $this->userSession->method('getUser')->willReturn($user); + $this->config->method('getAppValueBool') + ->with('allow_guest_access', false, true) + ->willReturn(false); + + $this->permissionService->expects($this->once()) + ->method('hasGlobalPermission') + ->with('user1', 'canManageUsers') + ->willReturn(true); + + $this->middleware->beforeController($this->controller, 'methodWithManageUsersPermission'); + $this->assertTrue(true); + } + + public function testCanManageUsersPermissionDeniesAccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user1'); + $this->userSession->method('getUser')->willReturn($user); + $this->config->method('getAppValueBool') + ->with('allow_guest_access', false, true) + ->willReturn(false); + + $this->permissionService->expects($this->once()) + ->method('hasGlobalPermission') + ->with('user1', 'canManageUsers') + ->willReturn(false); + + $this->expectException(OCSForbiddenException::class); + $this->middleware->beforeController($this->controller, 'methodWithManageUsersPermission'); + } + + public function testCanEditBBCodesPermissionAllowsAccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user1'); + $this->userSession->method('getUser')->willReturn($user); + $this->config->method('getAppValueBool') + ->with('allow_guest_access', false, true) + ->willReturn(false); + + $this->permissionService->expects($this->once()) + ->method('hasGlobalPermission') + ->with('user1', 'canEditBbcodes') + ->willReturn(true); + + $this->middleware->beforeController($this->controller, 'methodWithEditBBCodesPermission'); + $this->assertTrue(true); + } + + public function testCanEditBBCodesPermissionDeniesAccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('user1'); + $this->userSession->method('getUser')->willReturn($user); + $this->config->method('getAppValueBool') + ->with('allow_guest_access', false, true) + ->willReturn(false); + + $this->permissionService->expects($this->once()) + ->method('hasGlobalPermission') + ->with('user1', 'canEditBbcodes') + ->willReturn(false); + + $this->expectException(OCSForbiddenException::class); + $this->middleware->beforeController($this->controller, 'methodWithEditBBCodesPermission'); + } + public function testAuthenticatedUserBypassesGuestRestrictions(): void { $user = $this->createMock(IUser::class); $user->method('getUID')->willReturn('user1'); diff --git a/tests/unit/Service/PermissionServiceTest.php b/tests/unit/Service/PermissionServiceTest.php index f8b750b..a869192 100644 --- a/tests/unit/Service/PermissionServiceTest.php +++ b/tests/unit/Service/PermissionServiceTest.php @@ -127,6 +127,43 @@ class PermissionServiceTest extends TestCase { $this->assertTrue($result); } + public function testHasGlobalPermissionCanManageUsers(): void { + $userId = 'user1'; + $role = $this->createRole(1, 'Manager', false, false, false, false, Role::ROLE_TYPE_CUSTOM, true, false); + + $this->roleMapper->expects($this->once()) + ->method('findByUserId') + ->with($userId) + ->willReturn([$role]); + + $this->assertTrue($this->service->hasGlobalPermission($userId, 'canManageUsers')); + $this->assertFalse($role->getCanEditBbcodes()); + } + + public function testHasGlobalPermissionCanEditBbcodes(): void { + $userId = 'user1'; + $role = $this->createRole(1, 'BBCodeEditor', false, false, false, false, Role::ROLE_TYPE_CUSTOM, false, true); + + $this->roleMapper->expects($this->once()) + ->method('findByUserId') + ->with($userId) + ->willReturn([$role]); + + $this->assertTrue($this->service->hasGlobalPermission($userId, 'canEditBbcodes')); + $this->assertFalse($role->getCanManageUsers()); + } + + public function testHasGlobalPermissionReturnsFalseForNewPermissionsWhenNotSet(): void { + $userId = 'user1'; + $role = $this->createRole(1, 'Basic', false, false, false, false, Role::ROLE_TYPE_CUSTOM); + + $this->roleMapper->method('findByUserId') + ->with($userId) + ->willReturn([$role]); + + $this->assertFalse($this->service->hasGlobalPermission($userId, 'canManageUsers')); + } + public function testHasGlobalPermissionHandlesException(): void { $userId = 'user1'; $permission = 'canEditRoles'; @@ -776,13 +813,15 @@ class PermissionServiceTest extends TestCase { return $userRole; } - private function createRole(int $id, string $name, bool $canAccessAdminTools, bool $canEditRoles, bool $canEditCategories, bool $isSystemRole = false, string $roleType = Role::ROLE_TYPE_CUSTOM): Role { + private function createRole(int $id, string $name, bool $canAccessAdminTools, bool $canEditRoles, bool $canEditCategories, bool $isSystemRole = false, string $roleType = Role::ROLE_TYPE_CUSTOM, bool $canManageUsers = false, bool $canEditBbcodes = false): Role { $role = new Role(); $role->setId($id); $role->setName($name); $role->setCanAccessAdminTools($canAccessAdminTools); + $role->setCanManageUsers($canManageUsers); $role->setCanEditRoles($canEditRoles); $role->setCanEditCategories($canEditCategories); + $role->setCanEditBbcodes($canEditBbcodes); $role->setIsSystemRole($isSystemRole); $role->setRoleType($roleType); $role->setCreatedAt(time());