feat: admin section with repair seeds+add role helpers

This commit is contained in:
2026-01-21 01:40:29 +02:00
parent d2aa196765
commit d74a97e571
9 changed files with 1031 additions and 0 deletions

View File

@@ -67,6 +67,10 @@ The forum integrates seamlessly with your Nextcloud instance, using your existin
<command>OCA\Forum\Command\SetRole</command>
<command>OCA\Forum\Command\TestNotifier</command>
</commands>
<settings>
<admin>OCA\Forum\Settings\AdminSettings</admin>
<admin-section>OCA\Forum\Sections\AdminSection</admin-section>
</settings>
<navigations>
<navigation role="all">
<name>Forum</name>

View File

@@ -11,9 +11,12 @@ use OCA\Forum\Attribute\RequirePermission;
use OCA\Forum\Db\CategoryMapper;
use OCA\Forum\Db\ForumUserMapper;
use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Db\UserRoleMapper;
use OCA\Forum\Migration\SeedHelper;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\UserRoleService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
@@ -23,6 +26,7 @@ use OCP\AppFramework\OCSController;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Migration\IOutput;
use Psr\Log\LoggerInterface;
class AdminController extends OCSController {
@@ -36,6 +40,8 @@ class AdminController extends OCSController {
private PostMapper $postMapper,
private CategoryMapper $categoryMapper,
private UserRoleMapper $userRoleMapper,
private RoleMapper $roleMapper,
private UserRoleService $userRoleService,
private IUserManager $userManager,
private IUserSession $userSession,
private AdminSettingsService $settingsService,
@@ -228,4 +234,202 @@ class AdminController extends OCSController {
return false;
}
}
/**
* Run the repair seeds command to restore default forum data
*
* @return DataResponse<Http::STATUS_OK, array{success: bool, message: string}, array{}>
*
* 200: Seeds repaired successfully
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'POST', url: '/api/admin/repair-seeds')]
public function repairSeeds(): DataResponse {
try {
$messages = [];
$migrationOutput = new class($messages) implements IOutput {
/** @var array<string> */
private array $messages;
public function __construct(array &$messages) {
$this->messages = &$messages;
}
public function info($message): void {
$this->messages[] = $message;
}
public function warning($message): void {
$this->messages[] = '[Warning] ' . $message;
}
public function debug($message): void {
$this->messages[] = '[Debug] ' . $message;
}
public function startProgress($max = 0): void {
}
public function advance($step = 1, $description = ''): void {
}
public function finishProgress(): void {
}
};
SeedHelper::seedAll($migrationOutput, true);
$this->logger->info('Forum repair seeds completed successfully');
return new DataResponse([
'success' => true,
'message' => implode("\n", $messages),
]);
} catch (\Exception $e) {
$this->logger->error('Error running repair seeds: ' . $e->getMessage());
return new DataResponse([
'success' => false,
'message' => 'Failed to repair seeds: ' . $e->getMessage(),
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get all available roles
*
* @return DataResponse<Http::STATUS_OK, array{roles: list<array<string, mixed>>}, array{}>
*
* 200: Roles list returned
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'GET', url: '/api/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
*
* @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
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'POST', url: '/api/admin/users/{userId}/roles')]
public function assignRole(string $userId, int $roleId): DataResponse {
try {
// Check if user exists
$user = $this->userManager->get($userId);
if ($user === null) {
return new DataResponse([
'success' => false,
'message' => "User '$userId' does not exist.",
], Http::STATUS_NOT_FOUND);
}
// Check if role exists
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);
}
// Check if user already has this role
if ($this->userRoleService->hasRole($userId, $roleId)) {
return new DataResponse([
'success' => true,
'message' => "User '$userId' already has the role '{$role->getName()}'.",
]);
}
// Assign the role
$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);
}
}
/**
* Remove a role from a user
*
* @param string $userId The user ID
* @param int $roleId The role ID to remove
* @return DataResponse<Http::STATUS_OK, array{success: bool, message: string}, array{}>
*
* 200: Role removed successfully
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'DELETE', url: '/api/admin/users/{userId}/roles/{roleId}')]
public function removeRole(string $userId, int $roleId): DataResponse {
try {
// Check if user exists
$user = $this->userManager->get($userId);
if ($user === null) {
return new DataResponse([
'success' => false,
'message' => "User '$userId' does not exist.",
], Http::STATUS_NOT_FOUND);
}
// Check if role exists
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);
}
// Remove the role
$removed = $this->userRoleService->removeRole($userId, $roleId);
if (!$removed) {
return new DataResponse([
'success' => true,
'message' => "User '$userId' does not have the role '{$role->getName()}'.",
]);
}
$this->logger->info("Removed role '{$role->getName()}' from user '$userId'");
return new DataResponse([
'success' => true,
'message' => "Successfully removed role '{$role->getName()}' from user '$userId'.",
]);
} catch (\Exception $e) {
$this->logger->error('Error removing role: ' . $e->getMessage());
return new DataResponse([
'success' => false,
'message' => 'Failed to remove role: ' . $e->getMessage(),
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace OCA\Forum\Sections;
use OCA\Forum\AppInfo;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
class AdminSection implements IIconSection {
public function __construct(
private IL10N $l,
private IURLGenerator $urlGenerator,
) {
}
public function getIcon(): string {
return $this->urlGenerator->imagePath(AppInfo\Application::APP_ID, 'app-dark.svg');
}
public function getID(): string {
return AppInfo\Application::APP_ID;
}
public function getName(): string {
return $this->l->t('Forum');
}
public function getPriority(): int {
return 80;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace OCA\Forum\Settings;
use OCA\Forum\AppInfo\Application;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IAppConfig;
use OCP\IL10N;
use OCP\Settings\ISettings;
class AdminSettings implements ISettings {
public function __construct(
private IAppConfig $config,
private IL10N $l,
) {
}
/**
* @return TemplateResponse
*/
public function getForm(): TemplateResponse {
return new TemplateResponse(Application::APP_ID, 'settings', [
'script' => Application::getViteEntryScript('admin.ts'),
'style' => Application::getViteEntryScript('style.css'),
], '');
}
public function getSection(): string {
return Application::APP_ID;
}
/**
* @return int whether the form should be rather on the top or bottom of
* the admin section. The forms are arranged in ascending order of the
* priority values. It is required to return a value between 0 and 100.
*
* E.g.: 70
*/
public function getPriority(): int {
return 10;
}
}

