feat: allow editing can post/reply permissions

This commit is contained in:
2026-03-09 23:14:46 +02:00
parent 0574535f53
commit 9a33146bd8
17 changed files with 535 additions and 184 deletions

View File

@@ -303,6 +303,12 @@ test-docker:
echo "\x1b[33mRunning tests in container $$CONTAINER_ID for app $$APP_DIR\x1b[0m"; \
docker exec $$CONTAINER_ID phpunit -c apps-shared/$$APP_DIR/tests/phpunit.docker.xml
# test-frontend:
# - Run frontend (Vitest) tests
.PHONY: test-frontend
test-frontend:
$(pnpm_cmd) vitest run
# lint:
# - Lint JS via pnpm and PHP via composer script "lint"
.PHONY: lint

View File

@@ -402,7 +402,7 @@ class CategoryController extends OCSController {
* Update permissions for a category
*
* @param int $id Category ID
* @param list<array{roleId: int, canView: bool, canModerate: bool}> $permissions Role permissions array
* @param list<array{roleId: int, canView: bool, canPost: bool, canReply: bool, canModerate: bool}> $permissions Role permissions array
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Permissions updated
@@ -439,9 +439,8 @@ class CategoryController extends OCSController {
$categoryPerm->setTargetType(CategoryPerm::TARGET_TYPE_ROLE);
$categoryPerm->setTargetId((string)$perm['roleId']);
$categoryPerm->setCanView($perm['canView'] ?? false);
// canPost and canReply default to canView value
$categoryPerm->setCanPost($perm['canView'] ?? false);
$categoryPerm->setCanReply($perm['canView'] ?? false);
$categoryPerm->setCanPost($perm['canPost'] ?? $perm['canView'] ?? false);
$categoryPerm->setCanReply($perm['canReply'] ?? $perm['canPost'] ?? $perm['canView'] ?? false);
// Guest and Default roles never have moderate permission
try {

View File

@@ -257,7 +257,7 @@ class RoleController extends OCSController {
* Update permissions for a role
*
* @param int $id Role ID
* @param list<array{categoryId: int, canView: bool, canModerate: bool}> $permissions Permissions array
* @param list<array{categoryId: int, canView: bool, canPost: bool, canReply: bool, canModerate: bool}> $permissions Permissions array
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Permissions updated
@@ -280,10 +280,8 @@ class RoleController extends OCSController {
$categoryPerm->setTargetType(CategoryPerm::TARGET_TYPE_ROLE);
$categoryPerm->setTargetId((string)$id);
$categoryPerm->setCanView($perm['canView'] ?? false);
// canPost and canReply default to canView value
// This ensures that if a role can view a category, they can also post/reply unless explicitly restricted
$categoryPerm->setCanPost($perm['canView'] ?? false);
$categoryPerm->setCanReply($perm['canView'] ?? false);
$categoryPerm->setCanPost($perm['canPost'] ?? $perm['canView'] ?? false);
$categoryPerm->setCanReply($perm['canReply'] ?? $perm['canPost'] ?? $perm['canView'] ?? false);
// Guest and Default roles never have moderate permission
$categoryPerm->setCanModerate($role->isModeratorRestricted() ? false : ($perm['canModerate'] ?? false));

View File

@@ -128,7 +128,7 @@ class TeamController extends OCSController {
* Update category permissions for a team (circle)
*
* @param string $id Team/circle single ID
* @param list<array{categoryId: int, canView: bool, canModerate: bool}> $permissions Permissions array
* @param list<array{categoryId: int, canView: bool, canPost: bool, canReply: bool, canModerate: bool}> $permissions Permissions array
* @return DataResponse<Http::STATUS_OK, array{success: bool}, array{}>
*
* 200: Permissions updated
@@ -163,8 +163,8 @@ class TeamController extends OCSController {
$categoryPerm->setTargetType(CategoryPerm::TARGET_TYPE_TEAM);
$categoryPerm->setTargetId($id);
$categoryPerm->setCanView($perm['canView'] ?? false);
$categoryPerm->setCanPost($perm['canView'] ?? false);
$categoryPerm->setCanReply($perm['canView'] ?? false);
$categoryPerm->setCanPost($perm['canPost'] ?? $perm['canView'] ?? false);
$categoryPerm->setCanReply($perm['canReply'] ?? $perm['canPost'] ?? $perm['canView'] ?? false);
$categoryPerm->setCanModerate($perm['canModerate'] ?? false);
$this->categoryPermMapper->insert($categoryPerm);

View File

@@ -32,15 +32,6 @@ class Version17Date20260118000000 extends SimpleMigrationStep {
* @param array $options
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
// Re-run seeding to ensure all required data exists
// Pass throwOnError=false to avoid PostgreSQL transaction abort issues
// If seeding fails, users can run "occ forum:repair-seeds" to retry
try {
SeedHelper::seedAll($output, false);
} catch (\Exception $e) {
// This should not happen with throwOnError=false, but handle it gracefully
$this->logger->error('Forum migration: Seeding failed unexpectedly', ['exception' => $e->getMessage()]);
$output->warning('Forum: Seeding failed. Run "occ forum:repair-seeds" after enabling the app to complete setup.');
}
// Seeding moved to Version21 postSchemaChange, after target_type/target_id columns exist
}
}

View File

@@ -11,6 +11,7 @@ use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Psr\Log\LoggerInterface;
/**
* Version 21 Migration (runs after data migration in Version 20):
@@ -19,6 +20,11 @@ use OCP\Migration\SimpleMigrationStep;
* - Create new indexes for (category_id, target_type, target_id)
*/
class Version21Date20260301000001 extends SimpleMigrationStep {
public function __construct(
private LoggerInterface $logger,
) {
}
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
@@ -63,4 +69,16 @@ class Version21Date20260301000001 extends SimpleMigrationStep {
return $schema;
}
/**
* Run seeding after schema is finalized (target_type/target_id columns now exist)
*/
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
try {
SeedHelper::seedAll($output, false);
} catch (\Exception $e) {
$this->logger->error('Forum migration: Seeding failed unexpectedly', ['exception' => $e->getMessage()]);
$output->warning('Forum: Seeding failed. Run "occ forum:repair-seeds" after enabling the app to complete setup.');
}
}
}

View File

@@ -3473,6 +3473,8 @@
"required": [
"roleId",
"canView",
"canPost",
"canReply",
"canModerate"
],
"properties": {
@@ -3483,6 +3485,12 @@
"canView": {
"type": "boolean"
},
"canPost": {
"type": "boolean"
},
"canReply": {
"type": "boolean"
},
"canModerate": {
"type": "boolean"
}
@@ -7444,6 +7452,8 @@
"required": [
"categoryId",
"canView",
"canPost",
"canReply",
"canModerate"
],
"properties": {
@@ -7454,6 +7464,12 @@
"canView": {
"type": "boolean"
},
"canPost": {
"type": "boolean"
},
"canReply": {
"type": "boolean"
},
"canModerate": {
"type": "boolean"
}
@@ -8035,6 +8051,8 @@
"required": [
"categoryId",
"canView",
"canPost",
"canReply",
"canModerate"
],
"properties": {
@@ -8045,6 +8063,12 @@
"canView": {
"type": "boolean"
},
"canPost": {
"type": "boolean"
},
"canReply": {
"type": "boolean"
},
"canModerate": {
"type": "boolean"
}

View File

@@ -3473,6 +3473,8 @@
"required": [
"roleId",
"canView",
"canPost",
"canReply",
"canModerate"
],
"properties": {
@@ -3483,6 +3485,12 @@
"canView": {
"type": "boolean"
},
"canPost": {
"type": "boolean"
},
"canReply": {
"type": "boolean"
},
"canModerate": {
"type": "boolean"
}
@@ -7444,6 +7452,8 @@
"required": [
"categoryId",
"canView",
"canPost",
"canReply",
"canModerate"
],
"properties": {
@@ -7454,6 +7464,12 @@
"canView": {
"type": "boolean"
},
"canPost": {
"type": "boolean"
},
"canReply": {
"type": "boolean"
},
"canModerate": {
"type": "boolean"
}
@@ -8035,6 +8051,8 @@
"required": [
"categoryId",
"canView",
"canPost",
"canReply",
"canModerate"
],
"properties": {
@@ -8045,6 +8063,12 @@
"canView": {
"type": "boolean"
},
"canPost": {
"type": "boolean"
},
"canReply": {
"type": "boolean"
},
"canModerate": {
"type": "boolean"
}

View File

@@ -146,7 +146,7 @@
<NcAppNavigationItem
:name="strings.navAdminRoles"
:to="{ path: '/admin/roles' }"
:active="isPathActive('/admin/roles', true)"
:active="isPathActive(['/admin/roles', '/admin/teams'], true)"
>
<template #icon>
<ShieldAccountIcon :size="20" />
@@ -364,11 +364,22 @@ export default defineComponent({
}
},
isPathActive(path: string, usePrefix = false): boolean {
if (usePrefix) {
return this.$route.path.startsWith(path)
isPathActive(path: string | string[], usePrefix = false): boolean {
if (!Array.isArray(path)) {
path = [path]
}
return this.$route.path === path
for (const p of path) {
if (usePrefix) {
if (this.$route.path.startsWith(p)) {
return true
}
} else {
if (this.$route.path === p) {
return true
}
}
}
return false
},
toggleHeader(headerId: number): void {

View File

@@ -75,9 +75,9 @@ function createHeaders(): CategoryHeader[] {
function createPermissions(): Record<number, CategoryPermission> {
return {
10: { canView: true, canModerate: false },
11: { canView: false, canModerate: false },
20: { canView: true, canModerate: true },
10: { canView: true, canPost: false, canReply: false, canModerate: false },
11: { canView: false, canPost: false, canReply: false, canModerate: false },
20: { canView: true, canPost: true, canReply: true, canModerate: true },
}
}
@@ -153,6 +153,8 @@ describe('CategoryPermissionsTable', () => {
const header = wrapper.find('.table-header')
expect(header.text()).toContain('Category')
expect(header.text()).toContain('Can view')
expect(header.text()).toContain('Can post')
expect(header.text()).toContain('Can reply')
expect(header.text()).toContain('Can moderate')
})
})
@@ -167,8 +169,8 @@ describe('CategoryPermissionsTable', () => {
},
})
const checkboxes = wrapper.findAll('input[type="checkbox"]')
// 3 categories × 2 (view + moderate) + 2 headers × 2 (view + moderate) = 10
expect(checkboxes).toHaveLength(10)
// 3 categories × 4 perms + 2 headers × 4 perms = 20
expect(checkboxes).toHaveLength(20)
})
})
@@ -183,7 +185,7 @@ describe('CategoryPermissionsTable', () => {
})
// Header view checkboxes and category view checkboxes should be disabled
const disabledLabels = wrapper.findAll('.nc-checkbox.disabled')
// 2 header view + 3 category view = 5 disabled checkboxes
// 2 header view + 3 category view = 5
expect(disabledLabels.length).toBe(5)
})
@@ -196,22 +198,24 @@ describe('CategoryPermissionsTable', () => {
},
})
const disabledLabels = wrapper.findAll('.nc-checkbox.disabled')
// 2 header moderate + 3 category moderate = 5 disabled checkboxes
// 2 header moderate + 3 category moderate = 5
expect(disabledLabels.length).toBe(5)
})
it('should disable all checkboxes when both disable props are true', () => {
it('should disable all checkboxes when all disable props are true', () => {
const wrapper = mount(CategoryPermissionsTable, {
props: {
categoryHeaders: createHeaders(),
permissions: createPermissions(),
disableView: true,
disablePost: true,
disableReply: true,
disableModerate: true,
},
})
const disabledLabels = wrapper.findAll('.nc-checkbox.disabled')
// All 10 checkboxes disabled
expect(disabledLabels.length).toBe(10)
// All 20 checkboxes disabled
expect(disabledLabels.length).toBe(20)
})
it('should not disable any checkboxes by default', () => {
@@ -260,7 +264,7 @@ describe('CategoryPermissionsTable', () => {
// Category 10 (Announcements) currently has canModerate=false
const rows = wrapper.findAll('.table-row')
const announcementsRow = rows[0]
const moderateCheckbox = announcementsRow.findAll('.nc-checkbox')[1]
const moderateCheckbox = announcementsRow.findAll('.nc-checkbox')[3]
await moderateCheckbox.trigger('click')
@@ -272,9 +276,9 @@ describe('CategoryPermissionsTable', () => {
describe('header toggle behavior', () => {
it('should check all categories in header when header view is toggled on', () => {
const permissions: Record<number, CategoryPermission> = {
10: { canView: false, canModerate: false },
11: { canView: false, canModerate: false },
20: { canView: false, canModerate: false },
10: { canView: false, canPost: false, canReply: false, canModerate: false },
11: { canView: false, canPost: false, canReply: false, canModerate: false },
20: { canView: false, canPost: false, canReply: false, canModerate: false },
}
const wrapper = mount(CategoryPermissionsTable, {
props: {
@@ -298,9 +302,9 @@ describe('CategoryPermissionsTable', () => {
it('should uncheck all categories in header when header view is toggled off', () => {
const permissions: Record<number, CategoryPermission> = {
10: { canView: true, canModerate: false },
11: { canView: true, canModerate: false },
20: { canView: true, canModerate: false },
10: { canView: true, canPost: false, canReply: false, canModerate: false },
11: { canView: true, canPost: false, canReply: false, canModerate: false },
20: { canView: true, canPost: false, canReply: false, canModerate: false },
}
const wrapper = mount(CategoryPermissionsTable, {
props: {
@@ -324,9 +328,9 @@ describe('CategoryPermissionsTable', () => {
it('should check all categories in header when header moderate is toggled on', () => {
const permissions: Record<number, CategoryPermission> = {
10: { canView: true, canModerate: false },
11: { canView: true, canModerate: false },
20: { canView: true, canModerate: false },
10: { canView: true, canPost: false, canReply: false, canModerate: false },
11: { canView: true, canPost: false, canReply: false, canModerate: false },
20: { canView: true, canPost: false, canReply: false, canModerate: false },
}
const wrapper = mount(CategoryPermissionsTable, {
props: {
@@ -350,9 +354,9 @@ describe('CategoryPermissionsTable', () => {
describe('header state computation', () => {
it('should show indeterminate when some categories in header are checked', () => {
const permissions: Record<number, CategoryPermission> = {
10: { canView: true, canModerate: false },
11: { canView: false, canModerate: false },
20: { canView: true, canModerate: true },
10: { canView: true, canPost: false, canReply: false, canModerate: false },
11: { canView: false, canPost: false, canReply: false, canModerate: false },
20: { canView: true, canPost: true, canReply: true, canModerate: true },
}
const wrapper = mount(CategoryPermissionsTable, {
props: {
@@ -380,9 +384,9 @@ describe('CategoryPermissionsTable', () => {
it('should show checked when all categories in header are checked', () => {
const permissions: Record<number, CategoryPermission> = {
10: { canView: true, canModerate: true },
11: { canView: true, canModerate: true },
20: { canView: false, canModerate: false },
10: { canView: true, canPost: true, canReply: true, canModerate: true },
11: { canView: true, canPost: true, canReply: true, canModerate: true },
20: { canView: false, canPost: false, canReply: false, canModerate: false },
}
const wrapper = mount(CategoryPermissionsTable, {
props: {
@@ -410,9 +414,9 @@ describe('CategoryPermissionsTable', () => {
it('should show unchecked when no categories in header are checked', () => {
const permissions: Record<number, CategoryPermission> = {
10: { canView: false, canModerate: false },
11: { canView: false, canModerate: false },
20: { canView: true, canModerate: true },
10: { canView: false, canPost: false, canReply: false, canModerate: false },
11: { canView: false, canPost: false, canReply: false, canModerate: false },
20: { canView: true, canPost: true, canReply: true, canModerate: true },
}
const wrapper = mount(CategoryPermissionsTable, {
props: {
@@ -449,12 +453,17 @@ describe('CategoryPermissionsTable', () => {
const vm = wrapper.vm as unknown as VM
const result = vm.ensurePermission(999)
expect(result).toEqual({ canView: false, canModerate: false })
expect(result).toEqual({
canView: false,
canPost: false,
canReply: false,
canModerate: false,
})
})
it('should return existing permission entry when it exists', () => {
const permissions: Record<number, CategoryPermission> = {
10: { canView: true, canModerate: true },
10: { canView: true, canPost: true, canReply: true, canModerate: true },
}
const wrapper = mount(CategoryPermissionsTable, {
props: {
@@ -469,7 +478,7 @@ describe('CategoryPermissionsTable', () => {
const vm = wrapper.vm as unknown as VM
const result = vm.ensurePermission(10)
expect(result).toEqual({ canView: true, canModerate: true })
expect(result).toEqual({ canView: true, canPost: true, canReply: true, canModerate: true })
})
})
})

View File

@@ -1,75 +1,135 @@
<template>
<div v-if="categoryHeaders.length > 0" class="permissions-table">
<div class="table-header">
<div class="col-category">{{ strings.category }}</div>
<div class="col-permission">{{ strings.canView }}</div>
<div class="col-permission">{{ strings.canModerate }}</div>
<div class="permissions-wrapper">
<NcNoteCard type="info">
<ul class="permissions-info-list">
<li v-html="strings.infoView" />
<li v-html="strings.infoPost" />
<li v-html="strings.infoReply" />
<li v-html="strings.infoModerate" />
</ul>
</NcNoteCard>
<div v-if="categoryHeaders.length > 0" class="permissions-table">
<div class="table-header">
<div class="col-category">{{ strings.category }}</div>
<div class="col-permission">{{ strings.canView }}</div>
<div class="col-permission">{{ strings.canPost }}</div>
<div class="col-permission">{{ strings.canReply }}</div>
<div class="col-permission">{{ strings.canModerate }}</div>
</div>
<template v-for="header in categoryHeaders" :key="`header-${header.id}`">
<!-- Header row -->
<div class="table-header-row">
<div class="header-name">{{ header.name }}</div>
<div class="header-permission">
<NcCheckboxRadioSwitch
:model-value="getHeaderViewState(header.id).checked"
:indeterminate="getHeaderViewState(header.id).indeterminate"
:disabled="disableView"
@update:model-value="toggleHeaderView(header.id)"
>
{{ strings.allowAll }}
</NcCheckboxRadioSwitch>
</div>
<div class="header-permission">
<NcCheckboxRadioSwitch
:model-value="getHeaderPostState(header.id).checked"
:indeterminate="getHeaderPostState(header.id).indeterminate"
:disabled="disablePost"
@update:model-value="toggleHeaderPost(header.id)"
>
{{ strings.allowAll }}
</NcCheckboxRadioSwitch>
</div>
<div class="header-permission">
<NcCheckboxRadioSwitch
:model-value="getHeaderReplyState(header.id).checked"
:indeterminate="getHeaderReplyState(header.id).indeterminate"
:disabled="disableReply"
@update:model-value="toggleHeaderReply(header.id)"
>
{{ strings.allowAll }}
</NcCheckboxRadioSwitch>
</div>
<div class="header-permission">
<NcCheckboxRadioSwitch
:model-value="getHeaderModerateState(header.id).checked"
:indeterminate="getHeaderModerateState(header.id).indeterminate"
:disabled="disableModerate"
@update:model-value="toggleHeaderModerate(header.id)"
>
{{ strings.allowAll }}
</NcCheckboxRadioSwitch>
</div>
</div>
<!-- Category rows under this header -->
<div v-for="category in header.categories" :key="category.id" class="table-row">
<div class="col-category">
<span class="category-name">{{ category.name }}</span>
<span v-if="category.description" class="category-desc muted">
{{ category.description }}
</span>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canView || false"
:disabled="disableView"
@update:model-value="updateCategoryView(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canPost || false"
:disabled="disablePost"
@update:model-value="updateCategoryPost(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canReply || false"
:disabled="disableReply"
@update:model-value="updateCategoryReply(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canModerate || false"
:disabled="disableModerate"
@update:model-value="updateCategoryModerate(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
</div>
</template>
</div>
<template v-for="header in categoryHeaders" :key="`header-${header.id}`">
<!-- Header row -->
<div class="table-header-row">
<div class="header-name">{{ header.name }}</div>
<div class="header-permission">
<NcCheckboxRadioSwitch
:model-value="getHeaderViewState(header.id).checked"
:indeterminate="getHeaderViewState(header.id).indeterminate"
:disabled="disableView"
@update:model-value="toggleHeaderView(header.id)"
/>
</div>
<div class="header-permission">
<NcCheckboxRadioSwitch
:model-value="getHeaderModerateState(header.id).checked"
:indeterminate="getHeaderModerateState(header.id).indeterminate"
:disabled="disableModerate"
@update:model-value="toggleHeaderModerate(header.id)"
/>
</div>
</div>
<!-- Category rows under this header -->
<div v-for="category in header.categories" :key="category.id" class="table-row">
<div class="col-category">
<span class="category-name">{{ category.name }}</span>
<span v-if="category.description" class="category-desc muted">
{{ category.description }}
</span>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canView || false"
:disabled="disableView"
@update:model-value="updateCategoryView(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
:model-value="permissions[category.id]?.canModerate || false"
:disabled="disableModerate"
@update:model-value="updateCategoryModerate(category.id, $event)"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
</div>
</template>
<div v-else class="muted">{{ strings.noCategories }}</div>
</div>
<div v-else class="muted">{{ strings.noCategories }}</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import { t } from '@nextcloud/l10n'
import type { CategoryHeader } from '@/types'
export interface CategoryPermission {
canView: boolean
canPost: boolean
canReply: boolean
canModerate: boolean
}
@@ -77,6 +137,7 @@ export default defineComponent({
name: 'CategoryPermissionsTable',
components: {
NcCheckboxRadioSwitch,
NcNoteCard,
},
props: {
categoryHeaders: {
@@ -91,6 +152,14 @@ export default defineComponent({
type: Boolean,
default: false,
},
disablePost: {
type: Boolean,
default: false,
},
disableReply: {
type: Boolean,
default: false,
},
disableModerate: {
type: Boolean,
default: false,
@@ -102,9 +171,36 @@ export default defineComponent({
strings: {
category: t('forum', 'Category'),
canView: t('forum', 'Can view'),
canPost: t('forum', 'Can post'),
canReply: t('forum', 'Can reply'),
canModerate: t('forum', 'Can moderate'),
allow: t('forum', 'Allow'),
allowAll: t('forum', 'Allow All'),
noCategories: t('forum', 'No categories available'),
infoView: t(
'forum',
'{bStart}View:{bEnd} Allows seeing the category and its threads.',
{ bStart: '<strong>', bEnd: '</strong>' },
{ escape: false },
),
infoPost: t(
'forum',
'{bStart}Post:{bEnd} Allows creating new threads in the category.',
{ bStart: '<strong>', bEnd: '</strong>' },
{ escape: false },
),
infoReply: t(
'forum',
'{bStart}Reply:{bEnd} Allows replying to existing threads in the category.',
{ bStart: '<strong>', bEnd: '</strong>' },
{ escape: false },
),
infoModerate: t(
'forum',
'{bStart}Moderate:{bEnd} Allows editing and deleting posts, pinning, locking, and moving threads in the category.',
{ bStart: '<strong>', bEnd: '</strong>' },
{ escape: false },
),
},
}
},
@@ -113,21 +209,24 @@ export default defineComponent({
if (!this.permissions[categoryId]) {
this.permissions[categoryId] = {
canView: false,
canPost: false,
canReply: false,
canModerate: false,
}
}
return this.permissions[categoryId]
},
getHeaderViewState(headerId: number): { checked: boolean; indeterminate: boolean } {
getHeaderState(
headerId: number,
key: keyof CategoryPermission,
): { checked: boolean; indeterminate: boolean } {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories || header.categories.length === 0) {
return { checked: false, indeterminate: false }
}
const checkedCount = header.categories.filter(
(cat) => this.permissions[cat.id]?.canView,
).length
const checkedCount = header.categories.filter((cat) => this.permissions[cat.id]?.[key]).length
const totalCount = header.categories.length
if (checkedCount === 0) {
@@ -139,59 +238,58 @@ export default defineComponent({
}
},
getHeaderModerateState(headerId: number): { checked: boolean; indeterminate: boolean } {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories || header.categories.length === 0) {
return { checked: false, indeterminate: false }
}
const checkedCount = header.categories.filter(
(cat) => this.permissions[cat.id]?.canModerate,
).length
const totalCount = header.categories.length
if (checkedCount === 0) {
return { checked: false, indeterminate: false }
} else if (checkedCount === totalCount) {
return { checked: true, indeterminate: false }
} else {
return { checked: false, indeterminate: true }
}
getHeaderViewState(headerId: number) {
return this.getHeaderState(headerId, 'canView')
},
getHeaderPostState(headerId: number) {
return this.getHeaderState(headerId, 'canPost')
},
getHeaderReplyState(headerId: number) {
return this.getHeaderState(headerId, 'canReply')
},
getHeaderModerateState(headerId: number) {
return this.getHeaderState(headerId, 'canModerate')
},
updateCategoryView(categoryId: number, checked: boolean): void {
this.ensurePermission(categoryId).canView = checked
this.$emit('update:permissions', this.permissions)
this.updatePermission(categoryId, 'canView', checked)
},
updateCategoryPost(categoryId: number, checked: boolean): void {
this.updatePermission(categoryId, 'canPost', checked)
},
updateCategoryReply(categoryId: number, checked: boolean): void {
this.updatePermission(categoryId, 'canReply', checked)
},
updateCategoryModerate(categoryId: number, checked: boolean): void {
this.updatePermission(categoryId, 'canModerate', checked)
},
updateCategoryModerate(categoryId: number, checked: boolean): void {
this.ensurePermission(categoryId).canModerate = checked
toggleHeader(headerId: number, key: keyof CategoryPermission): void {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return
const newValue = !this.getHeaderState(headerId, key).checked
header.categories.forEach((cat) => {
this.ensurePermission(cat.id)[key] = newValue
})
this.$emit('update:permissions', this.permissions)
},
toggleHeaderView(headerId: number): void {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return
const state = this.getHeaderViewState(headerId)
const newValue = !state.checked
header.categories.forEach((cat) => {
this.ensurePermission(cat.id).canView = newValue
})
this.$emit('update:permissions', this.permissions)
this.toggleHeader(headerId, 'canView')
},
toggleHeaderPost(headerId: number): void {
this.toggleHeader(headerId, 'canPost')
},
toggleHeaderReply(headerId: number): void {
this.toggleHeader(headerId, 'canReply')
},
toggleHeaderModerate(headerId: number): void {
this.toggleHeader(headerId, 'canModerate')
},
toggleHeaderModerate(headerId: number): void {
const header = this.categoryHeaders.find((h) => h.id === headerId)
if (!header || !header.categories) return
const state = this.getHeaderModerateState(headerId)
const newValue = !state.checked
header.categories.forEach((cat) => {
this.ensurePermission(cat.id).canModerate = newValue
})
updatePermission(categoryId: number, key: keyof CategoryPermission, checked: boolean): void {
this.ensurePermission(categoryId)[key] = checked
this.$emit('update:permissions', this.permissions)
},
},
@@ -199,6 +297,20 @@ export default defineComponent({
</script>
<style scoped lang="scss">
.permissions-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.permissions-info-list {
margin: 0;
padding-left: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.permissions-table {
display: flex;
flex-direction: column;
@@ -206,11 +318,12 @@ export default defineComponent({
background: var(--color-border);
border-radius: 8px;
overflow: hidden;
--perm-column-width: 125px;
.table-header,
.table-row {
display: grid;
grid-template-columns: 1fr 150px 150px;
grid-template-columns: 1fr repeat(4, var(--perm-column-width));
gap: 16px;
padding: 16px;
background: var(--color-main-background);
@@ -228,7 +341,7 @@ export default defineComponent({
.table-header-row {
display: grid;
grid-template-columns: 1fr 150px 150px;
grid-template-columns: 1fr repeat(4, var(--perm-column-width));
gap: 16px;
padding: 12px 16px;
background: var(--color-background-dark);

View File

@@ -132,6 +132,36 @@
<p class="help-text muted">{{ strings.viewRolesHelp }}</p>
</div>
<div class="form-group">
<label>{{ strings.postRoles }}</label>
<NcSelect
v-model="selectedPostTargets"
:options="postTargetOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.postRolesHelp }}</p>
</div>
<div class="form-group">
<label>{{ strings.replyRoles }}</label>
<NcSelect
v-model="selectedReplyTargets"
:options="replyTargetOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.replyRolesHelp }}</p>
</div>
<div class="form-group">
<label>{{ strings.moderateRoles }}</label>
<NcSelect
@@ -230,6 +260,8 @@ export default defineComponent({
roles: [] as Role[],
selectedHeader: null as { id: number; label: string } | null,
selectedViewTargets: [] as Array<{ id: number; label: string }>,
selectedPostTargets: [] as Array<{ id: number; label: string }>,
selectedReplyTargets: [] as Array<{ id: number; label: string }>,
selectedModerateTargets: [] as Array<{ id: number; label: string }>,
formData: {
headerId: null as number | null,
@@ -283,6 +315,10 @@ export default defineComponent({
),
viewRoles: t('forum', 'Can view'),
viewRolesHelp: t('forum', 'Select roles that can view this category and its threads'),
postRoles: t('forum', 'Can post'),
postRolesHelp: t('forum', 'Select roles that can create new threads in this category'),
replyRoles: t('forum', 'Can reply'),
replyRolesHelp: t('forum', 'Select roles that can reply to threads in this category'),
moderateRoles: t('forum', 'Can moderate'),
moderateRolesHelp: t(
'forum',
@@ -320,6 +356,22 @@ export default defineComponent({
label: role.name,
}))
},
postTargetOptions(): Array<{ id: number; label: string }> {
return this.roles
.filter((role) => !isAdminRole(role))
.map((role) => ({
id: role.id,
label: role.name,
}))
},
replyTargetOptions(): Array<{ id: number; label: string }> {
return this.roles
.filter((role) => !isAdminRole(role))
.map((role) => ({
id: role.id,
label: role.name,
}))
},
moderateTargetOptions(): Array<{ id: number; label: string }> {
// Filter out Admin, Guest, and Default roles for moderation
return this.roles
@@ -397,15 +449,13 @@ export default defineComponent({
await this.loadPermissions()
} else {
// When creating a new category, prefill with default roles
// View: Default user role
// View, Post, Reply: Default user role
const memberRole = this.roles.find(isDefaultRole)
if (memberRole) {
this.selectedViewTargets = [
{
id: memberRole.id,
label: memberRole.name,
},
]
const memberOption = { id: memberRole.id, label: memberRole.name }
this.selectedViewTargets = [memberOption]
this.selectedPostTargets = [memberOption]
this.selectedReplyTargets = [memberOption]
}
// Moderate: Admin and Moderator
@@ -479,6 +529,22 @@ export default defineComponent({
})
.filter((o): o is { id: number; label: string } => o !== null)
this.selectedPostTargets = rolePerms
.filter((p) => p.canPost)
.map((p) => {
const role = this.roles.find((r) => String(r.id) === p.targetId)
return role ? { id: role.id, label: role.name } : null
})
.filter((o): o is { id: number; label: string } => o !== null)
this.selectedReplyTargets = rolePerms
.filter((p) => p.canReply)
.map((p) => {
const role = this.roles.find((r) => String(r.id) === p.targetId)
return role ? { id: role.id, label: role.name } : null
})
.filter((o): o is { id: number; label: string } => o !== null)
this.selectedModerateTargets = rolePerms
.filter((p) => p.canModerate)
.map((p) => {
@@ -536,17 +602,32 @@ export default defineComponent({
async updatePermissions(categoryId: number): Promise<void> {
const viewRoleIds = new Set(this.selectedViewTargets.map((t) => t.id))
const postRoleIds = new Set(this.selectedPostTargets.map((t) => t.id))
const replyRoleIds = new Set(this.selectedReplyTargets.map((t) => t.id))
const moderateRoleIds = new Set(this.selectedModerateTargets.map((t) => t.id))
// Collect all unique role IDs
const allRoleIds = new Set([...viewRoleIds, ...moderateRoleIds])
const allRoleIds = new Set([
...viewRoleIds,
...postRoleIds,
...replyRoleIds,
...moderateRoleIds,
])
const permissions: Array<{ roleId: number; canView: boolean; canModerate: boolean }> = []
const permissions: Array<{
roleId: number
canView: boolean
canPost: boolean
canReply: boolean
canModerate: boolean
}> = []
for (const roleId of allRoleIds) {
permissions.push({
roleId,
canView: viewRoleIds.has(roleId),
canPost: postRoleIds.has(roleId),
canReply: replyRoleIds.has(roleId),
canModerate: moderateRoleIds.has(roleId),
})
}

View File

@@ -183,6 +183,8 @@
:category-headers="categoryHeaders"
:permissions="permissions"
:disable-view="isAdmin"
:disable-post="isAdmin"
:disable-reply="isAdmin"
:disable-moderate="isAdmin || isGuest || isDefault"
/>
</section>
@@ -367,6 +369,8 @@ export default defineComponent({
header.categories.forEach((category) => {
this.permissions[category.id] = {
canView: false,
canPost: false,
canReply: false,
canModerate: false,
}
})
@@ -383,6 +387,8 @@ export default defineComponent({
header.categories.forEach((category) => {
this.permissions[category.id] = {
canView: true,
canPost: true,
canReply: true,
canModerate: false,
}
})
@@ -449,6 +455,8 @@ export default defineComponent({
categoryId: number
roleId: number
canView: boolean
canPost: boolean
canReply: boolean
canModerate: boolean
}>
>(`/roles/${this.roleId}/permissions`)
@@ -460,6 +468,8 @@ export default defineComponent({
const categoryPerm = this.permissions[perm.categoryId]
if (categoryPerm) {
categoryPerm.canView = perm.canView
categoryPerm.canPost = perm.canPost
categoryPerm.canReply = perm.canReply
categoryPerm.canModerate = perm.canModerate
}
})
@@ -471,6 +481,8 @@ export default defineComponent({
header.categories.forEach((category) => {
this.permissions[category.id] = {
canView: true,
canPost: true,
canReply: true,
canModerate: true,
}
})
@@ -558,6 +570,8 @@ export default defineComponent({
const permissionsData = Object.entries(this.permissions).map(([categoryId, perms]) => ({
categoryId: parseInt(categoryId),
canView: perms.canView,
canPost: perms.canPost,
canReply: perms.canReply,
canModerate: this.isGuest || this.isDefault ? false : perms.canModerate,
}))

View File

@@ -156,6 +156,8 @@ export default defineComponent({
header.categories.forEach((category) => {
this.permissions[category.id] = {
canView: false,
canPost: false,
canReply: false,
canModerate: false,
}
})
@@ -168,6 +170,8 @@ export default defineComponent({
id: number
categoryId: number
canView: boolean
canPost: boolean
canReply: boolean
canModerate: boolean
}>
>(`/teams/${this.teamId}/permissions`)
@@ -179,6 +183,8 @@ export default defineComponent({
const categoryPerm = this.permissions[perm.categoryId]
if (categoryPerm) {
categoryPerm.canView = perm.canView
categoryPerm.canPost = perm.canPost
categoryPerm.canReply = perm.canReply
categoryPerm.canModerate = perm.canModerate
}
})
@@ -197,6 +203,8 @@ export default defineComponent({
const permissionsData = Object.entries(this.permissions).map(([categoryId, perms]) => ({
categoryId: parseInt(categoryId),
canView: perms.canView,
canPost: perms.canPost,
canReply: perms.canReply,
canModerate: perms.canModerate,
}))

View File

@@ -586,8 +586,8 @@ class CategoryControllerTest extends TestCase {
public function testUpdatePermissionsSuccessfully(): void {
$categoryId = 1;
$permissions = [
['roleId' => 2, 'canView' => true, 'canModerate' => false],
['roleId' => 3, 'canView' => true, 'canModerate' => true],
['roleId' => 2, 'canView' => true, 'canPost' => true, 'canModerate' => false],
['roleId' => 3, 'canView' => true, 'canPost' => false, 'canModerate' => true],
];
$category = $this->createCategory($categoryId, 1, 'Test Category');
@@ -601,9 +601,33 @@ class CategoryControllerTest extends TestCase {
->method('deleteByCategoryId')
->with($categoryId);
$this->roleMapper->expects($this->exactly(4))
->method('find')
->willReturnMap([
[2, (function () {
$r = new Role();
$r->setId(2);
$r->setRoleType(Role::ROLE_TYPE_MODERATOR);
return $r;
})()],
[3, (function () {
$r = new Role();
$r->setId(3);
$r->setRoleType(Role::ROLE_TYPE_MODERATOR);
return $r;
})()],
]);
$this->categoryPermMapper->expects($this->exactly(2))
->method('insert')
->willReturnCallback(function ($perm) {
if ($perm->getTargetId() === '2') {
$this->assertTrue($perm->getCanPost());
$this->assertTrue($perm->getCanReply());
} else {
$this->assertFalse($perm->getCanPost());
$this->assertFalse($perm->getCanReply());
}
return $perm;
});

View File

@@ -370,8 +370,8 @@ class RoleControllerTest extends TestCase {
public function testUpdatePermissionsSuccessfully(): void {
$roleId = 2;
$permissions = [
['categoryId' => 1, 'canView' => true, 'canModerate' => false],
['categoryId' => 2, 'canView' => true, 'canModerate' => true],
['categoryId' => 1, 'canView' => true, 'canPost' => true, 'canModerate' => false],
['categoryId' => 2, 'canView' => true, 'canPost' => false, 'canModerate' => true],
];
$role = $this->createRole($roleId, 'Moderator', true, false, true, true, Role::ROLE_TYPE_MODERATOR);
@@ -389,7 +389,6 @@ class RoleControllerTest extends TestCase {
->method('insert')
->willReturnCallback(function ($perm) use ($roleId) {
$this->assertEquals((string)$roleId, $perm->getTargetId());
// Verify canPost and canReply are set based on canView
if ($perm->getCategoryId() === 1) {
$this->assertTrue($perm->getCanView());
$this->assertTrue($perm->getCanPost());
@@ -397,8 +396,8 @@ class RoleControllerTest extends TestCase {
$this->assertFalse($perm->getCanModerate());
} else {
$this->assertTrue($perm->getCanView());
$this->assertTrue($perm->getCanPost());
$this->assertTrue($perm->getCanReply());
$this->assertFalse($perm->getCanPost());
$this->assertFalse($perm->getCanReply());
$this->assertTrue($perm->getCanModerate());
}
return $perm;
@@ -411,6 +410,38 @@ class RoleControllerTest extends TestCase {
$this->assertTrue($data['success']);
}
public function testUpdatePermissionsCanPostFallsBackToCanView(): void {
$roleId = 2;
$permissions = [
['categoryId' => 1, 'canView' => true, 'canModerate' => false],
];
$role = $this->createRole($roleId, 'Moderator', true, false, true, true, Role::ROLE_TYPE_MODERATOR);
$this->roleMapper->expects($this->once())
->method('find')
->with($roleId)
->willReturn($role);
$this->categoryPermMapper->expects($this->once())
->method('deleteByRoleId')
->with($roleId);
$this->categoryPermMapper->expects($this->once())
->method('insert')
->willReturnCallback(function ($perm) {
// When canPost is not provided, it should fall back to canView
$this->assertTrue($perm->getCanView());
$this->assertTrue($perm->getCanPost());
$this->assertTrue($perm->getCanReply());
return $perm;
});
$response = $this->controller->updatePermissions($roleId, $permissions);
$this->assertEquals(Http::STATUS_OK, $response->getStatus());
}
public function testUpdatePermissionsReturnsNotFoundWhenRoleDoesNotExist(): void {
$roleId = 999;
$permissions = [

View File

@@ -151,7 +151,7 @@ class TeamControllerTest extends TestCase {
public function testUpdatePermissionsReturnsServiceUnavailableWhenCirclesNotAvailable(): void {
$teamId = 'circle-abc-123';
$permissions = [
['categoryId' => 1, 'canView' => true, 'canModerate' => false],
['categoryId' => 1, 'canView' => true, 'canPost' => true, 'canModerate' => false],
];
// Since Circles is not available in the test environment, this should return 503
@@ -165,7 +165,7 @@ class TeamControllerTest extends TestCase {
public function testUpdatePermissionsDoesNotCallMapperWhenCirclesNotAvailable(): void {
$teamId = 'circle-abc-123';
$permissions = [
['categoryId' => 1, 'canView' => true, 'canModerate' => false],
['categoryId' => 1, 'canView' => true, 'canPost' => true, 'canModerate' => false],
];
// Mapper methods should never be called when Circles is unavailable