mirror of
https://github.com/chenasraf/nextcloud-forum.git
synced 2026-05-17 17:28:02 +00:00
feat: allow editing can post/reply permissions
This commit is contained in:
6
Makefile
6
Makefile
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
24
openapi.json
24
openapi.json
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user