View File

@@ -454,6 +454,460 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/repair-seeds": {
"post": {
"operationId": "admin-repair-seeds",
"summary": "Run the repair seeds command to restore default forum data",
"tags": [
"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": "Seeds repaired 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": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/roles": {
"get": {
"operationId": "admin-get-roles",
"summary": "Get all available roles",
"tags": [
"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": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles": {
"post": {
"operationId": "admin-assign-role",
"summary": "Assign a role to a user",
"tags": [
"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": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles/{roleId}": {
"delete": {
"operationId": "admin-remove-role",
"summary": "Remove a role from a user",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "userId",
"in": "path",
"description": "The user ID",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "roleId",
"in": "path",
"description": "The role ID to remove",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"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 removed 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": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/bbcodes": {
"get": {
"operationId": "bb_code-index",

277
src/AdminSettings.vue Normal file
View File

@@ -0,0 +1,277 @@
<template>
<div id="forum-settings" class="section">
<h2>{{ strings.title }}</h2>
<NcSettingsSection :name="strings.repairSeedsHeader">
<NcNoteCard type="info">
{{ strings.repairSeedsHelp }}
</NcNoteCard>
<div class="settings-section-content">
<div class="repair-seeds-container">
<NcButton :disabled="repairSeedsLoading" @click="runRepairSeeds">
<template #icon>
<WrenchIcon v-if="!repairSeedsLoading" :size="20" />
<NcLoadingIcon v-else :size="20" />
</template>
{{ strings.runRepairSeeds }}
</NcButton>
<NcNoteCard v-if="repairSeedsResult" :type="repairSeedsSuccess ? 'success' : 'error'">
<pre class="repair-seeds-output">{{ repairSeedsResult }}</pre>
</NcNoteCard>
</div>
</div>
</NcSettingsSection>
<NcSettingsSection :name="strings.userRolesHeader">
<NcNoteCard type="info">
{{ strings.userRolesHelp }}
</NcNoteCard>
<div class="settings-section-content">
<div class="user-role-form">
<div class="field-row">
<div class="form-group">
<label for="user-id">{{ strings.userIdLabel }}</label>
<NcTextField
id="user-id"
v-model="userId"
:placeholder="strings.userIdPlaceholder"
:disabled="assignRoleLoading"
/>
</div>
<div class="form-group">
<label for="role-select">{{ strings.roleLabel }}</label>
<NcSelect
input-id="role-select"
v-model="selectedRole"
:options="roleOptions"
:placeholder="strings.rolePlaceholder"
:disabled="assignRoleLoading || rolesLoading"
:loading="rolesLoading"
/>
</div>
</div>
<div class="button-row">
<NcButton
variant="primary"
:disabled="!canAssignRole || assignRoleLoading"
@click="assignRole"
>
<template #icon>
<PlusIcon v-if="!assignRoleLoading" :size="20" />
<NcLoadingIcon v-else :size="20" />
</template>
{{ strings.assignRole }}
</NcButton>
</div>
<NcNoteCard v-if="assignRoleResult" :type="assignRoleSuccess ? 'success' : 'error'">
<p>{{ assignRoleResult }}</p>
</NcNoteCard>
</div>
</div>
</NcSettingsSection>
</div>
</template>
<script>
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import WrenchIcon from '@icons/Wrench.vue'
import PlusIcon from '@icons/Plus.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
export default {
name: 'AdminSettings',
components: {
NcSettingsSection,
NcButton,
NcSelect,
NcNoteCard,
NcTextField,
NcLoadingIcon,
WrenchIcon,
PlusIcon,
},
data() {
return {
// Repair seeds
repairSeedsLoading: false,
repairSeedsResult: null,
repairSeedsSuccess: false,
// User roles
rolesLoading: true,
roles: [],
userId: '',
selectedRole: null,
assignRoleLoading: false,
assignRoleResult: null,
assignRoleSuccess: false,
strings: {
title: t('forum', 'Forum'),
repairSeedsHeader: t('forum', 'Repair Seeds'),
repairSeedsHelp: t(
'forum',
'Run the repair seeds command to restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists.',
),
runRepairSeeds: t('forum', 'Run Repair Seeds'),
userRolesHeader: t('forum', 'User Roles'),
userRolesHelp: t(
'forum',
'Assign forum roles to users. This allows you to grant administrative or moderator privileges to specific users.',
),
userIdLabel: t('forum', 'User ID'),
userIdPlaceholder: t('forum', 'Enter user ID'),
roleLabel: t('forum', 'Role'),
rolePlaceholder: t('forum', 'Select a role'),
assignRole: t('forum', 'Assign Role'),
},
}
},
computed: {
roleOptions() {
return this.roles.map((role) => ({
id: role.id,
label: role.name,
}))
},
canAssignRole() {
return this.userId.trim() !== '' && this.selectedRole !== null
},
},
created() {
this.fetchRoles()
},
methods: {
async fetchRoles() {
try {
this.rolesLoading = true
const resp = await ocs.get('/admin/roles')
this.roles = resp.data.roles
} catch (e) {
console.error('Failed to fetch roles', e)
} finally {
this.rolesLoading = false
}
},
async runRepairSeeds() {
try {
this.repairSeedsLoading = true
this.repairSeedsResult = null
const resp = await ocs.post('/admin/repair-seeds')
this.repairSeedsSuccess = resp.data.success
this.repairSeedsResult = resp.data.message
if (resp.data.success) {
await this.fetchRoles()
}
} catch (e) {
console.error('Failed to run repair seeds', e)
this.repairSeedsSuccess = false
this.repairSeedsResult =
e.response?.data?.message || t('forum', 'Failed to run repair seeds')
} finally {
this.repairSeedsLoading = false
}
},
async assignRole() {
if (!this.canAssignRole) return
try {
this.assignRoleLoading = true
this.assignRoleResult = null
const resp = await ocs.post(
`/admin/users/${encodeURIComponent(this.userId.trim())}/roles`,
{
roleId: this.selectedRole.id,
},
)
this.assignRoleSuccess = resp.data.success
this.assignRoleResult = resp.data.message
if (resp.data.success) {
// Clear form on success
this.userId = ''
this.selectedRole = null
}
} catch (e) {
console.error('Failed to assign role', e)
this.assignRoleSuccess = false
this.assignRoleResult = e.response?.data?.message || t('forum', 'Failed to assign role')
} finally {
this.assignRoleLoading = false
}
},
},
}
</script>
<style scoped lang="scss">
#forum-settings {
h2:first-child {
margin-top: 0;
}
.settings-section-content {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 16px;
margin-top: 16px;
}
.repair-seeds-container {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 600px;
}
.repair-seeds-output {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
font-family: monospace;
font-size: 12px;
}
.user-role-form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 600px;
.field-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
.form-group {
flex: 1;
min-width: 200px;
display: flex;
flex-direction: column;
gap: 4px;
label {
font-weight: bold;
}
}
}
.button-row {
display: flex;
gap: 12px;
}
}
}
</style>

5
src/admin.ts Normal file
View File

@@ -0,0 +1,5 @@
import AdminSettings from './AdminSettings.vue'
import './style.scss'
import { createApp } from 'vue'
createApp(AdminSettings).mount('#forum-settings')

12
templates/settings.php Normal file
View File

@@ -0,0 +1,12 @@
<?php
use OCA\Forum\AppInfo\Application;
use OCP\Util;
/* @var array $_ */
$script = $_['script'];
$style = $_['style'];
Util::addScript(Application::APP_ID, Application::JS_DIR . "/$script");
Util::addStyle(Application::APP_ID, Application::CSS_DIR . "/$style");
?>
<div id="forum-settings"></div>

View File

@@ -34,6 +34,7 @@ const nextcloudSharedList = [
export default createAppConfig(
{
app: path.resolve(path.join('src', 'app.ts')),
admin: path.resolve(path.join('src', 'admin.ts')),
},
{
config: {