feat(admin): split role permissions for each section

This commit is contained in:
2026-03-25 16:44:45 +02:00
parent b139c4988c
commit 6174bed49a
25 changed files with 1152 additions and 113 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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<Http::STATUS_CREATED, array<string, mixed>, 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<Http::STATUS_OK, array<string, mixed>, 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 {

View File

@@ -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<Http::STATUS_OK, array{roles: list<array<string, mixed>>}, 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<Http::STATUS_OK, array{success: bool, message: string}, array{}>
*
* 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
*

View File

@@ -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(),

View File

@@ -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),

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// 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');
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -140,7 +140,7 @@
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="canEditRoles"
v-if="canManageUsers"
:name="strings.navAdminUsers"
:to="{ path: '/admin/users' }"
:active="isPathActive('/admin/users', true)"
@@ -173,7 +173,7 @@
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="canAccessAdminTools"
v-if="canEditBbcodes"
:name="strings.navAdminBBCodes"
:to="{ path: '/admin/bbcodes' }"
:active="isPathActive('/admin/bbcodes', true)"
@@ -266,8 +266,14 @@ export default defineComponent({
setup() {
const { categoryHeaders, fetchCategories } = useCategories()
const { userId, displayName, fetchCurrentUser } = useCurrentUser()
const { canAccessAdmin, canAccessAdminTools, canEditRoles, canEditCategories, fetchUserRoles } =
useUserRole()
const {
canAccessAdmin,
canAccessAdminTools,
canManageUsers,
canEditRoles,
canEditCategories,
canEditBbcodes,
} = useUserRole()
const { categoryId: currentThreadCategoryId, fetchThread, clearThread } = useCurrentThread()
const { isGuest, guestDisplayName, fetchGuestIdentity } = useGuestSession()
@@ -275,13 +281,14 @@ export default defineComponent({
categoryHeaders,
fetchCategories,
fetchCurrentUser,
fetchUserRoles,
userId,
displayName,
canAccessAdmin,
canAccessAdminTools,
canManageUsers,
canEditRoles,
canEditCategories,
canEditBbcodes,
currentThreadCategoryId,
fetchThread,
clearThread,
@@ -329,13 +336,8 @@ export default defineComponent({
this.fetchCurrentUser(),
])
// If user was fetched successfully, also fetch their roles
if (userResult.status === 'fulfilled' && userResult.value) {
// Wait for roles to load before showing the sidebar
await this.fetchUserRoles(userResult.value.userId).catch((e) => {
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' })
}
},

View File

@@ -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<ForumUser | null>(null)
const loading = ref<boolean>(false)
@@ -9,6 +10,8 @@ const error = ref<string | null>(null)
const loaded = ref<boolean>(false)
export function useCurrentUser() {
const { setRoles } = useUserRole()
const fetchCurrentUser = async (force = false): Promise<ForumUser | null> => {
// 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<ForumUser>('/users/me')
currentForumUser.value = response.data
const response = await ocs.get<ForumUser & { roles?: Role[] }>('/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

View File

@@ -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<boolean>(false)
const currentUserId = ref<string | null>(null)
export function useUserRole() {
const fetchUserRoles = async (userId: string, force = false): Promise<Role[]> => {
if (loaded.value && !force && currentUserId.value === userId) {
return userRoles.value
}
try {
loading.value = true
error.value = null
const response = await ocs.get<Role[]>(`/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<boolean>(() => {
@@ -44,8 +30,8 @@ export function useUserRole() {
return userRoles.value.some((role) => role.canAccessAdminTools)
})
const canAccessAdmin = computed<boolean>(() => {
return canAccessAdminTools.value || canEditRoles.value || canEditCategories.value
const canManageUsers = computed<boolean>(() => {
return userRoles.value.some((role) => role.canManageUsers)
})
const canEditRoles = computed<boolean>(() => {
@@ -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<boolean>(() => {
return userRoles.value.some((role) => role.canEditBbcodes)
})
const canAccessAdmin = computed<boolean>(() => {
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,
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -121,6 +121,15 @@
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-model="formData.canManageUsers"
:disabled="isAdmin || isGuest"
class="permission-switch"
>
<strong>{{ strings.canManageUsers }}</strong>
<span class="checkbox-desc muted">{{ strings.canManageUsersDesc }}</span>
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-model="formData.canEditRoles"
:disabled="isAdmin || isGuest"
@@ -138,6 +147,15 @@
<strong>{{ strings.canEditCategories }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
v-model="formData.canEditBbcodes"
:disabled="isAdmin || isGuest"
class="permission-switch"
>
<strong>{{ strings.canEditBbcodes }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditBbcodesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
</FormSection>
@@ -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<number, CategoryPermission>,
@@ -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

View File

@@ -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 {

View File

@@ -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());

View File

@@ -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
);

View File

@@ -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;
}

View File

@@ -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');

View File

@@ -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());