feat: add Permission management UI and API endpoints (Closes #37) (#88)

This commit was merged in pull request #88.
This commit is contained in:
2026-04-07 13:26:09 +02:00
parent 4e2e7df51c
commit 45a479ad37
6 changed files with 708 additions and 0 deletions
+9
View File
@@ -60,5 +60,14 @@ return [
['name' => 'fee#batchCalculate', 'url' => '/api/v1/fees/batch-calculate', 'verb' => 'POST'],
['name' => 'fee#markPaid', 'url' => '/api/v1/fees/records/{recordId}/paid', 'verb' => 'PUT'],
['name' => 'fee#manualOverride', 'url' => '/api/v1/fees/records/{recordId}/override', 'verb' => 'PUT'],
// ── Permissions ─────────────────────────────────────────────
['name' => 'permission#index', 'url' => '/api/v1/permissions', 'verb' => 'GET'],
['name' => 'permission#listUsers', 'url' => '/api/v1/permissions/users', 'verb' => 'GET'],
['name' => 'permission#setPermission', 'url' => '/api/v1/permissions/{ncUserId}', 'verb' => 'PUT'],
['name' => 'permission#removePermission', 'url' => '/api/v1/permissions/{ncUserId}', 'verb' => 'DELETE'],
// ── Audit log ───────────────────────────────────────────────
['name' => 'audit#index', 'url' => '/api/v1/audit-log', 'verb' => 'GET'],
],
];
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\AuditService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* REST API controller for the audit log.
*
* Part of Issue #40.
*/
class AuditController extends ApiController {
private AuditService $auditService;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
AuditService $auditService,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->auditService = $auditService;
$this->logger = $logger;
}
/**
* List audit log entries with optional filters.
*
* GET /api/v1/audit-log?entitaet=member&ncUserId=admin&from=2026-01-01&to=2026-12-31&limit=50&offset=0
*/
#[NoCSRFRequired]
public function index(): JSONResponse {
try {
$entitaet = $this->request->getParam('entitaet');
$entitaetId = $this->request->getParam('entitaetId');
$ncUserId = $this->request->getParam('ncUserId');
$fromDate = $this->request->getParam('from');
$toDate = $this->request->getParam('to');
$limit = $this->request->getParam('limit');
$offset = $this->request->getParam('offset');
$result = $this->auditService->getLog(
$entitaet ?: null,
$entitaetId !== null ? (int)$entitaetId : null,
$ncUserId ?: null,
$fromDate ?: null,
$toDate ?: null,
$limit !== null ? (int)$limit : 50,
$offset !== null ? (int)$offset : 0
);
return new JSONResponse($result);
} catch (\Exception $e) {
$this->logger->error('Failed to query audit log', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Internal server error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
}
+191
View File
@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\PermissionService;
use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* REST API controller for permission management.
*
* Part of Issue #37.
*/
class PermissionController extends ApiController {
private PermissionService $permissionService;
private IUserManager $userManager;
private IUserSession $userSession;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
PermissionService $permissionService,
IUserManager $userManager,
IUserSession $userSession,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->permissionService = $permissionService;
$this->userManager = $userManager;
$this->userSession = $userSession;
$this->logger = $logger;
}
/**
* List all permissions with user info.
*
* GET /api/v1/permissions
*/
#[NoCSRFRequired]
public function index(): JSONResponse {
try {
$permissions = $this->permissionService->getAllPermissions();
$result = array_map(fn($p) => $p->jsonSerialize(), $permissions);
return new JSONResponse($result);
} catch (\Exception $e) {
$this->logger->error('Failed to list permissions', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Internal server error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* List Nextcloud users for the user picker.
*
* GET /api/v1/permissions/users
*/
#[NoCSRFRequired]
public function listUsers(): JSONResponse {
try {
$users = [];
$this->userManager->callForAllUsers(function ($user) use (&$users) {
$users[] = [
'uid' => $user->getUID(),
'displayName' => $user->getDisplayName(),
];
});
return new JSONResponse($users);
} catch (\Exception $e) {
$this->logger->error('Failed to list users', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Internal server error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Set permission for a user (create or update).
*
* PUT /api/v1/permissions/{ncUserId}
*/
public function setPermission(string $ncUserId): JSONResponse {
try {
// Safety check: cannot remove own admin permission
$currentUser = $this->userSession->getUser();
$currentUserId = $currentUser ? $currentUser->getUID() : null;
$data = $this->getRequestData();
$level = $data['level'] ?? 'none';
if ($currentUserId === $ncUserId && $level !== 'admin') {
$currentPerm = $this->permissionService->getUserPermission($ncUserId);
if ($currentPerm !== null && $currentPerm->getLevel() === 'admin') {
return new JSONResponse(
['error' => 'Eigene Admin-Berechtigung kann nicht entfernt werden.'],
Http::STATUS_BAD_REQUEST
);
}
}
$allowedStufen = $data['allowedStufen'] ?? null;
$canSeeBanking = (bool)($data['canSeeBanking'] ?? false);
$perm = $this->permissionService->setPermission(
$ncUserId,
$level,
$allowedStufen,
$canSeeBanking
);
return new JSONResponse($perm);
} catch (ValidationException $e) {
return new JSONResponse(
['error' => $e->getMessage()],
Http::STATUS_BAD_REQUEST
);
} catch (\Exception $e) {
$this->logger->error('Failed to set permission', [
'ncUserId' => $ncUserId,
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Internal server error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Remove permission for a user.
*
* DELETE /api/v1/permissions/{ncUserId}
*/
public function removePermission(string $ncUserId): JSONResponse {
try {
// Safety check: cannot remove own permission
$currentUser = $this->userSession->getUser();
$currentUserId = $currentUser ? $currentUser->getUID() : null;
if ($currentUserId === $ncUserId) {
return new JSONResponse(
['error' => 'Eigene Berechtigung kann nicht entfernt werden.'],
Http::STATUS_BAD_REQUEST
);
}
$this->permissionService->removePermission($ncUserId);
return new JSONResponse(['status' => 'removed']);
} catch (\Exception $e) {
$this->logger->error('Failed to remove permission', [
'ncUserId' => $ncUserId,
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Internal server error'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
private function getRequestData(): array {
$input = file_get_contents('php://input');
if ($input === false || $input === '') {
return [];
}
$data = json_decode($input, true);
return is_array($data) ? $data : [];
}
}
+318
View File
@@ -0,0 +1,318 @@
<template>
<div class="permission-settings">
<h3>Berechtigungen verwalten</h3>
<p class="permission-settings__description">
Weisen Sie Nextcloud-Benutzern Zugriffsrechte zu.
</p>
<!-- Error -->
<div v-if="permStore.error" class="permission-settings__error">
{{ permStore.error }}
<NcButton @click="permStore.clearError()">Schliessen</NcButton>
</div>
<!-- Search/Filter -->
<div class="permission-settings__filter">
<NcTextField :value.sync="filterQuery"
placeholder="Benutzer filtern..."
@update:value="filterQuery = $event" />
</div>
<!-- Loading -->
<NcLoadingIcon v-if="permStore.loading && permStore.permissions.length === 0"
:size="32"
class="permission-settings__loading" />
<!-- User list with permissions -->
<div v-else class="permission-settings__list">
<div v-for="userPerm in filteredUsers"
:key="userPerm.uid"
class="permission-settings__user-row">
<!-- User info -->
<div class="permission-settings__user-info">
<strong>{{ userPerm.displayName || userPerm.uid }}</strong>
<span class="permission-settings__uid">{{ userPerm.uid }}</span>
</div>
<!-- Permission level dropdown -->
<div class="permission-settings__level">
<label>Zugriff</label>
<select :value="userPerm.level"
class="permission-settings__select"
@change="onLevelChange(userPerm.uid, $event.target.value)">
<option value="none">Kein Zugriff</option>
<option value="read">Lesezugriff</option>
<option value="stufe">Stufenzugriff</option>
<option value="full">Vollzugriff</option>
<option value="admin">Admin</option>
</select>
</div>
<!-- Stufen multi-select (only when level = stufe) -->
<div v-if="userPerm.level === 'stufe'" class="permission-settings__stufen">
<label>Erlaubte Stufen</label>
<div class="permission-settings__stufen-checks">
<label v-for="stufe in stufen"
:key="stufe.id"
class="permission-settings__stufe-check">
<input type="checkbox"
:checked="(userPerm.allowedStufen || []).includes(stufe.id)"
@change="onStufenChange(userPerm.uid, stufe.id, $event.target.checked)">
<span :style="{ color: stufe.color }">{{ stufe.name }}</span>
</label>
</div>
</div>
<!-- Banking visibility toggle -->
<div class="permission-settings__banking">
<label class="permission-settings__toggle-label">
<input type="checkbox"
:checked="userPerm.canSeeBanking"
@change="onBankingChange(userPerm.uid, $event.target.checked)">
Bankdaten sichtbar
</label>
</div>
</div>
</div>
<!-- No users found -->
<p v-if="!permStore.loading && filteredUsers.length === 0" class="permission-settings__empty">
Keine Benutzer gefunden.
</p>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { NcButton, NcTextField, NcLoadingIcon } from '@nextcloud/vue'
import { usePermissionsStore } from '../stores/permissions.js'
import { useStufenStore } from '../stores/stufen.js'
const permStore = usePermissionsStore()
const stufenStore = useStufenStore()
const filterQuery = ref('')
const stufen = computed(() => stufenStore.stufen)
/**
* Merge NC users with their permissions for display.
*/
const filteredUsers = computed(() => {
const permMap = {}
for (const perm of permStore.permissions) {
permMap[perm.ncUserId] = perm
}
let users = permStore.users.map(user => ({
uid: user.uid,
displayName: user.displayName,
level: permMap[user.uid]?.level || 'none',
allowedStufen: permMap[user.uid]?.allowedStufen || [],
canSeeBanking: permMap[user.uid]?.canSeeBanking || false,
}))
// Filter by search query
if (filterQuery.value.trim()) {
const q = filterQuery.value.trim().toLowerCase()
users = users.filter(u =>
u.uid.toLowerCase().includes(q)
|| (u.displayName || '').toLowerCase().includes(q),
)
}
return users
})
onMounted(async () => {
await Promise.all([
permStore.fetchPermissions(),
permStore.fetchUsers(),
stufenStore.fetchStufen(),
])
})
/**
* Handle permission level change.
*/
async function onLevelChange(uid, newLevel) {
const user = filteredUsers.value.find(u => u.uid === uid)
if (!user) return
try {
await permStore.setPermission(
uid,
newLevel,
newLevel === 'stufe' ? user.allowedStufen : null,
user.canSeeBanking,
)
} catch {
// Error displayed by store
}
}
/**
* Handle Stufen checkbox change.
*/
async function onStufenChange(uid, stufeId, checked) {
const user = filteredUsers.value.find(u => u.uid === uid)
if (!user) return
let allowedStufen = [...(user.allowedStufen || [])]
if (checked) {
if (!allowedStufen.includes(stufeId)) {
allowedStufen.push(stufeId)
}
} else {
allowedStufen = allowedStufen.filter(id => id !== stufeId)
}
try {
await permStore.setPermission(
uid,
user.level,
allowedStufen,
user.canSeeBanking,
)
} catch {
// Error displayed by store
}
}
/**
* Handle banking visibility toggle.
*/
async function onBankingChange(uid, canSeeBanking) {
const user = filteredUsers.value.find(u => u.uid === uid)
if (!user) return
try {
await permStore.setPermission(
uid,
user.level,
user.level === 'stufe' ? user.allowedStufen : null,
canSeeBanking,
)
} catch {
// Error displayed by store
}
}
</script>
<style scoped>
.permission-settings__description {
color: var(--color-text-lighter);
margin: 0 0 16px 0;
}
.permission-settings__error {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--color-error);
color: white;
border-radius: var(--border-radius);
margin-bottom: 12px;
}
.permission-settings__filter {
margin-bottom: 16px;
max-width: 400px;
}
.permission-settings__loading {
margin: 20px 0;
}
.permission-settings__list {
display: flex;
flex-direction: column;
gap: 8px;
}
.permission-settings__user-row {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: var(--color-background-dark);
border-radius: var(--border-radius);
flex-wrap: wrap;
}
.permission-settings__user-info {
min-width: 200px;
flex: 1;
}
.permission-settings__uid {
display: block;
font-size: 0.85em;
color: var(--color-text-lighter);
}
.permission-settings__level {
display: flex;
flex-direction: column;
gap: 4px;
}
.permission-settings__level label {
font-size: 0.8em;
color: var(--color-text-lighter);
}
.permission-settings__select {
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background: var(--color-main-background);
color: var(--color-text);
}
.permission-settings__stufen {
display: flex;
flex-direction: column;
gap: 4px;
}
.permission-settings__stufen > label {
font-size: 0.8em;
color: var(--color-text-lighter);
}
.permission-settings__stufen-checks {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.permission-settings__stufe-check {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.9em;
cursor: pointer;
}
.permission-settings__banking {
display: flex;
align-items: center;
}
.permission-settings__toggle-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.9em;
cursor: pointer;
white-space: nowrap;
}
.permission-settings__empty {
color: var(--color-text-lighter);
font-style: italic;
}
</style>
+110
View File
@@ -0,0 +1,110 @@
/**
* Pinia store for permission state management.
*
* Part of Issue #37.
*/
import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const usePermissionsStore = defineStore('permissions', {
state: () => ({
/** @type {Array} Permission records */
permissions: [],
/** @type {Array} Nextcloud users */
users: [],
/** @type {boolean} Loading state */
loading: false,
/** @type {string|null} Error message */
error: null,
}),
actions: {
/**
* Fetch all permissions.
*/
async fetchPermissions() {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/permissions')
const response = await axios.get(url)
this.permissions = response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden der Berechtigungen'
console.error('Failed to fetch permissions:', err)
} finally {
this.loading = false
}
},
/**
* Fetch Nextcloud users for the user picker.
*/
async fetchUsers() {
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/permissions/users')
const response = await axios.get(url)
this.users = response.data
} catch (err) {
console.error('Failed to fetch users:', err)
}
},
/**
* Set permission for a user.
*/
async setPermission(ncUserId, level, allowedStufen, canSeeBanking) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/permissions/${ncUserId}`)
const response = await axios.put(url, {
level,
allowedStufen,
canSeeBanking,
})
// Update local state
const index = this.permissions.findIndex(p => p.ncUserId === ncUserId)
if (index !== -1) {
this.permissions[index] = response.data
} else {
this.permissions.push(response.data)
}
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Setzen der Berechtigung'
throw err
} finally {
this.loading = false
}
},
/**
* Remove permission for a user.
*/
async removePermission(ncUserId) {
this.loading = true
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/permissions/${ncUserId}`)
await axios.delete(url)
this.permissions = this.permissions.filter(p => p.ncUserId !== ncUserId)
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Entfernen der Berechtigung'
throw err
} finally {
this.loading = false
}
},
clearError() {
this.error = null
},
},
})
+6
View File
@@ -107,6 +107,11 @@
</NcButton>
</div>
</div>
<!-- Permission Management Section -->
<div class="settings__section">
<PermissionSettings />
</div>
</div>
</template>
@@ -114,6 +119,7 @@
import { onMounted } from 'vue'
import { NcButton, NcTextField, NcLoadingIcon } from '@nextcloud/vue'
import { useStufenStore } from '../stores/stufen.js'
import PermissionSettings from '../components/PermissionSettings.vue'
import Plus from 'vue-material-design-icons/Plus.vue'
const stufenStore = useStufenStore()