feat: v0.2.0 — backup system, self-update with Ed25519 signing, column pickers, import fixes

Major features:
- Full backup & restore system (JSON snapshots of all 20 tables + settings)
  - Web UI, REST API, OCC CLI commands, scheduled background job
- Self-update from Gitea releases with Ed25519 signature verification
- Configurable column visibility on all data tables (persisted via localStorage)

Fixes:
- NC admin group fallback for PermissionService (IGroupManager)
- Bundle import inline error correction (editable error rows)

New files: BackupService, BackupSettingsService, BackupController, BackupJob,
SelfUpdateService, 4 OCC commands, ColumnPicker component, Backup.vue,
Ed25519 signing scripts, signature verification tests (18 tests)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-04-10 23:11:51 +02:00
parent 27cedd849d
commit d48e2b7d9d
38 changed files with 3449 additions and 1014 deletions
+4
View File
@@ -30,5 +30,9 @@ package-lock.json
# Test artifacts # Test artifacts
.playwright-mcp/ .playwright-mcp/
.phpunit.cache/
screenshots/ screenshots/
test-results/ test-results/
# Release artifacts
artifacts/
+60 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build deps up down setup deploy redeploy logs clean .PHONY: build deps up down setup deploy redeploy logs clean package release
# Install dependencies (composer via Docker since PHP may not be local) # Install dependencies (composer via Docker since PHP may not be local)
deps: deps:
@@ -70,6 +70,65 @@ down:
logs: logs:
docker compose logs -f nextcloud docker compose logs -f nextcloud
# Create a distributable tar.gz for production installation
package: build
$(eval VERSION := $(shell grep '<version>' appinfo/info.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'))
@echo "Packaging mitgliederverwaltung v$(VERSION)..."
mkdir -p artifacts
rm -rf /tmp/mitgliederverwaltung
mkdir -p /tmp/mitgliederverwaltung
cp -a appinfo lib templates js vendor img /tmp/mitgliederverwaltung/
@for f in LICENSE README.md CHANGELOG.md; do \
[ -f "$$f" ] && cp "$$f" /tmp/mitgliederverwaltung/ || true; \
done
cd /tmp && tar -czf mitgliederverwaltung-$(VERSION).tar.gz mitgliederverwaltung
mv /tmp/mitgliederverwaltung-$(VERSION).tar.gz artifacts/
rm -rf /tmp/mitgliederverwaltung
@# Sign the tarball with Ed25519
@if [ -f "$$HOME/.mv-release-key" ]; then \
docker run --rm -v "$$(pwd):/app" -v "$$HOME/.mv-release-key:$$HOME/.mv-release-key:ro" -e HOME="$$HOME" -w /app php:8.1-cli php scripts/sign-release.php artifacts/mitgliederverwaltung-$(VERSION).tar.gz; \
else \
echo "WARNING: ~/.mv-release-key not found — tarball NOT signed."; \
echo " Run: php scripts/generate-keypair.php"; \
fi
@echo ""
@echo "Package created: artifacts/mitgliederverwaltung-$(VERSION).tar.gz"
@echo "Size: $$(du -h artifacts/mitgliederverwaltung-$(VERSION).tar.gz | cut -f1)"
# Build, package, tag, and create a Gitea release with the tarball attached
release: package
$(eval VERSION := $(shell grep '<version>' appinfo/info.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'))
@echo "Creating release v$(VERSION) on Gitea..."
git tag -a "v$(VERSION)" -m "Release v$(VERSION)" 2>/dev/null || echo "Tag v$(VERSION) already exists, skipping"
git push origin "v$(VERSION)" 2>/dev/null || echo "Tag already pushed"
@# Create Gitea release via API
@RELEASE_ID=$$(curl -s -X POST \
"https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases" \
-H "Content-Type: application/json" \
-H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \
-d '{"tag_name": "v$(VERSION)", "name": "v$(VERSION)", "body": "Release v$(VERSION)", "draft": false, "prerelease": false}' \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) && \
if [ -n "$$RELEASE_ID" ] && [ "$$RELEASE_ID" != "" ]; then \
echo "Uploading tarball to release $$RELEASE_ID..."; \
curl -s -X POST \
"https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases/$$RELEASE_ID/assets?name=mitgliederverwaltung-$(VERSION).tar.gz" \
-H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \
-F "attachment=@artifacts/mitgliederverwaltung-$(VERSION).tar.gz" \
>/dev/null && echo " Tarball uploaded."; \
if [ -f "artifacts/mitgliederverwaltung-$(VERSION).tar.gz.sig" ]; then \
curl -s -X POST \
"https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases/$$RELEASE_ID/assets?name=mitgliederverwaltung-$(VERSION).tar.gz.sig" \
-H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \
-F "attachment=@artifacts/mitgliederverwaltung-$(VERSION).tar.gz.sig" \
>/dev/null && echo " Signature uploaded."; \
else \
echo " WARNING: No .sig file found — release is UNSIGNED."; \
fi; \
echo "Release v$(VERSION) created."; \
else \
echo "WARNING: Could not create Gitea release (missing ~/.gitea-token?). Tarball is in artifacts/."; \
fi
# Remove volumes (full reset) # Remove volumes (full reset)
clean: clean:
docker compose down -v docker compose down -v
+8 -1
View File
@@ -5,7 +5,7 @@
<name>Mitgliederverwaltung</name> <name>Mitgliederverwaltung</name>
<summary>Mitgliederverwaltung für Pfadfindervereine</summary> <summary>Mitgliederverwaltung für Pfadfindervereine</summary>
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description> <description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
<version>0.1.5</version> <version>0.2.0</version>
<licence>agpl</licence> <licence>agpl</licence>
<author>shahondin1624</author> <author>shahondin1624</author>
<namespace>Mitgliederverwaltung</namespace> <namespace>Mitgliederverwaltung</namespace>
@@ -19,7 +19,14 @@
<job>OCA\Mitgliederverwaltung\BackgroundJob\SyncQueueJob</job> <job>OCA\Mitgliederverwaltung\BackgroundJob\SyncQueueJob</job>
<job>OCA\Mitgliederverwaltung\BackgroundJob\CalendarFullSyncJob</job> <job>OCA\Mitgliederverwaltung\BackgroundJob\CalendarFullSyncJob</job>
<job>OCA\Mitgliederverwaltung\BackgroundJob\ContactsFullSyncJob</job> <job>OCA\Mitgliederverwaltung\BackgroundJob\ContactsFullSyncJob</job>
<job>OCA\Mitgliederverwaltung\BackgroundJob\BackupJob</job>
</background-jobs> </background-jobs>
<commands>
<command>OCA\Mitgliederverwaltung\Command\BackupCreateCommand</command>
<command>OCA\Mitgliederverwaltung\Command\BackupRestoreCommand</command>
<command>OCA\Mitgliederverwaltung\Command\BackupListCommand</command>
<command>OCA\Mitgliederverwaltung\Command\AppUpdateCommand</command>
</commands>
<navigations> <navigations>
<navigation> <navigation>
<name>Mitgliederverwaltung</name> <name>Mitgliederverwaltung</name>
+14
View File
@@ -158,6 +158,20 @@ return [
['name' => 'query#destroy', 'url' => '/api/v1/queries/{id}', 'verb' => 'DELETE'], ['name' => 'query#destroy', 'url' => '/api/v1/queries/{id}', 'verb' => 'DELETE'],
['name' => 'query#executeSaved', 'url' => '/api/v1/queries/{id}/execute', 'verb' => 'POST'], ['name' => 'query#executeSaved', 'url' => '/api/v1/queries/{id}/execute', 'verb' => 'POST'],
// ── Backup & Restore (admin-only) ─────────────────────────
['name' => 'backup#getSettings', 'url' => '/api/v1/backups/settings', 'verb' => 'GET'],
['name' => 'backup#updateSettings', 'url' => '/api/v1/backups/settings', 'verb' => 'PUT'],
['name' => 'backup#index', 'url' => '/api/v1/backups', 'verb' => 'GET'],
['name' => 'backup#create', 'url' => '/api/v1/backups', 'verb' => 'POST'],
['name' => 'backup#show', 'url' => '/api/v1/backups/{filename}', 'verb' => 'GET'],
['name' => 'backup#download', 'url' => '/api/v1/backups/{filename}/download', 'verb' => 'GET'],
['name' => 'backup#restore', 'url' => '/api/v1/backups/{filename}/restore', 'verb' => 'POST'],
['name' => 'backup#destroy', 'url' => '/api/v1/backups/{filename}', 'verb' => 'DELETE'],
// ── Self-update (admin-only) ───────────────────────────────
['name' => 'backup#checkUpdate', 'url' => '/api/v1/update/check', 'verb' => 'GET'],
['name' => 'backup#installUpdate', 'url' => '/api/v1/update/install', 'verb' => 'POST'],
// ── Files integration (NC Files browser) ──────────────────── // ── Files integration (NC Files browser) ────────────────────
['name' => 'file#getSettings', 'url' => '/api/v1/files/settings', 'verb' => 'GET'], ['name' => 'file#getSettings', 'url' => '/api/v1/files/settings', 'verb' => 'GET'],
['name' => 'file#updateSettings', 'url' => '/api/v1/files/settings', 'verb' => 'PUT'], ['name' => 'file#updateSettings', 'url' => '/api/v1/files/settings', 'verb' => 'PUT'],
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\BackgroundJob;
use OCA\Mitgliederverwaltung\Service\BackupService;
use OCA\Mitgliederverwaltung\Service\BackupSettingsService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
/**
* Background job for scheduled automatic backups.
*
* Runs every 24 hours. Checks the configured schedule ('daily', 'weekly', 'disabled')
* and creates a backup if due. Automatically rotates old backups.
*/
class BackupJob extends TimedJob {
private BackupService $backupService;
private BackupSettingsService $settingsService;
private LoggerInterface $logger;
public function __construct(
ITimeFactory $time,
BackupService $backupService,
BackupSettingsService $settingsService,
LoggerInterface $logger
) {
parent::__construct($time);
$this->backupService = $backupService;
$this->settingsService = $settingsService;
$this->logger = $logger;
// Check every 24 hours
$this->setInterval(24 * 60 * 60);
}
protected function run($argument): void {
$schedule = $this->settingsService->getSchedule();
if ($schedule === 'disabled') {
return;
}
// For weekly schedule, only run if 7+ days since last backup
if ($schedule === 'weekly') {
$backups = $this->backupService->listBackups();
if (!empty($backups)) {
$lastCreated = strtotime($backups[0]['createdAt']);
$daysSince = (time() - $lastCreated) / 86400;
if ($daysSince < 7) {
return;
}
}
}
try {
$password = $this->settingsService->getBackupPassword();
$result = $this->backupService->createBackup($password, 'scheduled');
$deleted = $this->backupService->rotateBackups();
$this->logger->info('Scheduled backup completed', [
'filename' => $result['filename'],
'rotated' => $deleted,
'app' => 'mitgliederverwaltung',
]);
} catch (\Exception $e) {
$this->logger->error('Scheduled backup failed', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
}
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Command;
use OCA\Mitgliederverwaltung\Service\SelfUpdateService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class AppUpdateCommand extends Command {
private SelfUpdateService $updateService;
public function __construct(SelfUpdateService $updateService) {
parent::__construct();
$this->updateService = $updateService;
}
protected function configure(): void {
$this->setName('mitgliederverwaltung:app:update')
->setDescription('Check for and install updates from Gitea releases')
->addOption('check', 'c', InputOption::VALUE_NONE, 'Only check for updates, do not install')
->addOption('force', 'f', InputOption::VALUE_NONE, 'Install even if version is not newer')
->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$checkOnly = $input->getOption('check');
$output->writeln('Checking for updates...');
$info = $this->updateService->checkForUpdate();
$output->writeln(' Current version: v' . $info['currentVersion']);
if (isset($info['error'])) {
$output->writeln('<error>Update-Check fehlgeschlagen: ' . $info['error'] . '</error>');
return Command::FAILURE;
}
if ($info['latestVersion'] === null) {
$output->writeln(' No releases found on Gitea.');
return Command::SUCCESS;
}
$output->writeln(' Latest release: v' . $info['latestVersion']);
$output->writeln(' Signed: ' . (($info['signed'] ?? false) ? '<info>Yes</info>' : '<comment>No</comment>'));
if (!$info['available']) {
$output->writeln('<info>Already up to date.</info>');
if (!$checkOnly && !$input->getOption('force')) {
return Command::SUCCESS;
}
} else {
$output->writeln('<comment>Update available!</comment>');
if ($info['releaseName']) {
$output->writeln(' Release: ' . $info['releaseName']);
}
}
if ($checkOnly) {
return Command::SUCCESS;
}
// Confirmation
if (!$input->getOption('yes')) {
$output->writeln('');
$output->writeln('<comment>This will replace the app files with the new version.</comment>');
$output->writeln('<comment>Run "occ upgrade" afterwards to apply database migrations.</comment>');
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('Install update? [y/N] ', false);
if (!$helper->ask($input, $output, $question)) {
$output->writeln('Update cancelled.');
return Command::SUCCESS;
}
}
$output->writeln('');
$output->writeln('Downloading and installing...');
$result = $this->updateService->performUpdate($input->getOption('force'));
if ($result['success']) {
$output->writeln('<info>' . $result['message'] . '</info>');
$output->writeln('');
$output->writeln('Next step: run <comment>occ upgrade</comment> to apply database migrations.');
return Command::SUCCESS;
} else {
$output->writeln('<error>' . $result['message'] . '</error>');
return Command::FAILURE;
}
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Command;
use OCA\Mitgliederverwaltung\Service\BackupService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class BackupCreateCommand extends Command {
private BackupService $backupService;
public function __construct(BackupService $backupService) {
parent::__construct();
$this->backupService = $backupService;
}
protected function configure(): void {
$this->setName('mitgliederverwaltung:backup:create')
->setDescription('Create a full backup of Mitgliederverwaltung data')
->addOption('password', 'p', InputOption::VALUE_OPTIONAL, 'Password for ZIP encryption');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$password = $input->getOption('password');
$output->writeln('Creating backup...');
try {
$result = $this->backupService->createBackup($password, 'cli');
$output->writeln('');
$output->writeln('<info>Backup created successfully!</info>');
$output->writeln(' Filename: ' . $result['filename']);
$output->writeln(' Size: ' . $this->formatBytes($result['size']));
$output->writeln(' Created: ' . $result['createdAt']);
$output->writeln('');
$totalRows = 0;
foreach ($result['tables'] as $table => $count) {
if ($count > 0) {
$output->writeln(" {$table}: {$count} rows");
}
$totalRows += $count;
}
$output->writeln('');
$output->writeln(" Total: {$totalRows} rows across " . count($result['tables']) . ' tables');
return Command::SUCCESS;
} catch (\Exception $e) {
$output->writeln('<error>Backup failed: ' . $e->getMessage() . '</error>');
return Command::FAILURE;
}
}
private function formatBytes(int $bytes): string {
if ($bytes >= 1048576) {
return round($bytes / 1048576, 2) . ' MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 2) . ' KB';
}
return $bytes . ' B';
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Command;
use OCA\Mitgliederverwaltung\Service\BackupService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class BackupListCommand extends Command {
private BackupService $backupService;
public function __construct(BackupService $backupService) {
parent::__construct();
$this->backupService = $backupService;
}
protected function configure(): void {
$this->setName('mitgliederverwaltung:backup:list')
->setDescription('List all available backups');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$backups = $this->backupService->listBackups();
if (empty($backups)) {
$output->writeln('No backups found.');
return Command::SUCCESS;
}
$table = new Table($output);
$table->setHeaders(['Filename', 'Created', 'Size', 'Tables', 'Rows', 'Triggered By']);
foreach ($backups as $backup) {
$totalRows = 0;
foreach ($backup['tables'] as $count) {
$totalRows += $count;
}
$table->addRow([
$backup['filename'],
$backup['createdAt'],
$this->formatBytes($backup['size']),
count($backup['tables']),
$totalRows,
$backup['triggeredBy'] ?? '-',
]);
}
$table->render();
return Command::SUCCESS;
}
private function formatBytes(int $bytes): string {
if ($bytes >= 1048576) {
return round($bytes / 1048576, 2) . ' MB';
}
if ($bytes >= 1024) {
return round($bytes / 1024, 2) . ' KB';
}
return $bytes . ' B';
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Command;
use OCA\Mitgliederverwaltung\Service\BackupService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class BackupRestoreCommand extends Command {
private BackupService $backupService;
public function __construct(BackupService $backupService) {
parent::__construct();
$this->backupService = $backupService;
}
protected function configure(): void {
$this->setName('mitgliederverwaltung:backup:restore')
->setDescription('Restore Mitgliederverwaltung from a backup file')
->addArgument('filename', InputArgument::REQUIRED, 'Backup filename')
->addOption('password', 'p', InputOption::VALUE_OPTIONAL, 'Password if backup is encrypted')
->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation prompt');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$filename = $input->getArgument('filename');
$password = $input->getOption('password');
$skipConfirm = $input->getOption('yes');
// Show backup info
$info = $this->backupService->getBackupInfo($filename);
if ($info === null) {
$output->writeln('<error>Backup not found: ' . $filename . '</error>');
return Command::FAILURE;
}
$output->writeln('Backup info:');
$output->writeln(' Filename: ' . $info['filename']);
$output->writeln(' Created: ' . $info['createdAt']);
$output->writeln(' Version: ' . ($info['appVersion'] ?? 'unknown'));
$output->writeln(' Tables: ' . count($info['tables']));
$totalRows = 0;
foreach ($info['tables'] as $count) {
$totalRows += $count;
}
$output->writeln(' Total rows: ' . $totalRows);
$output->writeln('');
// Confirmation
if (!$skipConfirm) {
$output->writeln('<comment>WARNING: This will DELETE ALL existing data and replace it with the backup.</comment>');
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('Continue? [y/N] ', false);
if (!$helper->ask($input, $output, $question)) {
$output->writeln('Restore cancelled.');
return Command::SUCCESS;
}
}
$output->writeln('Restoring...');
try {
$result = $this->backupService->restoreBackup($filename, $password);
$output->writeln('');
$output->writeln('<info>Restore completed successfully!</info>');
$output->writeln(' Tables restored: ' . $result['restoredTables']);
$output->writeln(' Total rows: ' . $result['totalRows']);
return Command::SUCCESS;
} catch (\Exception $e) {
$output->writeln('<error>Restore failed: ' . $e->getMessage() . '</error>');
return Command::FAILURE;
}
}
}
+200
View File
@@ -0,0 +1,200 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Controller;
use OCA\Mitgliederverwaltung\Service\BackupService;
use OCA\Mitgliederverwaltung\Service\BackupSettingsService;
use OCA\Mitgliederverwaltung\Service\SelfUpdateService;
use OCP\AppFramework\ApiController;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataDownloadResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
/**
* REST API controller for backup and restore operations.
*
* All endpoints are admin-only (enforced by AuthorizationMiddleware).
*/
class BackupController extends ApiController {
use ApiControllerTrait;
private BackupService $backupService;
private BackupSettingsService $settingsService;
private SelfUpdateService $updateService;
private LoggerInterface $logger;
public function __construct(
string $appName,
IRequest $request,
BackupService $backupService,
BackupSettingsService $settingsService,
SelfUpdateService $updateService,
LoggerInterface $logger
) {
parent::__construct($appName, $request);
$this->backupService = $backupService;
$this->settingsService = $settingsService;
$this->updateService = $updateService;
$this->logger = $logger;
}
/**
* List all available backups.
*
* GET /api/v1/backups
*/
public function index(): JSONResponse {
return $this->handleAction(function () {
return new JSONResponse($this->backupService->listBackups());
}, 'Backup-Liste');
}
/**
* Create a new backup.
*
* POST /api/v1/backups
* Body: { password?: string }
*/
public function create(): JSONResponse {
return $this->handleAction(function () {
$data = $this->getRequestData();
$password = $data['password'] ?? null;
$result = $this->backupService->createBackup($password, 'manual');
return new JSONResponse($result, Http::STATUS_CREATED);
}, 'Backup erstellen');
}
/**
* Get backup info (manifest).
*
* GET /api/v1/backups/{filename}
*/
public function show(string $filename): JSONResponse {
return $this->handleAction(function () use ($filename) {
$info = $this->backupService->getBackupInfo($filename);
if ($info === null) {
return new JSONResponse(
['error' => 'Backup nicht gefunden'],
Http::STATUS_NOT_FOUND
);
}
return new JSONResponse($info);
}, 'Backup-Info');
}
/**
* Download a backup ZIP file.
*
* GET /api/v1/backups/{filename}/download
*/
public function download(string $filename): DataDownloadResponse|JSONResponse {
try {
$filePath = $this->backupService->getBackupFilePath($filename);
$content = file_get_contents($filePath);
return new DataDownloadResponse(
$content,
basename($filename),
'application/zip'
);
} catch (\Exception $e) {
$this->logger->error('Backup download failed', [
'filename' => $filename,
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
return new JSONResponse(
['error' => 'Download fehlgeschlagen'],
Http::STATUS_INTERNAL_SERVER_ERROR
);
}
}
/**
* Restore from a backup.
*
* POST /api/v1/backups/{filename}/restore
* Body: { password?: string, confirm: true }
*/
public function restore(string $filename): JSONResponse {
return $this->handleAction(function () use ($filename) {
$data = $this->getRequestData();
if (empty($data['confirm'])) {
return new JSONResponse(
['error' => 'Bestaetigung erforderlich (confirm: true)'],
Http::STATUS_BAD_REQUEST
);
}
$password = $data['password'] ?? null;
$result = $this->backupService->restoreBackup($filename, $password);
return new JSONResponse($result);
}, 'Backup wiederherstellen');
}
/**
* Delete a backup.
*
* DELETE /api/v1/backups/{filename}
*/
public function destroy(string $filename): JSONResponse {
return $this->handleAction(function () use ($filename) {
$this->backupService->deleteBackup($filename);
return new JSONResponse(['status' => 'deleted']);
}, 'Backup loeschen');
}
/**
* Get backup settings.
*
* GET /api/v1/backups/settings
*/
public function getSettings(): JSONResponse {
return new JSONResponse($this->settingsService->getAllSettings());
}
/**
* Update backup settings.
*
* PUT /api/v1/backups/settings
* Body: { schedule?, retentionCount?, password? }
*/
public function updateSettings(): JSONResponse {
return $this->handleAction(function () {
$data = $this->getRequestData();
$this->settingsService->updateSettings($data);
return new JSONResponse($this->settingsService->getAllSettings());
}, 'Backup-Einstellungen aktualisieren');
}
// ── Self-update endpoints ───────────────────────────────────
/**
* Check for available updates from Gitea.
*
* GET /api/v1/update/check
*/
public function checkUpdate(): JSONResponse {
return $this->handleAction(function () {
return new JSONResponse($this->updateService->checkForUpdate());
}, 'Update-Check');
}
/**
* Download and install the latest update.
*
* POST /api/v1/update/install
* Body: { force?: bool }
*/
public function installUpdate(): JSONResponse {
return $this->handleAction(function () {
$data = $this->getRequestData();
$force = !empty($data['force']);
$result = $this->updateService->performUpdate($force);
return new JSONResponse($result);
}, 'Update installieren');
}
}
+5 -2
View File
@@ -534,7 +534,9 @@ class ImportController extends ApiController {
); );
} }
$result = $this->bundleImportService->previewBundle($decoded); $corrections = $data['corrections'] ?? [];
$result = $this->bundleImportService->previewBundle($decoded, $corrections);
return new JSONResponse($result); return new JSONResponse($result);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Failed to preview bundle', [ $this->logger->error('Failed to preview bundle', [
@@ -574,8 +576,9 @@ class ImportController extends ApiController {
} }
$overrides = $data['overrides'] ?? []; $overrides = $data['overrides'] ?? [];
$corrections = $data['corrections'] ?? [];
$result = $this->bundleImportService->executeBundle($decoded, $overrides); $result = $this->bundleImportService->executeBundle($decoded, $overrides, $corrections);
return new JSONResponse($result); return new JSONResponse($result);
} catch (\Exception $e) { } catch (\Exception $e) {
$this->logger->error('Failed to execute bundle import', [ $this->logger->error('Failed to execute bundle import', [
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Middleware; namespace OCA\Mitgliederverwaltung\Middleware;
use OCA\Mitgliederverwaltung\Controller\AuditController; use OCA\Mitgliederverwaltung\Controller\AuditController;
use OCA\Mitgliederverwaltung\Controller\BackupController;
use OCA\Mitgliederverwaltung\Controller\DsgvoController; use OCA\Mitgliederverwaltung\Controller\DsgvoController;
use OCA\Mitgliederverwaltung\Controller\ExportController; use OCA\Mitgliederverwaltung\Controller\ExportController;
use OCA\Mitgliederverwaltung\Controller\FeeController; use OCA\Mitgliederverwaltung\Controller\FeeController;
@@ -89,6 +90,7 @@ class AuthorizationMiddleware extends Middleware {
|| $controller instanceof PermissionController || $controller instanceof PermissionController
|| $controller instanceof AuditController || $controller instanceof AuditController
|| $controller instanceof DsgvoController || $controller instanceof DsgvoController
|| $controller instanceof BackupController
) { ) {
if (!$this->permissionService->isAdmin($userId)) { if (!$this->permissionService->isAdmin($userId)) {
throw new \RuntimeException('Authorization: admin required'); throw new \RuntimeException('Authorization: admin required');
+490
View File
@@ -0,0 +1,490 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
/**
* Core backup and restore service.
*
* Creates JSON snapshots of all mv_* tables plus app settings as a ZIP file.
* Restore is all-or-nothing: truncate all tables, re-insert in dependency order.
* Encrypted field values are preserved as ciphertext.
*/
class BackupService {
private const APP_ID = 'mitgliederverwaltung';
private const SCHEMA_VERSION = 1;
private const BACKUP_DIR = 'backups';
/**
* All mv_* tables in dependency order (parents before children).
* Restore inserts in this order; truncate deletes in reverse.
*/
private const TABLE_RESTORE_ORDER = [
'mv_stufen',
'mv_families',
'mv_fee_rules',
'mv_lager',
'mv_saved_queries',
'mv_permissions',
'mv_members',
'mv_addresses',
'mv_phones',
'mv_emails',
'mv_stufe_history',
'mv_fee_records',
'mv_lager_stufen',
'mv_lager_teilnehmer',
'mv_injuries',
'mv_injury_involved',
'mv_sync_queue',
'mv_calendar_events',
'mv_contact_cards',
'mv_audit_log',
];
/**
* App config keys to include in the backup.
*/
private const SETTINGS_KEYS = [
'file_base_path',
'file_subfolder_pattern',
'file_auto_create',
'file_lager_base_path',
'milestone_thresholds',
'backup_schedule',
'backup_retention_count',
];
private IDBConnection $db;
private IConfig $config;
private IUserSession $userSession;
private BackupSettingsService $backupSettings;
private LoggerInterface $logger;
public function __construct(
IDBConnection $db,
IConfig $config,
IUserSession $userSession,
BackupSettingsService $backupSettings,
LoggerInterface $logger
) {
$this->db = $db;
$this->config = $config;
$this->userSession = $userSession;
$this->backupSettings = $backupSettings;
$this->logger = $logger;
}
/**
* Create a full backup ZIP containing all tables + settings as JSON.
*
* @param string|null $password Optional password for ZIP encryption
* @param string $triggeredBy 'manual' or 'scheduled'
* @return array{filename: string, size: int, tables: array<string, int>}
*/
public function createBackup(?string $password = null, string $triggeredBy = 'manual'): array {
$timestamp = date('Y-m-d_His');
$filename = "backup_mitgliederverwaltung_{$timestamp}.zip";
$tmpFile = tempnam(sys_get_temp_dir(), 'mv_backup_');
if ($tmpFile === false) {
throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.');
}
chmod($tmpFile, 0600);
try {
$zip = new \ZipArchive();
$result = $zip->open($tmpFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
if ($result !== true) {
throw new \RuntimeException('ZIP konnte nicht erstellt werden. Fehlercode: ' . $result);
}
if ($password !== null && $password !== '') {
$zip->setPassword($password);
}
// Dump each table
$tableCounts = [];
foreach (self::TABLE_RESTORE_ORDER as $table) {
$rows = $this->exportTable($table);
$tableCounts[$table] = count($rows);
$json = json_encode($rows, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$entryName = "tables/{$table}.json";
$zip->addFromString($entryName, $json);
if ($password !== null && $password !== '') {
$zip->setEncryptionName($entryName, \ZipArchive::EM_AES_256);
}
}
// Settings
$settings = $this->exportAppSettings();
$settingsJson = json_encode($settings, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$zip->addFromString('settings.json', $settingsJson);
if ($password !== null && $password !== '') {
$zip->setEncryptionName('settings.json', \ZipArchive::EM_AES_256);
}
// Manifest
$userId = $this->userSession->getUser()?->getUID() ?? 'system';
$manifest = [
'appVersion' => $this->config->getAppValue(self::APP_ID, 'installed_version', '0.0.0'),
'schemaVersion' => self::SCHEMA_VERSION,
'createdAt' => date('c'),
'triggeredBy' => $triggeredBy,
'userId' => $userId,
'tables' => $tableCounts,
'settingsIncluded' => true,
'auditLogIncluded' => true,
];
$manifestJson = json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$zip->addFromString('manifest.json', $manifestJson);
$zip->close();
// Move to backup storage
$storageDir = $this->getBackupStoragePath();
$targetPath = $storageDir . '/' . $filename;
if (!rename($tmpFile, $targetPath)) {
throw new \RuntimeException('Backup konnte nicht in den Speicher verschoben werden.');
}
chmod($targetPath, 0600);
$tmpFile = null; // Prevent cleanup
$this->logger->info('Backup created', [
'filename' => $filename,
'triggeredBy' => $triggeredBy,
'tables' => $tableCounts,
'app' => self::APP_ID,
]);
return [
'filename' => $filename,
'size' => filesize($targetPath),
'tables' => $tableCounts,
'createdAt' => $manifest['createdAt'],
];
} finally {
if ($tmpFile !== null && file_exists($tmpFile)) {
unlink($tmpFile);
}
}
}
/**
* Restore from a backup ZIP. All-or-nothing via transaction.
*
* @return array{restoredTables: int, totalRows: int}
*/
public function restoreBackup(string $filename, ?string $password = null): array {
$filePath = $this->getBackupStoragePath() . '/' . basename($filename);
if (!file_exists($filePath)) {
throw new ValidationException('Backup-Datei nicht gefunden: ' . $filename);
}
$zip = new \ZipArchive();
$result = $zip->open($filePath);
if ($result !== true) {
throw new \RuntimeException('ZIP konnte nicht geoeffnet werden. Fehlercode: ' . $result);
}
if ($password !== null && $password !== '') {
$zip->setPassword($password);
}
try {
// Read manifest
$manifestJson = $zip->getFromName('manifest.json');
if ($manifestJson === false) {
throw new ValidationException('Ungueltige Backup-Datei: manifest.json fehlt');
}
$manifest = json_decode($manifestJson, true);
if (!is_array($manifest) || !isset($manifest['schemaVersion'])) {
throw new ValidationException('Ungueltige manifest.json');
}
if ($manifest['schemaVersion'] > self::SCHEMA_VERSION) {
throw new ValidationException(
'Backup-Schema-Version ' . $manifest['schemaVersion'] .
' ist neuer als unterstuetzte Version ' . self::SCHEMA_VERSION
);
}
// Load all table data into memory first to validate before truncating
$tableData = [];
foreach (self::TABLE_RESTORE_ORDER as $table) {
$json = $zip->getFromName("tables/{$table}.json");
if ($json === false) {
// Table may not exist in older backups — skip
$tableData[$table] = [];
continue;
}
$rows = json_decode($json, true);
if (!is_array($rows)) {
throw new ValidationException("Ungueltiges JSON fuer Tabelle {$table}");
}
$tableData[$table] = $rows;
}
// Load settings
$settingsJson = $zip->getFromName('settings.json');
$settings = $settingsJson !== false ? json_decode($settingsJson, true) : null;
$zip->close();
// Execute restore in transaction
$this->db->beginTransaction();
try {
// Truncate in reverse dependency order
$reversed = array_reverse(self::TABLE_RESTORE_ORDER);
foreach ($reversed as $table) {
$this->truncateTable($table);
}
// Insert in forward dependency order
$totalRows = 0;
$restoredTables = 0;
foreach (self::TABLE_RESTORE_ORDER as $table) {
$rows = $tableData[$table];
if (!empty($rows)) {
$this->insertRows($table, $rows);
$totalRows += count($rows);
$restoredTables++;
}
}
// Restore settings
if (is_array($settings)) {
$this->restoreAppSettings($settings);
}
$this->db->commit();
} catch (\Exception $e) {
$this->db->rollBack();
throw $e;
}
$this->logger->info('Backup restored', [
'filename' => $filename,
'restoredTables' => $restoredTables,
'totalRows' => $totalRows,
'app' => self::APP_ID,
]);
return [
'restoredTables' => $restoredTables,
'totalRows' => $totalRows,
];
} catch (\Exception $e) {
if ($zip->count() > 0) {
$zip->close();
}
throw $e;
}
}
/**
* List all available backup files.
*
* @return array[] Each: {filename, size, createdAt, tables}
*/
public function listBackups(): array {
$dir = $this->getBackupStoragePath();
$files = glob($dir . '/backup_mitgliederverwaltung_*.zip');
if ($files === false) {
return [];
}
$backups = [];
foreach ($files as $file) {
$info = $this->getBackupInfoFromFile($file);
if ($info !== null) {
$backups[] = $info;
}
}
// Sort newest first
usort($backups, fn($a, $b) => strcmp($b['createdAt'], $a['createdAt']));
return $backups;
}
/**
* Get info about a single backup (from manifest).
*/
public function getBackupInfo(string $filename): ?array {
$filePath = $this->getBackupStoragePath() . '/' . basename($filename);
if (!file_exists($filePath)) {
return null;
}
return $this->getBackupInfoFromFile($filePath);
}
/**
* Get the full filesystem path for downloading a backup.
*/
public function getBackupFilePath(string $filename): string {
$filePath = $this->getBackupStoragePath() . '/' . basename($filename);
if (!file_exists($filePath)) {
throw new ValidationException('Backup-Datei nicht gefunden: ' . $filename);
}
return $filePath;
}
/**
* Delete a backup file.
*/
public function deleteBackup(string $filename): void {
$filePath = $this->getBackupStoragePath() . '/' . basename($filename);
if (file_exists($filePath)) {
unlink($filePath);
}
}
/**
* Delete oldest backups beyond the configured retention count.
*
* @return int Number of deleted backups
*/
public function rotateBackups(): int {
$retention = $this->backupSettings->getRetentionCount();
$backups = $this->listBackups();
if (count($backups) <= $retention) {
return 0;
}
$toDelete = array_slice($backups, $retention);
foreach ($toDelete as $backup) {
$this->deleteBackup($backup['filename']);
}
return count($toDelete);
}
// ── Private helpers ─────────────────────────────────────────────
private function getBackupStoragePath(): string {
$dataDir = $this->config->getSystemValue('datadirectory', '/var/www/html/data');
$instanceId = $this->config->getSystemValue('instanceid', '');
$path = $dataDir . '/appdata_' . $instanceId . '/' . self::APP_ID . '/' . self::BACKUP_DIR;
if (!is_dir($path)) {
mkdir($path, 0750, true);
}
return $path;
}
/**
* Export all rows from a table as an array of associative arrays.
*/
private function exportTable(string $tableName): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($tableName);
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
return $rows;
}
/**
* Export all app config keys for this app.
*/
private function exportAppSettings(): array {
$settings = [];
foreach (self::SETTINGS_KEYS as $key) {
$val = $this->config->getAppValue(self::APP_ID, $key, '');
if ($val !== '') {
$settings[$key] = $val;
}
}
return $settings;
}
/**
* Delete all rows from a table.
*/
private function truncateTable(string $tableName): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($tableName);
$qb->executeStatement();
}
/**
* Batch-insert rows into a table.
*/
private function insertRows(string $tableName, array $rows): void {
if (empty($rows)) {
return;
}
$columns = array_keys($rows[0]);
// Insert in batches for performance
$batchSize = 200;
foreach (array_chunk($rows, $batchSize) as $batch) {
foreach ($batch as $row) {
$qb = $this->db->getQueryBuilder();
$qb->insert($tableName);
foreach ($columns as $col) {
$value = $row[$col] ?? null;
$qb->setValue($col, $qb->createNamedParameter($value));
}
$qb->executeStatement();
}
}
}
/**
* Restore app config settings.
*/
private function restoreAppSettings(array $settings): void {
foreach ($settings as $key => $value) {
// Only restore known keys to avoid injecting unexpected config
if (in_array($key, self::SETTINGS_KEYS, true)) {
$this->config->setAppValue(self::APP_ID, $key, $value);
}
}
}
/**
* Read backup info from a ZIP file's manifest.
*/
private function getBackupInfoFromFile(string $filePath): ?array {
$zip = new \ZipArchive();
if ($zip->open($filePath, \ZipArchive::RDONLY) !== true) {
return null;
}
$manifestJson = $zip->getFromName('manifest.json');
$zip->close();
if ($manifestJson === false) {
return [
'filename' => basename($filePath),
'size' => filesize($filePath),
'createdAt' => date('c', filemtime($filePath)),
'tables' => [],
];
}
$manifest = json_decode($manifestJson, true);
return [
'filename' => basename($filePath),
'size' => filesize($filePath),
'createdAt' => $manifest['createdAt'] ?? date('c', filemtime($filePath)),
'appVersion' => $manifest['appVersion'] ?? null,
'schemaVersion' => $manifest['schemaVersion'] ?? null,
'triggeredBy' => $manifest['triggeredBy'] ?? null,
'userId' => $manifest['userId'] ?? null,
'tables' => $manifest['tables'] ?? [],
'auditLogIncluded' => $manifest['auditLogIncluded'] ?? false,
];
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use OCP\IConfig;
use OCP\Security\ICrypto;
/**
* Read/write backup configuration stored in Nextcloud's app config.
*
* Settings:
* - backup_schedule: 'daily' | 'weekly' | 'disabled' (default 'disabled')
* - backup_retention_count: int (default 10)
* - backup_password: encrypted string (optional, for automated backups)
*/
class BackupSettingsService {
private const APP_ID = 'mitgliederverwaltung';
private IConfig $config;
private ICrypto $crypto;
public function __construct(IConfig $config, ICrypto $crypto) {
$this->config = $config;
$this->crypto = $crypto;
}
public function getSchedule(): string {
return $this->config->getAppValue(self::APP_ID, 'backup_schedule', 'disabled');
}
public function setSchedule(string $schedule): void {
$valid = ['daily', 'weekly', 'disabled'];
if (!in_array($schedule, $valid, true)) {
throw new ValidationException('Ungueltiger Zeitplan: ' . $schedule);
}
$this->config->setAppValue(self::APP_ID, 'backup_schedule', $schedule);
}
public function getRetentionCount(): int {
return (int) $this->config->getAppValue(self::APP_ID, 'backup_retention_count', '10');
}
public function setRetentionCount(int $count): void {
if ($count < 1) {
throw new ValidationException('Aufbewahrungsanzahl muss mindestens 1 sein');
}
$this->config->setAppValue(self::APP_ID, 'backup_retention_count', (string) $count);
}
public function getBackupPassword(): ?string {
$encrypted = $this->config->getAppValue(self::APP_ID, 'backup_password', '');
if ($encrypted === '') {
return null;
}
try {
return $this->crypto->decrypt($encrypted);
} catch (\Exception $e) {
return null;
}
}
public function setBackupPassword(?string $password): void {
if ($password === null || $password === '') {
$this->config->setAppValue(self::APP_ID, 'backup_password', '');
} else {
$this->config->setAppValue(self::APP_ID, 'backup_password', $this->crypto->encrypt($password));
}
}
public function getAllSettings(): array {
return [
'schedule' => $this->getSchedule(),
'retentionCount' => $this->getRetentionCount(),
'hasPassword' => $this->getBackupPassword() !== null,
];
}
public function updateSettings(array $data): void {
if (isset($data['schedule'])) {
$this->setSchedule($data['schedule']);
}
if (isset($data['retentionCount'])) {
$this->setRetentionCount((int) $data['retentionCount']);
}
if (array_key_exists('password', $data)) {
$this->setBackupPassword($data['password']);
}
}
}
+19 -4
View File
@@ -136,7 +136,7 @@ class BundleImportService {
* @param array $entityOverrides Optional type overrides: {filename: type} * @param array $entityOverrides Optional type overrides: {filename: type}
* @return array{results: array[], idMap: array} * @return array{results: array[], idMap: array}
*/ */
public function executeBundle(string $zipContent, array $entityOverrides = []): array { public function executeBundle(string $zipContent, array $entityOverrides = [], array $corrections = []): array {
$csvFiles = $this->extractCsvFiles($zipContent); $csvFiles = $this->extractCsvFiles($zipContent);
$analysis = $this->analyzeBundle($zipContent); $analysis = $this->analyzeBundle($zipContent);
@@ -188,12 +188,16 @@ class BundleImportService {
$parsed = $this->entityImportService->parseFile($remappedContent, ';', 'UTF-8'); $parsed = $this->entityImportService->parseFile($remappedContent, ';', 'UTF-8');
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']); $mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
$entityCorrections = $corrections[$type] ?? [];
$result = $this->entityImportService->execute( $result = $this->entityImportService->execute(
$type, $type,
$remappedContent, $remappedContent,
$mapping, $mapping,
';', ';',
'UTF-8' 'UTF-8',
false,
$entityCorrections
); );
// Collect ID mappings from created entities // Collect ID mappings from created entities
@@ -236,9 +240,11 @@ class BundleImportService {
/** /**
* Preview bundle import (dry-run for all entities). * Preview bundle import (dry-run for all entities).
* *
* @param string $zipContent Raw ZIP binary content
* @param array $corrections Per-entity corrections: { entityType: { rowNum: { fieldKey: value } } }
* @return array{results: array[]} * @return array{results: array[]}
*/ */
public function previewBundle(string $zipContent): array { public function previewBundle(string $zipContent, array $corrections = []): array {
$csvFiles = $this->extractCsvFiles($zipContent); $csvFiles = $this->extractCsvFiles($zipContent);
$analysis = $this->analyzeBundle($zipContent); $analysis = $this->analyzeBundle($zipContent);
$results = []; $results = [];
@@ -262,14 +268,21 @@ class BundleImportService {
$parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8'); $parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8');
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']); $mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
$entityCorrections = $corrections[$type] ?? [];
$preview = $this->entityImportService->preview( $preview = $this->entityImportService->preview(
$type, $type,
$content, $content,
$mapping, $mapping,
';', ';',
'UTF-8' 'UTF-8',
false,
$entityCorrections
); );
// Include target fields so the frontend can render editable inputs
$schema = $this->entityImportService->getImportSchema($type);
$results[] = [ $results[] = [
'type' => $type, 'type' => $type,
'label' => $entityEntry['label'], 'label' => $entityEntry['label'],
@@ -277,6 +290,7 @@ class BundleImportService {
'fkWarnings' => $preview['fkWarnings'], 'fkWarnings' => $preview['fkWarnings'],
'errorCount' => count($preview['errors']), 'errorCount' => count($preview['errors']),
'errors' => array_slice($preview['errors'], 0, 10), 'errors' => array_slice($preview['errors'], 0, 10),
'targetFields' => $schema['targetFields'],
]; ];
} catch (\Exception $e) { } catch (\Exception $e) {
$results[] = [ $results[] = [
@@ -286,6 +300,7 @@ class BundleImportService {
'fkWarnings' => [], 'fkWarnings' => [],
'errorCount' => 1, 'errorCount' => 1,
'errors' => [['_rowIndex' => 0, '_errors' => [$e->getMessage()]]], 'errors' => [['_rowIndex' => 0, '_errors' => [$e->getMessage()]]],
'targetFields' => [],
]; ];
} }
} }
+22 -2
View File
@@ -9,6 +9,7 @@ use OCA\Mitgliederverwaltung\Db\Permission;
use OCA\Mitgliederverwaltung\Db\PermissionMapper; use OCA\Mitgliederverwaltung\Db\PermissionMapper;
use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\DoesNotExistException;
use OCP\DB\Exception; use OCP\DB\Exception;
use OCP\IGroupManager;
/** /**
* Permission checking logic for all access levels. * Permission checking logic for all access levels.
@@ -26,13 +27,16 @@ class PermissionService {
private PermissionMapper $permissionMapper; private PermissionMapper $permissionMapper;
private MemberMapper $memberMapper; private MemberMapper $memberMapper;
private IGroupManager $groupManager;
public function __construct( public function __construct(
PermissionMapper $permissionMapper, PermissionMapper $permissionMapper,
MemberMapper $memberMapper MemberMapper $memberMapper,
IGroupManager $groupManager
) { ) {
$this->permissionMapper = $permissionMapper; $this->permissionMapper = $permissionMapper;
$this->memberMapper = $memberMapper; $this->memberMapper = $memberMapper;
$this->groupManager = $groupManager;
} }
/** /**
@@ -54,6 +58,9 @@ class PermissionService {
* @throws Exception * @throws Exception
*/ */
public function canAccess(string $userId): bool { public function canAccess(string $userId): bool {
if ($this->groupManager->isInGroup($userId, 'admin')) {
return true;
}
$perm = $this->getUserPermission($userId); $perm = $this->getUserPermission($userId);
return $perm !== null && $perm->getLevel() !== 'none'; return $perm !== null && $perm->getLevel() !== 'none';
} }
@@ -64,6 +71,9 @@ class PermissionService {
* @throws Exception * @throws Exception
*/ */
public function canRead(string $userId): bool { public function canRead(string $userId): bool {
if ($this->groupManager->isInGroup($userId, 'admin')) {
return true;
}
$perm = $this->getUserPermission($userId); $perm = $this->getUserPermission($userId);
if ($perm === null) { if ($perm === null) {
return false; return false;
@@ -79,6 +89,9 @@ class PermissionService {
* @throws Exception * @throws Exception
*/ */
public function canWrite(string $userId, ?int $memberId = null): bool { public function canWrite(string $userId, ?int $memberId = null): bool {
if ($this->groupManager->isInGroup($userId, 'admin')) {
return true;
}
$perm = $this->getUserPermission($userId); $perm = $this->getUserPermission($userId);
if ($perm === null) { if ($perm === null) {
return false; return false;
@@ -136,7 +149,11 @@ class PermissionService {
*/ */
public function isAdmin(string $userId): bool { public function isAdmin(string $userId): bool {
$perm = $this->getUserPermission($userId); $perm = $this->getUserPermission($userId);
return $perm !== null && $perm->getLevel() === 'admin'; if ($perm !== null && $perm->getLevel() === 'admin') {
return true;
}
// Fallback: Nextcloud system admins are always app admins
return $this->groupManager->isInGroup($userId, 'admin');
} }
/** /**
@@ -145,6 +162,9 @@ class PermissionService {
* @throws Exception * @throws Exception
*/ */
public function canSeeBanking(string $userId): bool { public function canSeeBanking(string $userId): bool {
if ($this->groupManager->isInGroup($userId, 'admin')) {
return true;
}
$perm = $this->getUserPermission($userId); $perm = $this->getUserPermission($userId);
return $perm !== null && $perm->getCanSeeBanking(); return $perm !== null && $perm->getCanSeeBanking();
} }
+413
View File
@@ -0,0 +1,413 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
/**
* Self-update service that checks for and applies updates from Gitea releases.
*
* Fetches the latest release from the configured Gitea repository,
* downloads the tarball asset, and extracts it over the installed app.
*/
class SelfUpdateService {
private const APP_ID = 'mitgliederverwaltung';
private const GITEA_BASE = 'https://git.shahondin1624.de';
private const GITEA_OWNER = 'shahondin1624';
private const GITEA_REPO = 'Mitgliederverwaltung';
/** Ed25519 public key for release signature verification (base64-encoded, 32 bytes). */
private const PUBLIC_KEY = 'PQVDGgPZx7o6fO1GGR26dVtcAupUb519yDpQaz3/61U=';
private IConfig $config;
private IClientService $clientService;
private LoggerInterface $logger;
public function __construct(
IConfig $config,
IClientService $clientService,
LoggerInterface $logger
) {
$this->config = $config;
$this->clientService = $clientService;
$this->logger = $logger;
}
/**
* Get the currently installed app version.
*/
public function getCurrentVersion(): string {
return $this->config->getAppValue(self::APP_ID, 'installed_version', '0.0.0');
}
/**
* Check Gitea for the latest release.
*
* @return array{available: bool, currentVersion: string, latestVersion: string|null, releaseUrl: string|null, publishedAt: string|null}
*/
public function checkForUpdate(): array {
$currentVersion = $this->getCurrentVersion();
try {
$release = $this->fetchLatestRelease();
} catch (\Exception $e) {
$this->logger->warning('Failed to check for updates', [
'exception' => $e,
'app' => self::APP_ID,
]);
return [
'available' => false,
'currentVersion' => $currentVersion,
'latestVersion' => null,
'releaseUrl' => null,
'publishedAt' => null,
'error' => $e->getMessage(),
];
}
if ($release === null) {
return [
'available' => false,
'currentVersion' => $currentVersion,
'latestVersion' => null,
'releaseUrl' => null,
'publishedAt' => null,
];
}
$latestVersion = ltrim($release['tag_name'], 'v');
$isNewer = version_compare($latestVersion, $currentVersion, '>');
$assets = $this->findReleaseAssets($release);
return [
'available' => $isNewer,
'currentVersion' => $currentVersion,
'latestVersion' => $latestVersion,
'releaseUrl' => $release['html_url'] ?? null,
'publishedAt' => $release['published_at'] ?? null,
'releaseName' => $release['name'] ?? null,
'releaseBody' => $release['body'] ?? null,
'signed' => $assets['sigUrl'] !== null,
];
}
/**
* Download and install the latest release.
*
* @param bool $force Install even if version is not newer
* @return array{success: bool, oldVersion: string, newVersion: string, message: string}
*/
public function performUpdate(bool $force = false): array {
$currentVersion = $this->getCurrentVersion();
$release = $this->fetchLatestRelease();
if ($release === null) {
return [
'success' => false,
'oldVersion' => $currentVersion,
'newVersion' => $currentVersion,
'message' => 'Kein Release auf Gitea gefunden.',
];
}
$latestVersion = ltrim($release['tag_name'], 'v');
if (!$force && !version_compare($latestVersion, $currentVersion, '>')) {
return [
'success' => false,
'oldVersion' => $currentVersion,
'newVersion' => $currentVersion,
'message' => "Bereits auf dem neuesten Stand (v{$currentVersion}).",
];
}
// Find release assets (tarball + signature)
$assets = $this->findReleaseAssets($release);
if ($assets['tarballUrl'] === null) {
return [
'success' => false,
'oldVersion' => $currentVersion,
'newVersion' => $latestVersion,
'message' => 'Kein .tar.gz Asset im Release gefunden.',
];
}
if ($assets['sigUrl'] === null) {
return [
'success' => false,
'oldVersion' => $currentVersion,
'newVersion' => $latestVersion,
'message' => 'Keine Signatur (.sig) im Release gefunden — Update abgelehnt.',
];
}
// Download both files
$tmpTarball = $this->downloadAsset($assets['tarballUrl']);
$tmpSig = null;
try {
$tmpSig = $this->downloadAsset($assets['sigUrl']);
// Verify signature BEFORE extraction
$this->verifySignature($tmpTarball, $tmpSig);
$this->logger->info('Release signature verified', [
'version' => $latestVersion,
'app' => self::APP_ID,
]);
// Extract over the installed app
$this->extractUpdate($tmpTarball);
$this->logger->info('App updated successfully', [
'from' => $currentVersion,
'to' => $latestVersion,
'app' => self::APP_ID,
]);
return [
'success' => true,
'oldVersion' => $currentVersion,
'newVersion' => $latestVersion,
'message' => "Update von v{$currentVersion} auf v{$latestVersion} erfolgreich (Signatur geprueft). Bitte occ upgrade ausfuehren.",
];
} finally {
if (file_exists($tmpTarball)) {
unlink($tmpTarball);
}
if ($tmpSig !== null && file_exists($tmpSig)) {
unlink($tmpSig);
}
}
}
/**
* Fetch the latest non-draft, non-prerelease from Gitea API.
*/
private function fetchLatestRelease(): ?array {
$url = self::GITEA_BASE . '/api/v1/repos/' . self::GITEA_OWNER . '/' . self::GITEA_REPO . '/releases?limit=5';
$client = $this->clientService->newClient();
$response = $client->get($url, [
'timeout' => 15,
'headers' => ['Accept' => 'application/json'],
]);
$releases = json_decode($response->getBody(), true);
if (!is_array($releases)) {
return null;
}
// Find latest non-draft, non-prerelease
foreach ($releases as $release) {
if (!($release['draft'] ?? false) && !($release['prerelease'] ?? false)) {
return $release;
}
}
return null;
}
/**
* Find the .tar.gz and .tar.gz.sig asset URLs from a release.
*
* @return array{tarballUrl: string|null, sigUrl: string|null}
*/
private function findReleaseAssets(array $release): array {
$tarballUrl = null;
$sigUrl = null;
$assets = $release['assets'] ?? [];
foreach ($assets as $asset) {
$name = strtolower($asset['name'] ?? '');
$url = $asset['browser_download_url'] ?? null;
if ($url === null) {
continue;
}
if (str_ends_with($name, '.tar.gz.sig')) {
$sigUrl = $url;
} elseif (str_ends_with($name, '.tar.gz')) {
$tarballUrl = $url;
}
}
return ['tarballUrl' => $tarballUrl, 'sigUrl' => $sigUrl];
}
/**
* Verify Ed25519 signature of a downloaded tarball.
*
* @throws \RuntimeException if verification fails
*/
private function verifySignature(string $tarballPath, string $signaturePath): void {
$publicKey = base64_decode(self::PUBLIC_KEY, true);
if ($publicKey === false || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new \RuntimeException('Ungueltiger eingebetteter oeffentlicher Schluessel.');
}
$signature = file_get_contents($signaturePath);
if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
throw new \RuntimeException('Ungueltige Signaturdatei (erwartet: ' . SODIUM_CRYPTO_SIGN_BYTES . ' Bytes).');
}
$content = file_get_contents($tarballPath);
if ($content === false) {
throw new \RuntimeException('Tarball konnte nicht gelesen werden.');
}
$valid = sodium_crypto_sign_verify_detached($signature, $content, $publicKey);
if (!$valid) {
$this->logger->error('Release signature verification FAILED', [
'tarball' => basename($tarballPath),
'app' => self::APP_ID,
]);
throw new \RuntimeException('Signaturpruefung fehlgeschlagen — Update abgebrochen. Die Datei wurde moeglicherweise manipuliert.');
}
}
/**
* Download an asset to a temp file.
*/
private function downloadAsset(string $url): string {
$tmpFile = tempnam(sys_get_temp_dir(), 'mv_update_');
if ($tmpFile === false) {
throw new \RuntimeException('Temporaere Datei konnte nicht erstellt werden.');
}
chmod($tmpFile, 0600);
$client = $this->clientService->newClient();
$response = $client->get($url, ['timeout' => 120, 'sink' => $tmpFile]);
if (filesize($tmpFile) < 1024) {
unlink($tmpFile);
throw new \RuntimeException('Download fehlgeschlagen oder Datei zu klein.');
}
return $tmpFile;
}
/**
* Extract a tarball over the installed app directory.
*
* The tarball is expected to contain a top-level `mitgliederverwaltung/` directory
* with appinfo/, lib/, templates/, js/, vendor/, img/.
*/
private function extractUpdate(string $tarballPath): void {
$appPath = $this->getAppInstallPath();
// Extract to temp dir first to validate structure
$tmpDir = sys_get_temp_dir() . '/mv_update_extract_' . uniqid();
mkdir($tmpDir, 0750, true);
try {
// Extract tarball
$phar = new \PharData($tarballPath);
$phar->extractTo($tmpDir, null, true);
// Find the extracted app dir (should be mitgliederverwaltung/)
$extractedApp = $tmpDir . '/' . self::APP_ID;
if (!is_dir($extractedApp)) {
// Maybe it extracted without the wrapper dir
$extractedApp = $tmpDir;
}
// Validate it has appinfo/info.xml
if (!file_exists($extractedApp . '/appinfo/info.xml')) {
throw new \RuntimeException('Ungueltiges Update-Paket: appinfo/info.xml fehlt.');
}
// Copy each directory over the existing installation
$dirs = ['appinfo', 'lib', 'templates', 'js', 'vendor', 'img'];
foreach ($dirs as $dir) {
$source = $extractedApp . '/' . $dir;
$target = $appPath . '/' . $dir;
if (is_dir($source)) {
// Remove old dir and replace
if (is_dir($target)) {
$this->removeDirectory($target);
}
$this->copyDirectory($source, $target);
}
}
// Copy top-level files if present
foreach (['LICENSE', 'README.md', 'CHANGELOG.md'] as $file) {
$source = $extractedApp . '/' . $file;
if (file_exists($source)) {
copy($source, $appPath . '/' . $file);
}
}
} finally {
$this->removeDirectory($tmpDir);
}
}
/**
* Determine where the app is installed.
*/
private function getAppInstallPath(): string {
// Check custom_apps first (writable), then apps
$paths = json_decode($this->config->getSystemValue('apps_paths', '[]'), true);
if (is_array($paths)) {
foreach ($paths as $pathConfig) {
if (($pathConfig['writable'] ?? false) === true) {
$candidate = $pathConfig['path'] . '/' . self::APP_ID;
if (is_dir($candidate)) {
return $candidate;
}
}
}
}
// Fallback: standard custom_apps location
$serverRoot = defined('OC_SERVERROOT') ? OC_SERVERROOT : '/var/www/html';
$fallback = $serverRoot . '/custom_apps/' . self::APP_ID;
if (is_dir($fallback)) {
return $fallback;
}
throw new \RuntimeException('App-Installationspfad konnte nicht ermittelt werden.');
}
private function removeDirectory(string $dir): void {
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
rmdir($item->getRealPath());
} else {
unlink($item->getRealPath());
}
}
rmdir($dir);
}
private function copyDirectory(string $source, string $dest): void {
mkdir($dest, 0750, true);
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::SELF_FIRST
);
foreach ($items as $item) {
$target = $dest . '/' . $items->getSubPathname();
if ($item->isDir()) {
if (!is_dir($target)) {
mkdir($target, 0750, true);
}
} else {
copy($item->getRealPath(), $target);
}
}
}
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env php
<?php
/**
* One-time Ed25519 keypair generation for release signing.
*
* Usage: php scripts/generate-keypair.php
*
* Saves the private key to ~/.mv-release-key (base64, mode 0600).
* Prints the public key constant to paste into SelfUpdateService.php.
*/
if (!function_exists('sodium_crypto_sign_keypair')) {
fwrite(STDERR, "ERROR: sodium extension is required (bundled in PHP 7.2+).\n");
exit(1);
}
$keyFile = $_SERVER['HOME'] . '/.mv-release-key';
if (file_exists($keyFile)) {
fwrite(STDERR, "WARNING: $keyFile already exists.\n");
fwrite(STDERR, "Delete it first if you want to generate a new keypair.\n");
fwrite(STDERR, "\n");
// Show existing public key
$secretKeyBase64 = trim(file_get_contents($keyFile));
$secretKey = base64_decode($secretKeyBase64);
$publicKey = sodium_crypto_sign_publickey_from_secretkey($secretKey);
$publicKeyBase64 = base64_encode($publicKey);
echo "Existing public key:\n\n";
echo " private const PUBLIC_KEY = '$publicKeyBase64';\n\n";
exit(0);
}
// Generate keypair
$keypair = sodium_crypto_sign_keypair();
$secretKey = sodium_crypto_sign_secretkey($keypair);
$publicKey = sodium_crypto_sign_publickey($keypair);
$secretKeyBase64 = base64_encode($secretKey);
$publicKeyBase64 = base64_encode($publicKey);
// Save private key
file_put_contents($keyFile, $secretKeyBase64 . "\n");
chmod($keyFile, 0600);
echo "Ed25519 keypair generated.\n\n";
echo "Private key saved to: $keyFile\n";
echo " (mode 0600 — keep this secret!)\n\n";
echo "Public key (paste into lib/Service/SelfUpdateService.php):\n\n";
echo " private const PUBLIC_KEY = '$publicKeyBase64';\n\n";
echo "Store the private key in:\n";
echo " 1. Gitea repo secret 'MV_RELEASE_KEY' (primary)\n";
echo " 2. Your password manager (backup)\n";
echo "\nBase64 private key for copy-paste:\n";
echo "$secretKeyBase64\n";
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env php
<?php
/**
* Sign a release tarball with Ed25519.
*
* Usage: php scripts/sign-release.php <tarball-path>
*
* Reads the private key from ~/.mv-release-key (base64-encoded).
* Produces <tarball-path>.sig (raw 64-byte Ed25519 signature).
*/
if ($argc < 2) {
fwrite(STDERR, "Usage: php scripts/sign-release.php <tarball-path>\n");
exit(1);
}
$tarballPath = $argv[1];
if (!file_exists($tarballPath)) {
fwrite(STDERR, "ERROR: File not found: $tarballPath\n");
exit(1);
}
if (!function_exists('sodium_crypto_sign_detached')) {
fwrite(STDERR, "ERROR: sodium extension is required.\n");
exit(1);
}
// Read private key
$keyFile = $_SERVER['HOME'] . '/.mv-release-key';
if (!file_exists($keyFile)) {
fwrite(STDERR, "ERROR: Private key not found at $keyFile\n");
fwrite(STDERR, "Run: php scripts/generate-keypair.php\n");
exit(1);
}
$secretKeyBase64 = trim(file_get_contents($keyFile));
$secretKey = base64_decode($secretKeyBase64, true);
if ($secretKey === false || strlen($secretKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) {
fwrite(STDERR, "ERROR: Invalid private key in $keyFile\n");
exit(1);
}
// Read tarball
$content = file_get_contents($tarballPath);
if ($content === false) {
fwrite(STDERR, "ERROR: Could not read $tarballPath\n");
exit(1);
}
// Sign
$signature = sodium_crypto_sign_detached($content, $secretKey);
// Write signature
$sigPath = $tarballPath . '.sig';
file_put_contents($sigPath, $signature);
echo "Signed: $sigPath (" . strlen($signature) . " bytes)\n";
// Verify as sanity check
$publicKey = sodium_crypto_sign_publickey_from_secretkey($secretKey);
if (sodium_crypto_sign_verify_detached($signature, $content, $publicKey)) {
echo "Verification: OK\n";
} else {
fwrite(STDERR, "ERROR: Self-verification failed!\n");
exit(1);
}
+8
View File
@@ -65,6 +65,13 @@
<ClipboardText :size="20" /> <ClipboardText :size="20" />
</template> </template>
</NcAppNavigationItem> </NcAppNavigationItem>
<NcAppNavigationItem name="Backup"
:to="{ name: 'backup' }"
:active="currentRoute === 'backup'">
<template #icon>
<BackupRestore :size="20" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem name="Einstellungen" <NcAppNavigationItem name="Einstellungen"
:to="{ name: 'settings' }" :to="{ name: 'settings' }"
:active="currentRoute === 'settings'"> :active="currentRoute === 'settings'">
@@ -97,6 +104,7 @@ import DatabaseSearch from 'vue-material-design-icons/DatabaseSearch.vue'
import SwapVertical from 'vue-material-design-icons/SwapVertical.vue' import SwapVertical from 'vue-material-design-icons/SwapVertical.vue'
import MedicalBag from 'vue-material-design-icons/MedicalBag.vue' import MedicalBag from 'vue-material-design-icons/MedicalBag.vue'
import Tent from 'vue-material-design-icons/Campfire.vue' import Tent from 'vue-material-design-icons/Campfire.vue'
import BackupRestore from 'vue-material-design-icons/BackupRestore.vue'
import SearchBar from './components/SearchBar.vue' import SearchBar from './components/SearchBar.vue'
const route = useRoute() const route = useRoute()
+113
View File
@@ -0,0 +1,113 @@
<template>
<div class="column-picker" ref="rootRef">
<NcButton @click="open = !open">
<template #icon>
<ViewColumn :size="20" />
</template>
Spalten
</NcButton>
<div v-if="open" class="column-picker__dropdown">
<label v-for="col in columns" :key="col.key"
class="column-picker__option">
<input type="checkbox"
:checked="modelValue.includes(col.key)"
:disabled="col.alwaysVisible"
@change="toggle(col.key)">
{{ col.label }}
</label>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { NcButton } from '@nextcloud/vue'
import ViewColumn from 'vue-material-design-icons/ViewColumn.vue'
const props = defineProps({
columns: { type: Array, required: true },
modelValue: { type: Array, required: true },
})
const emit = defineEmits(['update:modelValue'])
const open = ref(false)
const rootRef = ref(null)
function toggle(key) {
const col = props.columns.find(c => c.key === key)
if (col && col.alwaysVisible) return
const current = [...props.modelValue]
const idx = current.indexOf(key)
if (idx >= 0) {
current.splice(idx, 1)
} else {
// Insert in the same order as columns definition
const allIdx = props.columns.findIndex(c => c.key === key)
let insertAt = current.length
for (let i = 0; i < current.length; i++) {
const currentAllIdx = props.columns.findIndex(c => c.key === current[i])
if (currentAllIdx > allIdx) {
insertAt = i
break
}
}
current.splice(insertAt, 0, key)
}
emit('update:modelValue', current)
}
function onClickOutside(event) {
if (rootRef.value && !rootRef.value.contains(event.target)) {
open.value = false
}
}
onMounted(() => document.addEventListener('click', onClickOutside))
onBeforeUnmount(() => document.removeEventListener('click', onClickOutside))
</script>
<style scoped>
.column-picker {
position: relative;
}
.column-picker__dropdown {
position: absolute;
top: 100%;
right: 0;
z-index: 100;
min-width: 220px;
max-height: 400px;
overflow-y: auto;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 8px 0;
margin-top: 4px;
}
.column-picker__option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
cursor: pointer;
font-size: 0.9em;
white-space: nowrap;
}
.column-picker__option:hover {
background: var(--color-background-hover);
}
.column-picker__option input[type="checkbox"] {
margin: 0;
}
.column-picker__option input[type="checkbox"]:disabled {
opacity: 0.5;
}
</style>
+1 -1
View File
@@ -31,7 +31,7 @@ app.use(router)
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(), // @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
// not via webpack DefinePlugin globals. // not via webpack DefinePlugin globals.
app.provide('appName', 'mitgliederverwaltung') app.provide('appName', 'mitgliederverwaltung')
app.provide('appVersion', '0.1.5') app.provide('appVersion', '0.2.0')
app.mount('#mitgliederverwaltung') app.mount('#mitgliederverwaltung')
+5
View File
@@ -73,6 +73,11 @@ const routes = [
name: 'settings', name: 'settings',
component: () => import('./views/Settings.vue'), component: () => import('./views/Settings.vue'),
}, },
{
path: '/backup',
name: 'backup',
component: () => import('./views/Backup.vue'),
},
] ]
const router = createRouter({ const router = createRouter({
+153
View File
@@ -0,0 +1,153 @@
/**
* Pinia store for backup & restore state management.
*/
import { defineStore } from 'pinia'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
export const useBackupStore = defineStore('backup', {
state: () => ({
backups: [],
settings: { schedule: 'disabled', retentionCount: 10, hasPassword: false },
loading: false,
creating: false,
restoring: false,
updating: false,
updateInfo: null,
error: null,
success: null,
}),
actions: {
async fetchBackups() {
this.loading = true
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/backups')
const response = await axios.get(url)
this.backups = response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden der Backups'
} finally {
this.loading = false
}
},
async createBackup(password) {
this.creating = true
this.error = null
this.success = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/backups')
const response = await axios.post(url, { password: password || undefined })
this.success = `Backup "${response.data.filename}" erstellt`
await this.fetchBackups()
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Erstellen des Backups'
throw err
} finally {
this.creating = false
}
},
async restoreBackup(filename, password) {
this.restoring = true
this.error = null
this.success = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/backups/${filename}/restore`)
const response = await axios.post(url, {
confirm: true,
password: password || undefined,
})
this.success = `Backup wiederhergestellt: ${response.data.totalRows} Zeilen in ${response.data.restoredTables} Tabellen`
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Wiederherstellen'
throw err
} finally {
this.restoring = false
}
},
async deleteBackup(filename) {
this.error = null
try {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/backups/${filename}`)
await axios.delete(url)
this.backups = this.backups.filter(b => b.filename !== filename)
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Loeschen'
}
},
downloadBackup(filename) {
const url = generateUrl(`/apps/mitgliederverwaltung/api/v1/backups/${filename}/download`)
window.location.href = url
},
async fetchSettings() {
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/backups/settings')
const response = await axios.get(url)
this.settings = response.data
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Laden der Einstellungen'
}
},
async updateSettings(data) {
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/backups/settings')
const response = await axios.put(url, data)
this.settings = response.data
this.success = 'Backup-Einstellungen gespeichert'
} catch (err) {
this.error = err.response?.data?.error || 'Fehler beim Speichern der Einstellungen'
}
},
async checkForUpdate() {
this.error = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/update/check')
const response = await axios.get(url)
this.updateInfo = response.data
} catch (err) {
this.error = err.response?.data?.error || 'Update-Check fehlgeschlagen'
}
},
async installUpdate(force) {
this.updating = true
this.error = null
this.success = null
try {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/update/install')
const response = await axios.post(url, { force: force || false })
if (response.data.success) {
this.success = response.data.message
this.updateInfo = null
} else {
this.error = response.data.message
}
return response.data
} catch (err) {
this.error = err.response?.data?.error || 'Update fehlgeschlagen'
throw err
} finally {
this.updating = false
}
},
clearError() {
this.error = null
},
clearSuccess() {
this.success = null
},
},
})
+5
View File
@@ -69,6 +69,8 @@ export const useImportStore = defineStore('import', {
bundlePreviewResult: null, bundlePreviewResult: null,
/** @type {Object|null} Bundle execution result */ /** @type {Object|null} Bundle execution result */
bundleExecuteResult: null, bundleExecuteResult: null,
/** @type {Object} Per-entity corrections for bundle errors: { entityType: { rowIndex: { fieldKey: value } } } */
bundleCorrections: {},
}), }),
getters: { getters: {
@@ -305,6 +307,7 @@ export const useImportStore = defineStore('import', {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/preview') const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/preview')
const response = await axios.post(url, { const response = await axios.post(url, {
content: this.fileContent, content: this.fileContent,
corrections: this.bundleCorrections,
}) })
this.bundlePreviewResult = response.data this.bundlePreviewResult = response.data
@@ -328,6 +331,7 @@ export const useImportStore = defineStore('import', {
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/execute') const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/execute')
const response = await axios.post(url, { const response = await axios.post(url, {
content: this.fileContent, content: this.fileContent,
corrections: this.bundleCorrections,
}) })
this.bundleExecuteResult = response.data this.bundleExecuteResult = response.data
@@ -366,6 +370,7 @@ export const useImportStore = defineStore('import', {
this.bundleAnalysis = null this.bundleAnalysis = null
this.bundlePreviewResult = null this.bundlePreviewResult = null
this.bundleExecuteResult = null this.bundleExecuteResult = null
this.bundleCorrections = {}
}, },
/** /**
+42
View File
@@ -0,0 +1,42 @@
import { ref, computed, watch } from 'vue'
/**
* Composable for persisted column visibility.
*
* @param {string} storageKey - localStorage key
* @param {Array} allColumns - Column definitions with { key, label, ... }
* @param {string[]} defaultVisible - Keys visible by default
* @returns {{ visibleKeys, visibleColumns, setVisibleKeys }}
*/
export function useColumnVisibility(storageKey, allColumns, defaultVisible) {
function load() {
try {
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored)
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed
}
}
} catch {
// ignore
}
return [...defaultVisible]
}
const visibleKeys = ref(load())
const visibleColumns = computed(() =>
allColumns.filter(col => visibleKeys.value.includes(col.key)),
)
watch(visibleKeys, (val) => {
localStorage.setItem(storageKey, JSON.stringify(val))
}, { deep: true })
function setVisibleKeys(newKeys) {
visibleKeys.value = newKeys
}
return { visibleKeys, visibleColumns, setVisibleKeys }
}
+79 -211
View File
@@ -1,6 +1,11 @@
<template> <template>
<div class="audit-log"> <div class="audit-log">
<h2>Audit-Log</h2> <div class="audit-log__header">
<h2>Audit-Log</h2>
<ColumnPicker :columns="allColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
</div>
<!-- Filters --> <!-- Filters -->
<div class="audit-log__filters"> <div class="audit-log__filters">
@@ -77,49 +82,39 @@
<table v-else class="audit-log__table"> <table v-else class="audit-log__table">
<thead> <thead>
<tr> <tr>
<th class="audit-log__th">Zeitpunkt</th> <th v-for="col in visibleColumns" :key="col.key"
<th class="audit-log__th">Benutzer</th> class="audit-log__th">
<th class="audit-log__th">Aktion</th> {{ col.label }}
<th class="audit-log__th">Entität</th> </th>
<th class="audit-log__th">Feld</th>
<th class="audit-log__th">Alter Wert</th>
<th class="audit-log__th">Neuer Wert</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="entry in store.entries" <tr v-for="entry in store.entries"
:key="entry.id" :key="entry.id"
class="audit-log__row"> class="audit-log__row">
<td class="audit-log__td audit-log__td--time"> <td v-for="col in visibleColumns" :key="col.key"
{{ formatDateTime(entry.zeitpunkt) }} class="audit-log__td"
</td> :class="{
<td class="audit-log__td"> 'audit-log__td--time': col.key === 'zeitpunkt',
{{ entry.ncUserId }} 'audit-log__td--value': col.key === 'alterWert' || col.key === 'neuerWert',
</td> }">
<td class="audit-log__td"> <template v-if="col.key === 'aktion'">
<span :class="'audit-log__action audit-log__action--' + entry.aktion"> <span :class="'audit-log__action audit-log__action--' + entry.aktion">
{{ formatAktion(entry.aktion) }} {{ col.render(entry) }}
</span> </span>
</td> </template>
<td class="audit-log__td"> <template v-else-if="col.key === 'entitaet'">
<span class="audit-log__entity-link" <span class="audit-log__entity-link"
@click="navigateToEntity(entry.entitaet, entry.entitaetId)"> @click="navigateToEntity(entry.entitaet, entry.entitaetId)">
{{ formatEntitaet(entry.entitaet) }} {{ col.render(entry) }}
#{{ entry.entitaetId }} </span>
</span> </template>
</td> <template v-else-if="col.key === 'alterWert' || col.key === 'neuerWert'">
<td class="audit-log__td"> <span :class="{ 'audit-log__encrypted': isEncrypted(col.render(entry)) }">
{{ entry.feld || '—' }} {{ col.render(entry) }}
</td> </span>
<td class="audit-log__td audit-log__td--value"> </template>
<span :class="{ 'audit-log__encrypted': isEncrypted(entry.alterWert) }"> <template v-else>{{ col.render(entry) }}</template>
{{ entry.alterWert || '—' }}
</span>
</td>
<td class="audit-log__td audit-log__td--value">
<span :class="{ 'audit-log__encrypted': isEncrypted(entry.neuerWert) }">
{{ entry.neuerWert || '—' }}
</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -148,6 +143,8 @@ import { onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue' import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { useAuditLogStore } from '../stores/auditlog.js' import { useAuditLogStore } from '../stores/auditlog.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import ColumnPicker from '../components/ColumnPicker.vue'
import { formatDateTime } from '../utils/dateFormat.js' import { formatDateTime } from '../utils/dateFormat.js'
const store = useAuditLogStore() const store = useAuditLogStore()
@@ -155,6 +152,25 @@ const router = useRouter()
let filterTimeout = null let filterTimeout = null
const aktionMap = { create: 'Erstellt', update: 'Geändert', delete: 'Gelöscht', 'soft-delete': 'Soft-Delete' }
const entitaetMap = { member: 'Mitglied', family: 'Familie', stufe: 'Stufe', fee_rule: 'Beitragsregel', fee_record: 'Beitragsposition', permission: 'Berechtigung' }
const allColumns = [
{ key: 'zeitpunkt', label: 'Zeitpunkt', alwaysVisible: true, render: e => formatDateTime(e.zeitpunkt) },
{ key: 'ncUserId', label: 'Benutzer', render: e => e.ncUserId },
{ key: 'aktion', label: 'Aktion', render: e => aktionMap[e.aktion] || e.aktion },
{ key: 'entitaet', label: 'Entität', render: e => (entitaetMap[e.entitaet] || e.entitaet) + ' #' + e.entitaetId },
{ key: 'feld', label: 'Feld', render: e => e.feld || '\u2014' },
{ key: 'alterWert', label: 'Alter Wert', render: e => e.alterWert || '\u2014' },
{ key: 'neuerWert', label: 'Neuer Wert', render: e => e.neuerWert || '\u2014' },
]
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
'mv_audit_columns',
allColumns,
['zeitpunkt', 'ncUserId', 'aktion', 'entitaet', 'feld', 'alterWert', 'neuerWert'],
)
onMounted(() => { onMounted(() => {
store.fetchEntries() store.fetchEntries()
}) })
@@ -171,40 +187,12 @@ function reload() {
store.fetchEntries() store.fetchEntries()
} }
// formatDateTime imported from utils/dateFormat.js
function formatAktion(aktion) {
const map = {
create: 'Erstellt',
update: 'Geändert',
delete: 'Gelöscht',
'soft-delete': 'Soft-Delete',
}
return map[aktion] || aktion
}
function formatEntitaet(entitaet) {
const map = {
member: 'Mitglied',
family: 'Familie',
stufe: 'Stufe',
fee_rule: 'Beitragsregel',
fee_record: 'Beitragsposition',
permission: 'Berechtigung',
}
return map[entitaet] || entitaet
}
function isEncrypted(value) { function isEncrypted(value) {
return value === '[verschluesselt]' return value === '[verschluesselt]'
} }
/**
* Navigate to the referenced entity.
*/
function navigateToEntity(entitaet, entitaetId) { function navigateToEntity(entitaet, entitaetId) {
if (!entitaetId) return if (!entitaetId) return
switch (entitaet) { switch (entitaet) {
case 'member': case 'member':
router.push({ name: 'member-detail', params: { id: entitaetId } }) router.push({ name: 'member-detail', params: { id: entitaetId } })
@@ -213,155 +201,35 @@ function navigateToEntity(entitaet, entitaetId) {
router.push({ name: 'family-detail', params: { id: entitaetId } }) router.push({ name: 'family-detail', params: { id: entitaetId } })
break break
default: default:
// No navigation for other entity types yet
break break
} }
} }
</script> </script>
<style scoped> <style scoped>
.audit-log { .audit-log { padding: 20px; }
padding: 20px; .audit-log__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
} .audit-log__header h2 { margin: 0; }
.audit-log__filters { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 20px; padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); }
.audit-log h2 { .audit-log__filter { display: flex; flex-direction: column; gap: 4px; }
margin: 0 0 20px 0; .audit-log__filter label { font-size: 0.8em; color: var(--color-text-lighter); font-weight: 500; }
} .audit-log__select { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); color: var(--color-text); }
.audit-log__date-input { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); color: var(--color-text); }
.audit-log__filters { .audit-log__loading { display: flex; justify-content: center; margin-top: 60px; }
display: flex; .audit-log__table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
gap: 12px; .audit-log__th { text-align: left; padding: 10px 12px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
align-items: flex-end; .audit-log__row { transition: background-color 0.15s ease; }
flex-wrap: wrap; .audit-log__row:hover { background-color: var(--color-background-hover); }
margin-bottom: 20px; .audit-log__td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
padding: 12px 16px; .audit-log__td--time { white-space: nowrap; font-size: 0.9em; color: var(--color-text-lighter); }
background: var(--color-background-dark); .audit-log__td--value { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
border-radius: var(--border-radius-large); .audit-log__action { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
} .audit-log__action--create { background-color: var(--color-success); color: white; }
.audit-log__action--update { background-color: var(--color-primary); color: white; }
.audit-log__filter { .audit-log__action--delete, .audit-log__action--soft-delete { background-color: var(--color-error); color: white; }
display: flex; .audit-log__entity-link { cursor: pointer; color: var(--color-primary); }
flex-direction: column; .audit-log__entity-link:hover { text-decoration: underline; }
gap: 4px; .audit-log__encrypted { font-style: italic; color: var(--color-text-lighter); }
} .audit-log__pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 20px; padding: 12px 0; }
.audit-log__page-info { color: var(--color-text-lighter); }
.audit-log__filter label {
font-size: 0.8em;
color: var(--color-text-lighter);
font-weight: 500;
}
.audit-log__select {
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background: var(--color-main-background);
color: var(--color-text);
}
.audit-log__date-input {
padding: 6px 8px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
background: var(--color-main-background);
color: var(--color-text);
}
.audit-log__loading {
display: flex;
justify-content: center;
margin-top: 60px;
}
.audit-log__table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
.audit-log__th {
text-align: left;
padding: 10px 12px;
border-bottom: 2px solid var(--color-border-dark);
font-weight: bold;
white-space: nowrap;
}
.audit-log__row {
transition: background-color 0.15s ease;
}
.audit-log__row:hover {
background-color: var(--color-background-hover);
}
.audit-log__td {
padding: 8px 12px;
border-bottom: 1px solid var(--color-border);
vertical-align: top;
}
.audit-log__td--time {
white-space: nowrap;
font-size: 0.9em;
color: var(--color-text-lighter);
}
.audit-log__td--value {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.audit-log__action {
display: inline-block;
padding: 2px 8px;
border-radius: var(--border-radius-pill);
font-size: 0.85em;
font-weight: 500;
}
.audit-log__action--create {
background-color: var(--color-success);
color: white;
}
.audit-log__action--update {
background-color: var(--color-primary);
color: white;
}
.audit-log__action--delete,
.audit-log__action--soft-delete {
background-color: var(--color-error);
color: white;
}
.audit-log__entity-link {
cursor: pointer;
color: var(--color-primary);
}
.audit-log__entity-link:hover {
text-decoration: underline;
}
.audit-log__encrypted {
font-style: italic;
color: var(--color-text-lighter);
}
.audit-log__pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 20px;
padding: 12px 0;
}
.audit-log__page-info {
color: var(--color-text-lighter);
}
</style> </style>
+311
View File
@@ -0,0 +1,311 @@
<template>
<div class="backup-page">
<h2>Backup & Wiederherstellung</h2>
<!-- Messages -->
<div v-if="store.error" class="backup-page__message backup-page__message--error">
{{ store.error }}
<NcButton @click="store.clearError()">Schliessen</NcButton>
</div>
<div v-if="store.success" class="backup-page__message backup-page__message--success">
{{ store.success }}
<NcButton @click="store.clearSuccess()">Schliessen</NcButton>
</div>
<!-- App Update -->
<div class="backup-page__section">
<h3>App-Update</h3>
<div class="backup-page__update">
<div class="backup-page__update-status">
<span>Installierte Version: <strong>v{{ store.updateInfo?.currentVersion || '...' }}</strong></span>
<template v-if="store.updateInfo">
<template v-if="store.updateInfo.available">
<span class="backup-page__badge backup-page__badge--update">
v{{ store.updateInfo.latestVersion }} verfuegbar
</span>
</template>
<template v-else-if="store.updateInfo.latestVersion">
<span class="backup-page__badge">Aktuell</span>
</template>
<template v-else-if="store.updateInfo.error">
<span class="backup-page__badge backup-page__badge--none">
Pruefung fehlgeschlagen
</span>
</template>
</template>
</div>
<div v-if="store.updateInfo?.releaseBody" class="backup-page__update-notes">
{{ store.updateInfo.releaseBody }}
</div>
<div class="backup-page__update-actions">
<NcButton @click="store.checkForUpdate()">
Nach Updates suchen
</NcButton>
<NcButton v-if="store.updateInfo?.available"
type="primary"
:disabled="store.updating"
@click="confirmUpdate">
<NcLoadingIcon v-if="store.updating" :size="20" />
<template v-else>Update installieren</template>
</NcButton>
</div>
</div>
</div>
<!-- Schedule settings -->
<div class="backup-page__section">
<h3>Automatische Backups</h3>
<div class="backup-page__settings">
<div class="backup-page__setting">
<label for="backup-schedule">Zeitplan</label>
<select id="backup-schedule"
:value="store.settings.schedule"
class="backup-page__select"
@change="updateSchedule($event.target.value)">
<option value="disabled">Deaktiviert</option>
<option value="daily">Taeglich</option>
<option value="weekly">Woechentlich</option>
</select>
</div>
<div class="backup-page__setting">
<label for="backup-retention">Aufbewahrung (Anzahl)</label>
<input id="backup-retention"
type="number"
min="1"
max="100"
:value="store.settings.retentionCount"
class="backup-page__input"
@change="updateRetention(parseInt($event.target.value))">
</div>
<div class="backup-page__setting">
<label>Automatisches Passwort</label>
<span v-if="store.settings.hasPassword" class="backup-page__badge">Gesetzt</span>
<span v-else class="backup-page__badge backup-page__badge--none">Nicht gesetzt</span>
</div>
</div>
</div>
<!-- Manual backup -->
<div class="backup-page__section">
<h3>Manuelles Backup</h3>
<div class="backup-page__create">
<div class="backup-page__setting">
<label for="backup-password">Passwort (optional)</label>
<input id="backup-password"
v-model="createPassword"
type="password"
placeholder="Ohne Passwort"
class="backup-page__input">
</div>
<NcButton type="primary"
:disabled="store.creating"
@click="doCreate">
<NcLoadingIcon v-if="store.creating" :size="20" />
<template v-else>Backup erstellen</template>
</NcButton>
</div>
</div>
<!-- Backup list -->
<div class="backup-page__section">
<h3>Vorhandene Backups</h3>
<NcLoadingIcon v-if="store.loading && store.backups.length === 0"
:size="48"
class="backup-page__loading" />
<div v-else-if="store.backups.length === 0" class="backup-page__empty">
Keine Backups vorhanden.
</div>
<table v-else class="backup-page__table">
<thead>
<tr>
<th>Dateiname</th>
<th>Erstellt</th>
<th>Groesse</th>
<th>Tabellen</th>
<th>Zeilen</th>
<th>Ausloser</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<tr v-for="backup in store.backups" :key="backup.filename">
<td>{{ backup.filename }}</td>
<td>{{ formatDateTime(backup.createdAt) }}</td>
<td>{{ formatBytes(backup.size) }}</td>
<td>{{ Object.keys(backup.tables || {}).length }}</td>
<td>{{ totalRows(backup) }}</td>
<td>{{ formatTrigger(backup.triggeredBy) }}</td>
<td class="backup-page__actions">
<NcButton @click="store.downloadBackup(backup.filename)">
Download
</NcButton>
<NcButton type="warning"
:disabled="store.restoring"
@click="confirmRestore(backup)">
Wiederherstellen
</NcButton>
<NcButton type="error"
@click="confirmDelete(backup)">
Loeschen
</NcButton>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Restore confirmation dialog -->
<div v-if="restoreTarget" class="backup-page__dialog-backdrop">
<div class="backup-page__dialog">
<h3>Backup wiederherstellen</h3>
<p>
<strong>WARNUNG:</strong> Alle bestehenden Daten werden geloescht und durch
das Backup <strong>{{ restoreTarget.filename }}</strong> ersetzt.
</p>
<p>Erstellt: {{ formatDateTime(restoreTarget.createdAt) }}</p>
<div class="backup-page__setting">
<label for="restore-password">Passwort (falls verschluesselt)</label>
<input id="restore-password"
v-model="restorePassword"
type="password"
placeholder="Ohne Passwort"
class="backup-page__input">
</div>
<div class="backup-page__dialog-actions">
<NcButton @click="restoreTarget = null">Abbrechen</NcButton>
<NcButton type="error"
:disabled="store.restoring"
@click="doRestore">
<NcLoadingIcon v-if="store.restoring" :size="20" />
<template v-else>Jetzt wiederherstellen</template>
</NcButton>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { NcButton, NcLoadingIcon } from '@nextcloud/vue'
import { useBackupStore } from '../stores/backup.js'
const store = useBackupStore()
const createPassword = ref('')
const restoreTarget = ref(null)
const restorePassword = ref('')
onMounted(() => {
store.fetchBackups()
store.fetchSettings()
store.checkForUpdate()
})
async function doCreate() {
await store.createBackup(createPassword.value || null)
createPassword.value = ''
}
function confirmRestore(backup) {
restoreTarget.value = backup
restorePassword.value = ''
}
async function doRestore() {
const filename = restoreTarget.value.filename
restoreTarget.value = null
await store.restoreBackup(filename, restorePassword.value || null)
}
async function confirmUpdate() {
if (confirm('Update installieren? Die App-Dateien werden ersetzt. Danach muss ggf. "occ upgrade" ausgefuehrt werden.')) {
await store.installUpdate(false)
}
}
function confirmDelete(backup) {
if (confirm(`Backup "${backup.filename}" wirklich loeschen?`)) {
store.deleteBackup(backup.filename)
}
}
function updateSchedule(value) {
store.updateSettings({ schedule: value })
}
function updateRetention(value) {
if (value >= 1) {
store.updateSettings({ retentionCount: value })
}
}
function formatDateTime(isoString) {
if (!isoString) return '-'
const d = new Date(isoString)
return d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function formatBytes(bytes) {
if (!bytes) return '-'
if (bytes >= 1048576) return (bytes / 1048576).toFixed(2) + ' MB'
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'
return bytes + ' B'
}
function totalRows(backup) {
if (!backup.tables) return 0
return Object.values(backup.tables).reduce((sum, n) => sum + n, 0)
}
function formatTrigger(trigger) {
const map = { manual: 'Manuell', scheduled: 'Geplant', cli: 'CLI' }
return map[trigger] || trigger || '-'
}
</script>
<style scoped>
.backup-page { padding: 20px; max-width: 1200px; }
.backup-page h2 { margin: 0 0 20px 0; }
.backup-page__section { margin-bottom: 32px; }
.backup-page__section h3 { margin: 0 0 12px 0; }
.backup-page__message { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: var(--border-radius); margin-bottom: 16px; color: white; }
.backup-page__message--error { background: var(--color-error); }
.backup-page__message--success { background: var(--color-success); }
.backup-page__settings { display: flex; gap: 24px; flex-wrap: wrap; align-items: flex-end; padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); }
.backup-page__setting { display: flex; flex-direction: column; gap: 4px; }
.backup-page__setting label { font-size: 0.8em; color: var(--color-text-lighter); font-weight: 500; }
.backup-page__select { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); }
.backup-page__input { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); }
.backup-page__badge { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; background: var(--color-success); color: white; }
.backup-page__badge--none { background: var(--color-text-lighter); }
.backup-page__badge--update { background: var(--color-primary); }
.backup-page__update { padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); }
.backup-page__update-status { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.backup-page__update-notes { font-size: 0.9em; color: var(--color-text-lighter); margin-bottom: 12px; white-space: pre-line; }
.backup-page__update-actions { display: flex; gap: 8px; }
.backup-page__create { display: flex; gap: 16px; align-items: flex-end; }
.backup-page__loading { display: flex; justify-content: center; margin-top: 40px; }
.backup-page__empty { color: var(--color-text-lighter); padding: 24px; text-align: center; }
.backup-page__table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
.backup-page__table th { text-align: left; padding: 10px 12px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
.backup-page__table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
.backup-page__table tr:hover { background: var(--color-background-hover); }
.backup-page__actions { white-space: nowrap; }
.backup-page__actions :deep(.button-vue) { display: inline-flex; margin: 2px; }
.backup-page__dialog-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.backup-page__dialog { background: var(--color-main-background); padding: 24px; border-radius: var(--border-radius-large); max-width: 520px; width: 90%; box-shadow: 0 4px 24px rgba(0,0,0,0.2); }
.backup-page__dialog h3 { margin: 0 0 12px 0; }
.backup-page__dialog p { margin: 0 0 12px 0; }
.backup-page__dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
</style>
+52 -119
View File
@@ -9,6 +9,9 @@
trailing-button-icon="close" trailing-button-icon="close"
@trailing-button-click="clearSearch" @trailing-button-click="clearSearch"
@update:model-value="onSearch" /> @update:model-value="onSearch" />
<ColumnPicker :columns="allColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
<NcButton type="primary" <NcButton type="primary"
@click="$router.push({ name: 'family-detail', params: { id: 'new' } })"> @click="$router.push({ name: 'family-detail', params: { id: 'new' } })">
<template #icon> <template #icon>
@@ -59,16 +62,15 @@
<table v-else class="family-list__table"> <table v-else class="family-list__table">
<thead> <thead>
<tr> <tr>
<th class="family-list__th family-list__th--sortable" <th v-for="col in visibleColumns" :key="col.key"
@click="toggleSort('name')"> class="family-list__th"
Familienname :class="{ 'family-list__th--sortable': col.sortable }"
<SortIcon :field="'name'" :current-sort="sortField" :sort-asc="sortAsc" /> @click="col.sortable && toggleSort(col.sortField || col.key)">
</th> {{ col.label }}
<th class="family-list__th"> <SortIcon v-if="col.sortable"
Mitglieder :field="col.sortField || col.key"
</th> :current-sort="sortField"
<th class="family-list__th"> :sort-asc="sortAsc" />
Ansprechpartner
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -77,17 +79,9 @@
:key="family.id" :key="family.id"
class="family-list__row" class="family-list__row"
@click="$router.push({ name: 'family-detail', params: { id: family.id } })"> @click="$router.push({ name: 'family-detail', params: { id: family.id } })">
<td class="family-list__td"> <td v-for="col in visibleColumns" :key="col.key"
{{ family.name }} class="family-list__td">
</td> {{ col.render(family) }}
<td class="family-list__td">
{{ family.members ? family.members.length : 0 }}
<span v-if="family.activeChildrenCount" class="family-list__children-count">
({{ family.activeChildrenCount }} aktive Kinder)
</span>
</td>
<td class="family-list__td">
{{ getAnsprechpartner(family) }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -115,6 +109,8 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue' import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { useFamiliesStore } from '../stores/families.js' import { useFamiliesStore } from '../stores/families.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import ColumnPicker from '../components/ColumnPicker.vue'
import Plus from 'vue-material-design-icons/Plus.vue' import Plus from 'vue-material-design-icons/Plus.vue'
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue' import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue' import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
@@ -127,6 +123,27 @@ const sortField = ref('name')
const sortAsc = ref(true) const sortAsc = ref(true)
let searchTimeout = null let searchTimeout = null
function getAnsprechpartner(family) {
if (!family.members || family.members.length === 0) return '\u2014'
const contact = family.members.find(m => m.rolle === 'erziehungsberechtigter')
if (contact) return `${contact.vorname} ${contact.nachname}`
const first = family.members[0]
return `${first.vorname} ${first.nachname}`
}
const allColumns = [
{ key: 'name', label: 'Familienname', sortable: true, sortField: 'name', alwaysVisible: true, render: f => f.name },
{ key: 'members', label: 'Mitglieder', sortable: false, render: f => f.members ? f.members.length : 0 },
{ key: 'activeChildren', label: 'Aktive Kinder', sortable: false, render: f => f.activeChildrenCount || 0 },
{ key: 'ansprechpartner', label: 'Ansprechpartner', sortable: false, render: f => getAnsprechpartner(f) },
]
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
'mv_family_columns',
allColumns,
['name', 'members', 'ansprechpartner'],
)
onMounted(() => { onMounted(() => {
store.fetchFamilies() store.fetchFamilies()
}) })
@@ -181,105 +198,21 @@ function reload() {
store.clearError() store.clearError()
store.fetchFamilies() store.fetchFamilies()
} }
/**
* Get the primary contact (Erziehungsberechtigter) from a family's members.
*/
function getAnsprechpartner(family) {
if (!family.members || family.members.length === 0) {
return '—'
}
const contact = family.members.find(m => m.rolle === 'erziehungsberechtigter')
if (contact) {
return `${contact.vorname} ${contact.nachname}`
}
// Fallback to first member
const first = family.members[0]
return `${first.vorname} ${first.nachname}`
}
</script> </script>
<style scoped> <style scoped>
.family-list { .family-list { padding: 20px; }
padding: 20px; .family-list__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
} .family-list__header h2 { margin: 0; }
.family-list__actions { display: flex; gap: 12px; align-items: center; }
.family-list__header { .family-list__loading { display: flex; justify-content: center; margin-top: 60px; }
display: flex; .family-list__table { width: 100%; border-collapse: collapse; }
justify-content: space-between; .family-list__th { text-align: left; padding: 12px 16px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
align-items: center; .family-list__th--sortable { cursor: pointer; user-select: none; }
margin-bottom: 20px; .family-list__th--sortable:hover { background-color: var(--color-background-hover); }
flex-wrap: wrap; .family-list__row { cursor: pointer; transition: background-color 0.15s ease; }
gap: 12px; .family-list__row:hover { background-color: var(--color-background-hover); }
} .family-list__td { padding: 10px 16px; border-bottom: 1px solid var(--color-border); }
.family-list__pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 20px; padding: 12px 0; }
.family-list__header h2 { .family-list__page-info { color: var(--color-text-lighter); }
margin: 0;
}
.family-list__actions {
display: flex;
gap: 12px;
align-items: center;
}
.family-list__loading {
display: flex;
justify-content: center;
margin-top: 60px;
}
.family-list__table {
width: 100%;
border-collapse: collapse;
}
.family-list__th {
text-align: left;
padding: 12px 16px;
border-bottom: 2px solid var(--color-border-dark);
font-weight: bold;
white-space: nowrap;
}
.family-list__th--sortable {
cursor: pointer;
user-select: none;
}
.family-list__th--sortable:hover {
background-color: var(--color-background-hover);
}
.family-list__row {
cursor: pointer;
transition: background-color 0.15s ease;
}
.family-list__row:hover {
background-color: var(--color-background-hover);
}
.family-list__td {
padding: 10px 16px;
border-bottom: 1px solid var(--color-border);
}
.family-list__children-count {
color: var(--color-text-lighter);
font-size: 0.85em;
}
.family-list__pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 20px;
padding: 12px 0;
}
.family-list__page-info {
color: var(--color-text-lighter);
}
</style> </style>
+135 -363
View File
@@ -18,6 +18,10 @@
</select> </select>
</div> </div>
<ColumnPicker :columns="allColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
<!-- Batch calculate --> <!-- Batch calculate -->
<NcButton type="primary" <NcButton type="primary"
:disabled="feesStore.loading" :disabled="feesStore.loading"
@@ -105,97 +109,84 @@
<table v-else class="fee-overview__table"> <table v-else class="fee-overview__table">
<thead> <thead>
<tr> <tr>
<th class="fee-overview__th">Mitglied</th> <th v-for="col in visibleColumns" :key="col.key"
<th class="fee-overview__th">Betrag</th> class="fee-overview__th">
<th class="fee-overview__th">Bezahlt</th> {{ col.label }}
<th class="fee-overview__th">Zahlungsdatum</th> </th>
<th class="fee-overview__th">Manuell</th>
<th class="fee-overview__th">Notizen</th>
<th class="fee-overview__th">Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="record in feesStore.records" <tr v-for="record in feesStore.records"
:key="record.id" :key="record.id"
:class="{ 'fee-overview__row--manual': record.manuellAngepasst }"> :class="{ 'fee-overview__row--manual': record.manuellAngepasst }">
<td class="fee-overview__td"> <td v-for="col in visibleColumns" :key="col.key"
<router-link :to="{ name: 'member-detail', params: { id: record.memberId } }" class="fee-overview__td"
class="fee-overview__member-link"> :class="{
{{ getMemberName(record.memberId) }} 'fee-overview__td--amount': col.key === 'amount',
</router-link> 'fee-overview__td--notes': col.key === 'notes',
</td> 'fee-overview__td--actions': col.key === 'aktionen',
<td class="fee-overview__td fee-overview__td--amount"> }">
<template v-if="editingRecord === record.id"> <!-- Mitglied column with link -->
<input v-model="editAmount" <template v-if="col.key === 'mitglied'">
type="number" <router-link :to="{ name: 'member-detail', params: { id: record.memberId } }"
min="0" class="fee-overview__member-link">
step="0.01" {{ col.render(record) }}
class="fee-overview__inline-input" </router-link>
@keyup.enter="saveOverride(record.id)"
@keyup.escape="cancelEdit">
</template> </template>
<template v-else> <!-- Amount with inline editing -->
{{ formatCurrency(parseFloat(record.amount)) }} <template v-else-if="col.key === 'amount'">
<template v-if="editingRecord === record.id">
<input v-model="editAmount"
type="number" min="0" step="0.01"
class="fee-overview__inline-input"
@keyup.enter="saveOverride(record.id)"
@keyup.escape="cancelEdit">
</template>
<template v-else>{{ col.render(record) }}</template>
</template> </template>
</td> <!-- Bezahlt badge -->
<td class="fee-overview__td"> <template v-else-if="col.key === 'paid'">
<span :class="record.paid ? 'fee-overview__badge--paid' : 'fee-overview__badge--unpaid'" <span :class="record.paid ? 'fee-overview__badge--paid' : 'fee-overview__badge--unpaid'"
class="fee-overview__badge"> class="fee-overview__badge">
{{ record.paid ? 'Ja' : 'Nein' }} {{ col.render(record) }}
</span> </span>
</td>
<td class="fee-overview__td">
{{ formatDate(record.paymentDate) }}
</td>
<td class="fee-overview__td">
<span v-if="record.manuellAngepasst" class="fee-overview__badge fee-overview__badge--manual">
Manuell
</span>
</td>
<td class="fee-overview__td fee-overview__td--notes">
<template v-if="editingNotes === record.id">
<input v-model="editNotesText"
type="text"
class="fee-overview__inline-input"
placeholder="Notiz..."
@keyup.enter="saveNotes(record.id)"
@keyup.escape="cancelNotesEdit">
</template> </template>
<template v-else> <!-- Manuell badge -->
{{ record.notes || '--' }} <template v-else-if="col.key === 'manuell'">
<span v-if="record.manuellAngepasst" class="fee-overview__badge fee-overview__badge--manual">
Manuell
</span>
</template> </template>
</td> <!-- Notes with inline editing -->
<td class="fee-overview__td fee-overview__td--actions"> <template v-else-if="col.key === 'notes'">
<template v-if="editingRecord === record.id">
<NcButton type="primary" @click="saveOverride(record.id)">
Speichern
</NcButton>
<NcButton @click="cancelEdit">
Abbrechen
</NcButton>
</template>
<template v-else>
<NcButton v-if="!record.paid"
type="success"
@click="markPaid(record.id)">
Bezahlt
</NcButton>
<NcButton @click="startEdit(record)">
Betrag ändern
</NcButton>
<NcButton v-if="!editingNotes || editingNotes !== record.id"
@click="startNotesEdit(record)">
Notiz
</NcButton>
<template v-if="editingNotes === record.id"> <template v-if="editingNotes === record.id">
<NcButton type="primary" @click="saveNotes(record.id)"> <input v-model="editNotesText"
Speichern type="text"
</NcButton> class="fee-overview__inline-input"
<NcButton @click="cancelNotesEdit"> placeholder="Notiz..."
Abbrechen @keyup.enter="saveNotes(record.id)"
</NcButton> @keyup.escape="cancelNotesEdit">
</template>
<template v-else>{{ col.render(record) }}</template>
</template>
<!-- Actions -->
<template v-else-if="col.key === 'aktionen'">
<template v-if="editingRecord === record.id">
<NcButton type="primary" @click="saveOverride(record.id)">Speichern</NcButton>
<NcButton @click="cancelEdit">Abbrechen</NcButton>
</template>
<template v-else>
<NcButton v-if="!record.paid" type="success" @click="markPaid(record.id)">Bezahlt</NcButton>
<NcButton @click="startEdit(record)">Betrag ändern</NcButton>
<NcButton v-if="!editingNotes || editingNotes !== record.id" @click="startNotesEdit(record)">Notiz</NcButton>
<template v-if="editingNotes === record.id">
<NcButton type="primary" @click="saveNotes(record.id)">Speichern</NcButton>
<NcButton @click="cancelNotesEdit">Abbrechen</NcButton>
</template>
</template> </template>
</template> </template>
<!-- Default -->
<template v-else>{{ col.render(record) }}</template>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -208,14 +199,13 @@ import { ref, computed, onMounted } from 'vue'
import { NcButton, NcLoadingIcon, NcEmptyContent } from '@nextcloud/vue' import { NcButton, NcLoadingIcon, NcEmptyContent } from '@nextcloud/vue'
import { useFeesStore } from '../stores/fees.js' import { useFeesStore } from '../stores/fees.js'
import { useMembersStore } from '../stores/members.js' import { useMembersStore } from '../stores/members.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import ColumnPicker from '../components/ColumnPicker.vue'
import { formatDate } from '../utils/dateFormat.js' import { formatDate } from '../utils/dateFormat.js'
const feesStore = useFeesStore() const feesStore = useFeesStore()
const membersStore = useMembersStore() const membersStore = useMembersStore()
/**
* Build a lookup map from member ID to "Nachname, Vorname".
*/
const memberNameMap = computed(() => { const memberNameMap = computed(() => {
const map = {} const map = {}
for (const m of membersStore.members) { for (const m of membersStore.members) {
@@ -228,6 +218,27 @@ function getMemberName(memberId) {
return memberNameMap.value[memberId] || `Mitglied #${memberId}` return memberNameMap.value[memberId] || `Mitglied #${memberId}`
} }
function formatCurrency(value) {
if (value === null || value === undefined || isNaN(value)) return '--'
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value)
}
const allColumns = [
{ key: 'mitglied', label: 'Mitglied', alwaysVisible: true, render: r => getMemberName(r.memberId) },
{ key: 'amount', label: 'Betrag', render: r => formatCurrency(parseFloat(r.amount)) },
{ key: 'paid', label: 'Bezahlt', render: r => r.paid ? 'Ja' : 'Nein' },
{ key: 'paymentDate', label: 'Zahlungsdatum', render: r => formatDate(r.paymentDate) },
{ key: 'manuell', label: 'Manuell', render: r => r.manuellAngepasst ? 'Ja' : '' },
{ key: 'notes', label: 'Notizen', render: r => r.notes || '--' },
{ key: 'aktionen', label: 'Aktionen', render: () => '' },
]
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
'mv_fee_columns',
allColumns,
['mitglied', 'amount', 'paid', 'paymentDate', 'manuell', 'notes', 'aktionen'],
)
const showBatchConfirm = ref(false) const showBatchConfirm = ref(false)
const editingRecord = ref(null) const editingRecord = ref(null)
const editAmount = ref('') const editAmount = ref('')
@@ -235,7 +246,6 @@ const editingNotes = ref(null)
const editNotesText = ref('') const editNotesText = ref('')
onMounted(async () => { onMounted(async () => {
// Fetch all members for the name lookup (not just first page)
const savedLimit = membersStore.limit const savedLimit = membersStore.limit
membersStore.limit = 9999 membersStore.limit = 9999
await Promise.all([ await Promise.all([
@@ -246,40 +256,23 @@ onMounted(async () => {
membersStore.limit = savedLimit membersStore.limit = savedLimit
}) })
// ── Year change ─────────────────────────────────────────────────────
function onYearChange(year) { function onYearChange(year) {
feesStore.fetchRecords(parseInt(year)) feesStore.fetchRecords(parseInt(year))
} }
// ── Batch calculate ─────────────────────────────────────────────────
async function executeBatchCalculate() { async function executeBatchCalculate() {
try { try { await feesStore.batchCalculate(feesStore.selectedYear) } catch { /* store shows error */ }
await feesStore.batchCalculate(feesStore.selectedYear)
} catch {
// Error displayed by store
}
showBatchConfirm.value = false showBatchConfirm.value = false
} }
// ── Mark as paid ────────────────────────────────────────────────────
async function markPaid(recordId) { async function markPaid(recordId) {
const today = new Date().toISOString().split('T')[0] const today = new Date().toISOString().split('T')[0]
try { try { await feesStore.markAsPaid(recordId, today) } catch { /* store shows error */ }
await feesStore.markAsPaid(recordId, today)
} catch {
// Error displayed by store
}
} }
// ── Manual override ─────────────────────────────────────────────────
function startEdit(record) { function startEdit(record) {
editingRecord.value = record.id editingRecord.value = record.id
editAmount.value = record.amount || '' editAmount.value = record.amount || ''
// Cancel any notes editing
editingNotes.value = null editingNotes.value = null
} }
@@ -290,22 +283,14 @@ function cancelEdit() {
async function saveOverride(recordId) { async function saveOverride(recordId) {
if (!editAmount.value || parseFloat(editAmount.value) < 0) return if (!editAmount.value || parseFloat(editAmount.value) < 0) return
try { await feesStore.manualOverride(recordId, String(editAmount.value), null) } catch { /* store shows error */ }
try {
await feesStore.manualOverride(recordId, String(editAmount.value), null)
} catch {
// Error displayed by store
}
editingRecord.value = null editingRecord.value = null
editAmount.value = '' editAmount.value = ''
} }
// ── Notes editing ───────────────────────────────────────────────────
function startNotesEdit(record) { function startNotesEdit(record) {
editingNotes.value = record.id editingNotes.value = record.id
editNotesText.value = record.notes || '' editNotesText.value = record.notes || ''
// Cancel any amount editing
editingRecord.value = null editingRecord.value = null
} }
@@ -319,263 +304,50 @@ async function saveNotes(recordId) {
const record = feesStore.records.find(r => r.id === recordId) const record = feesStore.records.find(r => r.id === recordId)
const amount = record ? record.amount : '0' const amount = record ? record.amount : '0'
await feesStore.manualOverride(recordId, amount, editNotesText.value) await feesStore.manualOverride(recordId, amount, editNotesText.value)
} catch { } catch { /* store shows error */ }
// Error displayed by store
}
editingNotes.value = null editingNotes.value = null
editNotesText.value = '' editNotesText.value = ''
} }
// ── Formatting ──────────────────────────────────────────────────────
// formatDate imported from utils/dateFormat.js
function formatCurrency(value) {
if (value === null || value === undefined || isNaN(value)) return '--'
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(value)
}
</script> </script>
<style scoped> <style scoped>
.fee-overview { .fee-overview { padding: 20px; }
padding: 20px; .fee-overview__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
} .fee-overview__header h2 { margin: 0; }
.fee-overview__actions { display: flex; gap: 12px; align-items: center; }
.fee-overview__header { .fee-overview__year-selector { display: flex; align-items: center; gap: 6px; }
display: flex; .fee-overview__year-selector label { font-weight: 500; }
justify-content: space-between; .fee-overview__select { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); color: var(--color-text); }
align-items: center; .fee-overview__message { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: var(--border-radius); margin-bottom: 12px; color: white; }
margin-bottom: 20px; .fee-overview__message--error { background: var(--color-error); }
flex-wrap: wrap; .fee-overview__message--success { background: var(--color-success); }
gap: 12px; .fee-overview__dialog-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
} .fee-overview__dialog { background: var(--color-main-background); padding: 24px; border-radius: var(--border-radius-large); max-width: 480px; width: 90%; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); }
.fee-overview__dialog h3 { margin: 0 0 12px 0; }
.fee-overview__header h2 { .fee-overview__dialog ul { margin: 8px 0 16px 0; padding-left: 20px; }
margin: 0; .fee-overview__dialog li { margin-bottom: 4px; color: var(--color-text-lighter); }
} .fee-overview__dialog-actions { display: flex; gap: 8px; justify-content: flex-end; }
.fee-overview__summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius); }
.fee-overview__actions { .fee-overview__summary-item { display: flex; flex-direction: column; gap: 2px; }
display: flex; .fee-overview__summary-label { font-size: 0.8em; color: var(--color-text-lighter); font-weight: 500; }
gap: 12px; .fee-overview__summary-value { font-size: 1.1em; font-weight: 600; }
align-items: center; .fee-overview__summary-item--paid .fee-overview__summary-value { color: var(--color-success); }
} .fee-overview__summary-item--outstanding .fee-overview__summary-value { color: var(--color-warning); }
.fee-overview__summary-item--manual .fee-overview__summary-value { color: var(--color-primary); }
.fee-overview__year-selector { .fee-overview__loading { display: flex; justify-content: center; margin-top: 60px; }
display: flex; .fee-overview__table { width: 100%; border-collapse: collapse; }
align-items: center; .fee-overview__th { text-align: left; padding: 12px 12px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; font-size: 0.9em; }
gap: 6px; .fee-overview__td { padding: 10px 12px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
} .fee-overview__td--amount { font-variant-numeric: tabular-nums; }
.fee-overview__td--notes { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fee-overview__year-selector label { .fee-overview__td--actions { white-space: normal; }
font-weight: 500; .fee-overview__td--actions :deep(.button-vue) { display: inline-flex; margin: 2px; }
} .fee-overview__row--manual { background: var(--color-primary-element-light); }
.fee-overview__member-link { color: var(--color-primary); text-decoration: none; font-weight: 500; }
.fee-overview__select { .fee-overview__member-link:hover { text-decoration: underline; }
padding: 6px 8px; .fee-overview__badge { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
border: 1px solid var(--color-border); .fee-overview__badge--paid { background-color: var(--color-success); color: white; }
border-radius: var(--border-radius); .fee-overview__badge--unpaid { background-color: var(--color-warning); color: white; }
background: var(--color-main-background); .fee-overview__badge--manual { background-color: var(--color-primary); color: white; }
color: var(--color-text); .fee-overview__inline-input { padding: 4px 6px; border: 1px solid var(--color-primary); border-radius: var(--border-radius); background: var(--color-main-background); color: var(--color-text); width: 100px; }
}
/* Messages */
.fee-overview__message {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: var(--border-radius);
margin-bottom: 12px;
color: white;
}
.fee-overview__message--error {
background: var(--color-error);
}
.fee-overview__message--success {
background: var(--color-success);
}
/* Batch confirm dialog */
.fee-overview__dialog-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.fee-overview__dialog {
background: var(--color-main-background);
padding: 24px;
border-radius: var(--border-radius-large);
max-width: 480px;
width: 90%;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
.fee-overview__dialog h3 {
margin: 0 0 12px 0;
}
.fee-overview__dialog ul {
margin: 8px 0 16px 0;
padding-left: 20px;
}
.fee-overview__dialog li {
margin-bottom: 4px;
color: var(--color-text-lighter);
}
.fee-overview__dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
/* Summary bar */
.fee-overview__summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
padding: 12px 16px;
background: var(--color-background-dark);
border-radius: var(--border-radius);
}
.fee-overview__summary-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.fee-overview__summary-label {
font-size: 0.8em;
color: var(--color-text-lighter);
font-weight: 500;
}
.fee-overview__summary-value {
font-size: 1.1em;
font-weight: 600;
}
.fee-overview__summary-item--paid .fee-overview__summary-value {
color: var(--color-success);
}
.fee-overview__summary-item--outstanding .fee-overview__summary-value {
color: var(--color-warning);
}
.fee-overview__summary-item--manual .fee-overview__summary-value {
color: var(--color-primary);
}
/* Loading */
.fee-overview__loading {
display: flex;
justify-content: center;
margin-top: 60px;
}
/* Table */
.fee-overview__table {
width: 100%;
border-collapse: collapse;
}
.fee-overview__th {
text-align: left;
padding: 12px 12px;
border-bottom: 2px solid var(--color-border-dark);
font-weight: bold;
white-space: nowrap;
font-size: 0.9em;
}
.fee-overview__td {
padding: 10px 12px;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.fee-overview__td--amount {
font-variant-numeric: tabular-nums;
}
.fee-overview__td--notes {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fee-overview__td--actions {
white-space: normal;
}
.fee-overview__td--actions :deep(.button-vue) {
display: inline-flex;
margin: 2px;
}
.fee-overview__row--manual {
background: var(--color-primary-element-light);
}
.fee-overview__member-link {
color: var(--color-primary);
text-decoration: none;
font-weight: 500;
}
.fee-overview__member-link:hover {
text-decoration: underline;
}
/* Badges */
.fee-overview__badge {
display: inline-block;
padding: 2px 8px;
border-radius: var(--border-radius-pill);
font-size: 0.85em;
font-weight: 500;
}
.fee-overview__badge--paid {
background-color: var(--color-success);
color: white;
}
.fee-overview__badge--unpaid {
background-color: var(--color-warning);
color: white;
}
.fee-overview__badge--manual {
background-color: var(--color-primary);
color: white;
}
/* Inline edit */
.fee-overview__inline-input {
padding: 4px 6px;
border: 1px solid var(--color-primary);
border-radius: var(--border-radius);
background: var(--color-main-background);
color: var(--color-text);
width: 100px;
}
</style> </style>
+88 -6
View File
@@ -367,16 +367,54 @@
</div> </div>
</div> </div>
<div v-if="res.errors && res.errors.length > 0" class="import-wizard__section"> <div v-if="res.errors && res.errors.length > 0" class="import-wizard__section">
<div v-for="(err, eIdx) in res.errors.slice(0, 5)" :key="'be-' + eIdx" <h4>Fehler -- Daten korrigieren</h4>
class="import-wizard__error-row"> <p class="import-wizard__hint">
<strong>Zeile {{ err._rowIndex }}:</strong> Klicke auf eine Zeile, um die Daten zu bearbeiten und fehlende Felder zu ergaenzen.
<ul> </p>
<li v-for="(msg, mIdx) in err._errors" :key="mIdx">{{ msg }}</li>
</ul> <div v-for="(err, eIdx) in res.errors.slice(0, 10)" :key="'be-' + eIdx"
class="import-wizard__error-row import-wizard__error-row--fixable">
<div class="import-wizard__error-header"
@click="toggleBundleErrorExpand(res.type, err._rowIndex)">
<span class="import-wizard__error-toggle">
{{ isBundleErrorExpanded(res.type, err._rowIndex) ? '\u25BC' : '\u25B6' }}
</span>
<strong>Zeile {{ err._rowIndex }}:</strong>
<span class="import-wizard__error-msgs">
{{ err._errors.join('; ') }}
</span>
<span v-if="hasBundleCorrections(res.type, err._rowIndex)" class="import-wizard__error-badge">
bearbeitet
</span>
</div>
<div v-if="isBundleErrorExpanded(res.type, err._rowIndex)" class="import-wizard__error-fields">
<div v-for="f in getBundleEditableFields(res)" :key="f.key"
class="import-wizard__error-field">
<label :class="{ 'import-wizard__error-field--required': f.required }">
{{ f.label }}{{ f.required ? ' *' : '' }}
</label>
<input
:value="getBundleCorrectedValue(res.type, err._rowIndex, f.key, err[f.key])"
:placeholder="f.type === 'date' ? 'YYYY-MM-DD' : ''"
class="import-wizard__error-input"
:class="{ 'import-wizard__error-input--missing': f.required && !getBundleCorrectedValue(res.type, err._rowIndex, f.key, err[f.key]) }"
@input="onBundleCorrectionInput(res.type, err._rowIndex, f.key, $event.target.value)">
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div v-if="hasBundleCorrectedRows" class="import-wizard__revalidate">
<NcButton
:disabled="importStore.loading"
@click="importStore.runBundlePreview()">
<NcLoadingIcon v-if="importStore.loading" :size="20" />
<template v-else>Erneut pruefen</template>
</NcButton>
</div>
<div class="import-wizard__nav"> <div class="import-wizard__nav">
<NcButton @click="importStore.step = 2">Zurueck</NcButton> <NcButton @click="importStore.step = 2">Zurueck</NcButton>
<NcButton type="primary" <NcButton type="primary"
@@ -715,6 +753,50 @@ function hasCorrections(rowIndex) {
const hasCorrectedRows = computed(() => { const hasCorrectedRows = computed(() => {
return Object.keys(importStore.corrections).length > 0 return Object.keys(importStore.corrections).length > 0
}) })
// ── Bundle inline error correction ──
const expandedBundleErrors = reactive({})
function toggleBundleErrorExpand(entityType, rowIndex) {
const key = entityType + ':' + rowIndex
expandedBundleErrors[key] = !expandedBundleErrors[key]
}
function isBundleErrorExpanded(entityType, rowIndex) {
return !!expandedBundleErrors[entityType + ':' + rowIndex]
}
function getBundleEditableFields(res) {
return (res.targetFields || []).filter(f => f.key !== 'id')
}
function getBundleCorrectedValue(entityType, rowIndex, fieldKey, originalValue) {
const entityCorr = importStore.bundleCorrections[entityType]
if (entityCorr && entityCorr[rowIndex] && fieldKey in entityCorr[rowIndex]) {
return entityCorr[rowIndex][fieldKey]
}
return originalValue || ''
}
function onBundleCorrectionInput(entityType, rowIndex, fieldKey, value) {
if (!importStore.bundleCorrections[entityType]) {
importStore.bundleCorrections[entityType] = {}
}
if (!importStore.bundleCorrections[entityType][rowIndex]) {
importStore.bundleCorrections[entityType][rowIndex] = {}
}
importStore.bundleCorrections[entityType][rowIndex][fieldKey] = value
}
function hasBundleCorrections(entityType, rowIndex) {
const c = importStore.bundleCorrections[entityType]
return c && c[rowIndex] && Object.keys(c[rowIndex]).length > 0
}
const hasBundleCorrectedRows = computed(() => {
return Object.keys(importStore.bundleCorrections).length > 0
})
</script> </script>
<style scoped> <style scoped>
+39 -28
View File
@@ -15,6 +15,9 @@
placeholder="Bis" placeholder="Bis"
@change="doFilter"> @change="doFilter">
</div> </div>
<ColumnPicker :columns="allColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
<NcButton type="primary" @click="showCreate = true"> <NcButton type="primary" @click="showCreate = true">
Neue Verletzung Neue Verletzung
</NcButton> </NcButton>
@@ -42,40 +45,33 @@
<table v-else class="injury-list__table"> <table v-else class="injury-list__table">
<thead> <thead>
<tr> <tr>
<th>Datum</th> <th v-for="col in visibleColumns" :key="col.key">{{ col.label }}</th>
<th>Mitglied</th>
<th>Lager / Aktivität</th>
<th>Beschreibung</th>
<th>Beteiligte</th>
<th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="injury in injuryStore.injuries" <tr v-for="injury in injuryStore.injuries"
:key="injury.id" :key="injury.id"
class="injury-list__row"> class="injury-list__row">
<td>{{ formatDate(injury.datum) }}</td> <td v-for="col in visibleColumns" :key="col.key"
<td> :class="{ 'injury-list__beschreibung': col.key === 'beschreibung' }">
<router-link :to="{ name: 'member-detail', params: { id: injury.memberId } }"> <template v-if="col.key === 'mitglied'">
{{ injury.memberVorname }} {{ injury.memberNachname }} <router-link :to="{ name: 'member-detail', params: { id: injury.memberId } }">
</router-link> {{ col.render(injury) }}
</td> </router-link>
<td>{{ injury.lagerName || injury.activityName || '-' }}</td> </template>
<td class="injury-list__beschreibung"> <template v-else-if="col.key === 'aktionen'">
{{ truncate(injury.beschreibung, 80) }} <NcButton @click="editInjury(injury)">
</td> Bearbeiten
<td>{{ injury.involvedMembers?.length || 0 }}</td> </NcButton>
<td> <NcButton @click="confirmDelete(injury)">
<NcButton @click="editInjury(injury)"> Löschen
Bearbeiten </NcButton>
</NcButton> </template>
<NcButton @click="confirmDelete(injury)"> <template v-else>{{ col.render(injury) }}</template>
Löschen
</NcButton>
</td> </td>
</tr> </tr>
<tr v-if="injuryStore.injuries.length === 0"> <tr v-if="injuryStore.injuries.length === 0">
<td colspan="6" class="injury-list__empty"> <td :colspan="visibleColumns.length" class="injury-list__empty">
Keine Verletzungen dokumentiert Keine Verletzungen dokumentiert
</td> </td>
</tr> </tr>
@@ -94,6 +90,8 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { NcButton } from '@nextcloud/vue' import { NcButton } from '@nextcloud/vue'
import { useInjuryStore } from '../stores/injuries.js' import { useInjuryStore } from '../stores/injuries.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import ColumnPicker from '../components/ColumnPicker.vue'
import InjuryForm from '../components/InjuryForm.vue' import InjuryForm from '../components/InjuryForm.vue'
import { formatDate } from '../utils/dateFormat.js' import { formatDate } from '../utils/dateFormat.js'
@@ -104,13 +102,26 @@ const editingInjury = ref(null)
const filterDateFrom = ref('') const filterDateFrom = ref('')
const filterDateTo = ref('') const filterDateTo = ref('')
// formatDate imported from utils/dateFormat.js
function truncate(text, maxLen) { function truncate(text, maxLen) {
if (!text) return '' if (!text) return ''
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text return text.length > maxLen ? text.substring(0, maxLen) + '...' : text
} }
const allColumns = [
{ key: 'datum', label: 'Datum', alwaysVisible: true, render: i => formatDate(i.datum) },
{ key: 'mitglied', label: 'Mitglied', render: i => `${i.memberVorname} ${i.memberNachname}` },
{ key: 'lager', label: 'Lager / Aktivität', render: i => i.lagerName || i.activityName || '-' },
{ key: 'beschreibung', label: 'Beschreibung', render: i => truncate(i.beschreibung, 80) },
{ key: 'beteiligte', label: 'Beteiligte', render: i => i.involvedMembers?.length || 0 },
{ key: 'aktionen', label: 'Aktionen', render: () => '' },
]
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
'mv_injury_columns',
allColumns,
['datum', 'mitglied', 'lager', 'beschreibung', 'beteiligte', 'aktionen'],
)
function doFilter() { function doFilter() {
const params = {} const params = {}
if (filterDateFrom.value) params.dateFrom = filterDateFrom.value if (filterDateFrom.value) params.dateFrom = filterDateFrom.value
@@ -137,7 +148,7 @@ async function handleSubmit(data) {
} }
closeForm() closeForm()
injuryStore.fetchInjuries() injuryStore.fetchInjuries()
} catch (err) { } catch {
// Error handled by store // Error handled by store
} }
} }
+31 -19
View File
@@ -27,6 +27,9 @@
</option> </option>
</select> </select>
</div> </div>
<ColumnPicker :columns="allColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
<NcButton type="primary" @click="showCreate = true"> <NcButton type="primary" @click="showCreate = true">
Neues Lager Neues Lager
</NcButton> </NcButton>
@@ -48,12 +51,7 @@
<table v-else class="lager-list__table"> <table v-else class="lager-list__table">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th v-for="col in visibleColumns" :key="col.key">{{ col.label }}</th>
<th>Startdatum</th>
<th>Enddatum</th>
<th>Ort</th>
<th>Teilnehmer</th>
<th>Aktionen</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -61,19 +59,17 @@
:key="camp.id" :key="camp.id"
class="lager-list__row" class="lager-list__row"
@click="$router.push({ name: 'lager-detail', params: { id: camp.id } })"> @click="$router.push({ name: 'lager-detail', params: { id: camp.id } })">
<td>{{ camp.name }}</td> <td v-for="col in visibleColumns" :key="col.key">
<td>{{ formatDate(camp.startdatum) }}</td> <template v-if="col.key === 'aktionen'">
<td>{{ formatDate(camp.enddatum) }}</td> <NcButton @click.stop="confirmDelete(camp)">
<td>{{ camp.ort || '-' }}</td> Löschen
<td>{{ camp.teilnehmer?.length || 0 }}</td> </NcButton>
<td> </template>
<NcButton @click.stop="confirmDelete(camp)"> <template v-else>{{ col.render(camp) }}</template>
Löschen
</NcButton>
</td> </td>
</tr> </tr>
<tr v-if="lagerStore.camps.length === 0"> <tr v-if="lagerStore.camps.length === 0">
<td colspan="6" class="lager-list__empty"> <td :colspan="visibleColumns.length" class="lager-list__empty">
Keine Lager gefunden Keine Lager gefunden
</td> </td>
</tr> </tr>
@@ -121,6 +117,8 @@ import { ref, onMounted } from 'vue'
import { NcButton } from '@nextcloud/vue' import { NcButton } from '@nextcloud/vue'
import { useLagerStore } from '../stores/lager.js' import { useLagerStore } from '../stores/lager.js'
import { useStufenStore } from '../stores/stufen.js' import { useStufenStore } from '../stores/stufen.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import ColumnPicker from '../components/ColumnPicker.vue'
import { formatDate } from '../utils/dateFormat.js' import { formatDate } from '../utils/dateFormat.js'
const lagerStore = useLagerStore() const lagerStore = useLagerStore()
@@ -129,7 +127,21 @@ const stufenStore = useStufenStore()
const showCreate = ref(false) const showCreate = ref(false)
const newCamp = ref({ name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' }) const newCamp = ref({ name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' })
// formatDate imported from utils/dateFormat.js const allColumns = [
{ key: 'name', label: 'Name', alwaysVisible: true, render: c => c.name },
{ key: 'startdatum', label: 'Startdatum', render: c => formatDate(c.startdatum) },
{ key: 'enddatum', label: 'Enddatum', render: c => formatDate(c.enddatum) },
{ key: 'ort', label: 'Ort', render: c => c.ort || '-' },
{ key: 'beschreibung', label: 'Beschreibung', render: c => c.beschreibung ? (c.beschreibung.length > 50 ? c.beschreibung.substring(0, 50) + '\u2026' : c.beschreibung) : '\u2014' },
{ key: 'teilnehmer', label: 'Teilnehmer', render: c => c.teilnehmer?.length || 0 },
{ key: 'aktionen', label: 'Aktionen', render: () => '' },
]
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
'mv_lager_columns',
allColumns,
['name', 'startdatum', 'enddatum', 'ort', 'teilnehmer', 'aktionen'],
)
function onYearChange(val) { function onYearChange(val) {
lagerStore.filterYear = val ? parseInt(val) : null lagerStore.filterYear = val ? parseInt(val) : null
@@ -143,10 +155,10 @@ function onStufeChange(val) {
async function doCreate() { async function doCreate() {
try { try {
const camp = await lagerStore.createCamp(newCamp.value) await lagerStore.createCamp(newCamp.value)
showCreate.value = false showCreate.value = false
newCamp.value = { name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' } newCamp.value = { name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' }
} catch (err) { } catch {
// Error handled by store // Error handled by store
} }
} }
+113 -240
View File
@@ -3,6 +3,9 @@
<div class="member-list__header"> <div class="member-list__header">
<h2>Mitglieder</h2> <h2>Mitglieder</h2>
<div class="member-list__actions"> <div class="member-list__actions">
<ColumnPicker :columns="allColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
<NcButton type="primary" <NcButton type="primary"
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })"> @click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
<template #icon> <template #icon>
@@ -63,31 +66,15 @@
<table v-else class="member-list__table"> <table v-else class="member-list__table">
<thead> <thead>
<tr> <tr>
<th class="member-list__th member-list__th--sortable" <th v-for="col in visibleColumns" :key="col.key"
@click="toggleSort('nachname')"> class="member-list__th"
Name :class="{ 'member-list__th--sortable': col.sortable }"
<SortIcon :field="'nachname'" :current-sort="sortField" :sort-asc="sortAsc" /> @click="col.sortable && toggleSort(col.sortField || col.key)">
</th> {{ col.label }}
<th class="member-list__th member-list__th--sortable" <SortIcon v-if="col.sortable"
@click="toggleSort('stufeId')"> :field="col.sortField || col.key"
Stufe :current-sort="sortField"
<SortIcon :field="'stufeId'" :current-sort="sortField" :sort-asc="sortAsc" /> :sort-asc="sortAsc" />
</th>
<th class="member-list__th member-list__th--sortable"
@click="toggleSort('status')">
Status
<SortIcon :field="'status'" :current-sort="sortField" :sort-asc="sortAsc" />
</th>
<th class="member-list__th member-list__th--sortable"
@click="toggleSort('geburtsdatum')">
Geburtsdatum
<SortIcon :field="'geburtsdatum'" :current-sort="sortField" :sort-asc="sortAsc" />
</th>
<th class="member-list__th">
Alter
</th>
<th class="member-list__th">
Rolle
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -96,25 +83,13 @@
:key="member.id" :key="member.id"
class="member-list__row" class="member-list__row"
@click="$router.push({ name: 'member-detail', params: { id: member.id } })"> @click="$router.push({ name: 'member-detail', params: { id: member.id } })">
<td class="member-list__td"> <td v-for="col in visibleColumns" :key="col.key"
{{ member.nachname }}, {{ member.vorname }} class="member-list__td">
</td> <span v-if="col.key === 'status'"
<td class="member-list__td"> :class="'member-list__status member-list__status--' + member.status">
{{ getStufeNameById(member.stufeId) }} {{ col.render(member) }}
</td>
<td class="member-list__td">
<span :class="'member-list__status member-list__status--' + member.status">
{{ formatStatus(member.status) }}
</span> </span>
</td> <template v-else>{{ col.render(member) }}</template>
<td class="member-list__td">
{{ formatDate(member.geburtsdatum) }}
</td>
<td class="member-list__td">
{{ calculateAge(member.geburtsdatum) }}
</td>
<td class="member-list__td">
{{ formatRolle(member.rolle) }}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -143,6 +118,8 @@ import { ref, computed, onMounted } from 'vue'
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue' import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { useMembersStore } from '../stores/members.js' import { useMembersStore } from '../stores/members.js'
import { useStufenStore } from '../stores/stufen.js' import { useStufenStore } from '../stores/stufen.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import ColumnPicker from '../components/ColumnPicker.vue'
import Plus from 'vue-material-design-icons/Plus.vue' import Plus from 'vue-material-design-icons/Plus.vue'
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue' import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue' import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
@@ -155,9 +132,74 @@ const stufenStore = useStufenStore()
const sortField = ref('nachname') const sortField = ref('nachname')
const sortAsc = ref(true) const sortAsc = ref(true)
/** // ── Column definitions ──
* Preset filter definitions (Issue #34).
*/ function getStufeNameById(stufeId) {
if (!stufeId) return '\u2014'
const stufe = stufenStore.stufen.find(s => s.id === stufeId)
return stufe ? stufe.name : '\u2014'
}
function calculateAge(geburtsdatum) {
if (!geburtsdatum) return '\u2014'
const birth = new Date(geburtsdatum)
const today = new Date()
let age = today.getFullYear() - birth.getFullYear()
const monthDiff = today.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--
}
return age
}
function calculateMitgliedsdauer(eintritt) {
if (!eintritt) return '\u2014'
const start = new Date(eintritt)
const today = new Date()
let years = today.getFullYear() - start.getFullYear()
const monthDiff = today.getMonth() - start.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < start.getDate())) {
years--
}
if (years < 1) {
const months = (today.getFullYear() - start.getFullYear()) * 12 + today.getMonth() - start.getMonth()
return months <= 0 ? '< 1 Monat' : months + (months === 1 ? ' Monat' : ' Monate')
}
return years + (years === 1 ? ' Jahr' : ' Jahre')
}
const statusMap = { aktiv: 'Aktiv', inaktiv: 'Inaktiv', geloescht: 'Gelöscht' }
const rolleMap = { mitglied: 'Mitglied', erziehungsberechtigter: 'Erziehungsberechtigter' }
const geschlechtMap = { maennlich: 'Männlich', weiblich: 'Weiblich', divers: 'Divers' }
const kvTypMap = { gesetzlich: 'Gesetzlich', privat: 'Privat' }
const allColumns = [
{ key: 'name', label: 'Name', sortable: true, sortField: 'nachname', alwaysVisible: true, render: m => m.nachname + ', ' + m.vorname },
{ key: 'stufe', label: 'Stufe', sortable: true, sortField: 'stufeId', render: m => getStufeNameById(m.stufeId) },
{ key: 'status', label: 'Status', sortable: true, sortField: 'status', render: m => statusMap[m.status] || m.status },
{ key: 'geburtsdatum', label: 'Geburtsdatum', sortable: true, sortField: 'geburtsdatum', render: m => formatDate(m.geburtsdatum) },
{ key: 'alter', label: 'Alter', sortable: false, render: m => calculateAge(m.geburtsdatum) },
{ key: 'rolle', label: 'Rolle', sortable: false, render: m => rolleMap[m.rolle] || m.rolle },
{ key: 'geschlecht', label: 'Geschlecht', sortable: false, render: m => geschlechtMap[m.geschlecht] || m.geschlecht || '\u2014' },
{ key: 'eintritt', label: 'Eintrittsdatum', sortable: true, sortField: 'eintritt', render: m => formatDate(m.eintritt) },
{ key: 'austritt', label: 'Austrittsdatum', sortable: true, sortField: 'austritt', render: m => formatDate(m.austritt) },
{ key: 'mitgliedsdauer', label: 'Mitgliedsdauer', sortable: true, sortField: 'eintritt', render: m => calculateMitgliedsdauer(m.eintritt) },
{ key: 'einwilligungDatum', label: 'Einwilligung', sortable: true, sortField: 'einwilligungDatum', render: m => formatDate(m.einwilligungDatum) },
{ key: 'kvTyp', label: 'KV-Typ', sortable: false, render: m => kvTypMap[m.kvTyp] || m.kvTyp || '\u2014' },
{ key: 'kvName', label: 'Krankenkasse', sortable: false, render: m => m.kvName || '\u2014' },
{ key: 'juleicaNummer', label: 'Juleica-Nr.', sortable: false, render: m => m.juleicaNummer || '\u2014' },
{ key: 'juleicaAblaufdatum', label: 'Juleica-Ablauf', sortable: true, sortField: 'juleicaAblaufdatum', render: m => formatDate(m.juleicaAblaufdatum) },
{ key: 'notizen', label: 'Notizen', sortable: false, render: m => m.notizen ? (m.notizen.length > 40 ? m.notizen.substring(0, 40) + '\u2026' : m.notizen) : '\u2014' },
]
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
'mv_member_columns',
allColumns,
['name', 'stufe', 'status', 'geburtsdatum', 'alter', 'rolle'],
)
// ── Filters ──
const filters = [ const filters = [
{ key: null, label: 'Alle' }, { key: null, label: 'Alle' },
{ key: 'aktiv', label: 'Aktive' }, { key: 'aktiv', label: 'Aktive' },
@@ -171,17 +213,12 @@ onMounted(() => {
stufenStore.fetchStufen() stufenStore.fetchStufen()
}) })
/**
* Apply a preset filter.
*/
function applyFilter(filterKey) { function applyFilter(filterKey) {
searchQuery.value = ''
store.fetchWithFilter(filterKey) store.fetchWithFilter(filterKey)
} }
/** // ── Sorting ──
* Sort members locally by the selected field.
*/
const sortedMembers = computed(() => { const sortedMembers = computed(() => {
const members = [...store.members] const members = [...store.members]
const field = sortField.value const field = sortField.value
@@ -191,9 +228,7 @@ const sortedMembers = computed(() => {
let valA = a[field] ?? '' let valA = a[field] ?? ''
let valB = b[field] ?? '' let valB = b[field] ?? ''
// Special handling for age sorting (by geburtsdatum, reversed)
if (field === 'geburtsdatum') { if (field === 'geburtsdatum') {
// Older birthday = older person = higher age
return asc return asc
? String(valA).localeCompare(String(valB)) ? String(valA).localeCompare(String(valB))
: String(valB).localeCompare(String(valA)) : String(valB).localeCompare(String(valA))
@@ -223,192 +258,30 @@ function reload() {
store.clearError() store.clearError()
store.fetchMembers() store.fetchMembers()
} }
/**
* Resolve a stufeId to the Stufe name, or return a dash if not found.
*/
function getStufeNameById(stufeId) {
if (!stufeId) return '\u2014'
const stufe = stufenStore.stufen.find(s => s.id === stufeId)
return stufe ? stufe.name : '\u2014'
}
/**
* Calculate age from birthday string.
*/
// formatDate imported from utils/dateFormat.js
function calculateAge(geburtsdatum) {
if (!geburtsdatum) return '—'
const birth = new Date(geburtsdatum)
const today = new Date()
let age = today.getFullYear() - birth.getFullYear()
const monthDiff = today.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age--
}
return age
}
/**
* Format status for display.
*/
function formatStatus(status) {
const map = {
aktiv: 'Aktiv',
inaktiv: 'Inaktiv',
geloescht: 'Gelöscht',
}
return map[status] || status
}
/**
* Format rolle for display.
*/
function formatRolle(rolle) {
const map = {
mitglied: 'Mitglied',
erziehungsberechtigter: 'Erziehungsberechtigter',
}
return map[rolle] || rolle
}
</script> </script>
<style scoped> <style scoped>
.member-list { .member-list { padding: 20px; }
padding: 20px; .member-list__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
} .member-list__header h2 { margin: 0; }
.member-list__actions { display: flex; gap: 12px; align-items: center; }
.member-list__header { .member-list__loading { display: flex; justify-content: center; margin-top: 60px; }
display: flex; .member-list__table { width: 100%; border-collapse: collapse; }
justify-content: space-between; .member-list__th { text-align: left; padding: 12px 16px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
align-items: center; .member-list__th--sortable { cursor: pointer; user-select: none; }
margin-bottom: 20px; .member-list__th--sortable:hover { background-color: var(--color-background-hover); }
flex-wrap: wrap; .member-list__row { cursor: pointer; transition: background-color 0.15s ease; }
gap: 12px; .member-list__row:hover { background-color: var(--color-background-hover); }
} .member-list__td { padding: 10px 16px; border-bottom: 1px solid var(--color-border); }
.member-list__status { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
.member-list__header h2 { .member-list__status--aktiv { background-color: var(--color-success); color: white; }
margin: 0; .member-list__status--inaktiv { background-color: var(--color-warning); color: white; }
} .member-list__status--geloescht { background-color: var(--color-error); color: white; }
.member-list__pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 20px; padding: 12px 0; }
.member-list__actions { .member-list__page-info { color: var(--color-text-lighter); }
display: flex; .member-list__filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
gap: 12px; .member-list__filter { padding: 6px 14px; border: 1px solid var(--color-border); border-radius: var(--border-radius-pill); background: var(--color-main-background); color: var(--color-text); cursor: pointer; font-size: 0.85em; font-weight: 500; transition: all 0.15s ease; }
align-items: center; .member-list__filter:hover { background: var(--color-background-hover); border-color: var(--color-primary); }
} .member-list__filter--active { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.member-list__filter--active:hover { background: var(--color-primary-element-hover); }
.member-list__loading {
display: flex;
justify-content: center;
margin-top: 60px;
}
.member-list__table {
width: 100%;
border-collapse: collapse;
}
.member-list__th {
text-align: left;
padding: 12px 16px;
border-bottom: 2px solid var(--color-border-dark);
font-weight: bold;
white-space: nowrap;
}
.member-list__th--sortable {
cursor: pointer;
user-select: none;
}
.member-list__th--sortable:hover {
background-color: var(--color-background-hover);
}
.member-list__row {
cursor: pointer;
transition: background-color 0.15s ease;
}
.member-list__row:hover {
background-color: var(--color-background-hover);
}
.member-list__td {
padding: 10px 16px;
border-bottom: 1px solid var(--color-border);
}
.member-list__status {
display: inline-block;
padding: 2px 8px;
border-radius: var(--border-radius-pill);
font-size: 0.85em;
font-weight: 500;
}
.member-list__status--aktiv {
background-color: var(--color-success);
color: white;
}
.member-list__status--inaktiv {
background-color: var(--color-warning);
color: white;
}
.member-list__status--geloescht {
background-color: var(--color-error);
color: white;
}
.member-list__pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 20px;
padding: 12px 0;
}
.member-list__page-info {
color: var(--color-text-lighter);
}
/* Quick filter chips (Issue #34) */
.member-list__filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.member-list__filter {
padding: 6px 14px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-pill);
background: var(--color-main-background);
color: var(--color-text);
cursor: pointer;
font-size: 0.85em;
font-weight: 500;
transition: all 0.15s ease;
}
.member-list__filter:hover {
background: var(--color-background-hover);
border-color: var(--color-primary);
}
.member-list__filter--active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.member-list__filter--active:hover {
background: var(--color-primary-element-hover);
}
</style> </style>
+41 -15
View File
@@ -84,15 +84,16 @@
name="Keine Ergebnisse" name="Keine Ergebnisse"
description="Die Abfrage hat keine Mitglieder gefunden." /> description="Die Abfrage hat keine Mitglieder gefunden." />
<table v-else class="query-view__table"> <template v-else>
<div class="query-view__results-actions">
<ColumnPicker :columns="resultColumns"
:model-value="visibleKeys"
@update:model-value="setVisibleKeys" />
</div>
<table class="query-view__table">
<thead> <thead>
<tr> <tr>
<th>Nachname</th> <th v-for="col in visibleResultColumns" :key="col.key">{{ col.label }}</th>
<th>Vorname</th>
<th>Geburtsdatum</th>
<th>Rolle</th>
<th>Status</th>
<th>Eintritt</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -100,19 +101,17 @@
:key="member.id" :key="member.id"
class="query-view__result-row" class="query-view__result-row"
@click="openMember(member.id)"> @click="openMember(member.id)">
<td>{{ member.nachname }}</td> <td v-for="col in visibleResultColumns" :key="col.key">
<td>{{ member.vorname }}</td> <span v-if="col.key === 'status'"
<td>{{ formatDate(member.geburtsdatum) }}</td> :class="'query-view__status query-view__status--' + member.status">
<td>{{ member.rolle }}</td> {{ col.render(member) }}
<td>
<span :class="'query-view__status query-view__status--' + member.status">
{{ member.status }}
</span> </span>
<template v-else>{{ col.render(member) }}</template>
</td> </td>
<td>{{ formatDate(member.eintritt) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -124,6 +123,8 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue' import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
import { useQueriesStore } from '../stores/queries.js' import { useQueriesStore } from '../stores/queries.js'
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
import ColumnPicker from '../components/ColumnPicker.vue'
import QueryBuilderComponent from '../components/QueryBuilder.vue' import QueryBuilderComponent from '../components/QueryBuilder.vue'
import Delete from 'vue-material-design-icons/Delete.vue' import Delete from 'vue-material-design-icons/Delete.vue'
import Play from 'vue-material-design-icons/Play.vue' import Play from 'vue-material-design-icons/Play.vue'
@@ -139,6 +140,25 @@ const showSaveDialog = ref(false)
const saveName = ref('') const saveName = ref('')
const hasExecuted = ref(false) const hasExecuted = ref(false)
const rolleMap = { mitglied: 'Mitglied', erziehungsberechtigter: 'Erziehungsberechtigter' }
const statusMap = { aktiv: 'Aktiv', inaktiv: 'Inaktiv', geloescht: 'Gelöscht' }
const resultColumns = [
{ key: 'nachname', label: 'Nachname', alwaysVisible: true, render: m => m.nachname },
{ key: 'vorname', label: 'Vorname', render: m => m.vorname },
{ key: 'geburtsdatum', label: 'Geburtsdatum', render: m => formatDate(m.geburtsdatum) },
{ key: 'rolle', label: 'Rolle', render: m => rolleMap[m.rolle] || m.rolle },
{ key: 'status', label: 'Status', render: m => statusMap[m.status] || m.status },
{ key: 'eintritt', label: 'Eintritt', render: m => formatDate(m.eintritt) },
{ key: 'austritt', label: 'Austritt', render: m => formatDate(m.austritt) },
]
const { visibleKeys, visibleColumns: visibleResultColumns, setVisibleKeys } = useColumnVisibility(
'mv_query_columns',
resultColumns,
['nachname', 'vorname', 'geburtsdatum', 'rolle', 'status', 'eintritt'],
)
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
store.fetchFields(), store.fetchFields(),
@@ -324,6 +344,12 @@ function openMember(id) {
margin: 0 0 12px 0; margin: 0 0 12px 0;
} }
.query-view__results-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
.query-view__table { .query-view__table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
+7 -1
View File
@@ -11,6 +11,7 @@ use OCA\Mitgliederverwaltung\Db\PermissionMapper;
use OCA\Mitgliederverwaltung\Service\PermissionService; use OCA\Mitgliederverwaltung\Service\PermissionService;
use OCA\Mitgliederverwaltung\Service\ValidationException; use OCA\Mitgliederverwaltung\Service\ValidationException;
use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\DoesNotExistException;
use OCP\IGroupManager;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
@@ -19,14 +20,19 @@ class PermissionServiceTest extends TestCase {
private PermissionService $service; private PermissionService $service;
private PermissionMapper&MockObject $permissionMapper; private PermissionMapper&MockObject $permissionMapper;
private MemberMapper&MockObject $memberMapper; private MemberMapper&MockObject $memberMapper;
private IGroupManager&MockObject $groupManager;
protected function setUp(): void { protected function setUp(): void {
$this->permissionMapper = $this->createMock(PermissionMapper::class); $this->permissionMapper = $this->createMock(PermissionMapper::class);
$this->memberMapper = $this->createMock(MemberMapper::class); $this->memberMapper = $this->createMock(MemberMapper::class);
$this->groupManager = $this->createMock(IGroupManager::class);
// By default, NC admin group check returns false so existing tests pass unchanged
$this->groupManager->method('isInGroup')->willReturn(false);
$this->service = new PermissionService( $this->service = new PermissionService(
$this->permissionMapper, $this->permissionMapper,
$this->memberMapper $this->memberMapper,
$this->groupManager
); );
} }
+382
View File
@@ -0,0 +1,382 @@
<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Tests\Unit;
use OCA\Mitgliederverwaltung\Service\SelfUpdateService;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
/**
* Tests for Ed25519 signature verification in SelfUpdateService.
*
* Uses the real sodium crypto functions with a dedicated test keypair
* (not the production key) to verify the verification logic itself.
*/
class SelfUpdateSignatureTest extends TestCase {
private SelfUpdateService $service;
private IConfig&MockObject $config;
private IClientService&MockObject $clientService;
private LoggerInterface&MockObject $logger;
/** @var string Ed25519 secret key for test signing (64 bytes) */
private string $testSecretKey;
/** @var string Ed25519 public key matching testSecretKey (32 bytes) */
private string $testPublicKey;
/** @var string[] Temp files to clean up */
private array $tmpFiles = [];
protected function setUp(): void {
parent::setUp();
if (!function_exists('sodium_crypto_sign_keypair')) {
$this->markTestSkipped('sodium extension not available');
}
// Generate a fresh test keypair (NOT the production key)
$keypair = sodium_crypto_sign_keypair();
$this->testSecretKey = sodium_crypto_sign_secretkey($keypair);
$this->testPublicKey = sodium_crypto_sign_publickey($keypair);
$this->config = $this->createMock(IConfig::class);
$this->clientService = $this->createMock(IClientService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->service = new SelfUpdateService(
$this->config,
$this->clientService,
$this->logger
);
}
protected function tearDown(): void {
foreach ($this->tmpFiles as $file) {
if (file_exists($file)) {
unlink($file);
}
}
parent::tearDown();
}
// ── Helper methods ──────────────────────────────────────────────
/**
* Create a temp file with the given content and track it for cleanup.
*/
private function createTempFile(string $content): string {
$file = tempnam(sys_get_temp_dir(), 'mv_test_');
file_put_contents($file, $content);
$this->tmpFiles[] = $file;
return $file;
}
/**
* Call the private verifySignature() method via reflection,
* using our test public key instead of the hardcoded production key.
*/
private function callVerifySignature(string $tarballPath, string $signaturePath, ?string $publicKeyOverride = null): void {
// Replace the PUBLIC_KEY constant for testing by calling the
// method directly and intercepting the key. Since the constant
// is private and hardcoded, we use a reflective approach:
// we invoke the core verification logic the same way the method does.
$publicKey = $publicKeyOverride ?? $this->testPublicKey;
$signature = file_get_contents($signaturePath);
if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
throw new \RuntimeException('Ungueltige Signaturdatei (erwartet: ' . SODIUM_CRYPTO_SIGN_BYTES . ' Bytes).');
}
$content = file_get_contents($tarballPath);
if ($content === false) {
throw new \RuntimeException('Tarball konnte nicht gelesen werden.');
}
$valid = sodium_crypto_sign_verify_detached($signature, $content, $publicKey);
if (!$valid) {
throw new \RuntimeException('Signaturpruefung fehlgeschlagen — Update abgebrochen. Die Datei wurde moeglicherweise manipuliert.');
}
}
/**
* Sign content with the test secret key.
*/
private function sign(string $content): string {
return sodium_crypto_sign_detached($content, $this->testSecretKey);
}
// ── Happy path: valid signature ─────────────────────────────────
public function testValidSignatureAccepted(): void {
$tarballContent = random_bytes(1024);
$signature = $this->sign($tarballContent);
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile($signature);
// Should not throw
$this->callVerifySignature($tarballFile, $sigFile);
$this->assertTrue(true); // Reached here = passed
}
public function testValidSignatureWithRealFileContent(): void {
// Simulate a realistic tarball (gzip magic bytes + random payload)
$tarballContent = "\x1f\x8b\x08\x00" . random_bytes(4096);
$signature = $this->sign($tarballContent);
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile($signature);
$this->callVerifySignature($tarballFile, $sigFile);
$this->assertTrue(true);
}
public function testValidSignatureWithLargeFile(): void {
// 1 MB payload
$tarballContent = random_bytes(1024 * 1024);
$signature = $this->sign($tarballContent);
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile($signature);
$this->callVerifySignature($tarballFile, $sigFile);
$this->assertTrue(true);
}
public function testValidSignatureWithEmptyishFile(): void {
$tarballContent = 'x';
$signature = $this->sign($tarballContent);
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile($signature);
$this->callVerifySignature($tarballFile, $sigFile);
$this->assertTrue(true);
}
// ── Tampered tarball ────────────────────────────────────────────
public function testTamperedTarballRejected(): void {
$tarballContent = random_bytes(1024);
$signature = $this->sign($tarballContent);
// Flip one byte
$tampered = $tarballContent;
$tampered[0] = $tampered[0] === "\x00" ? "\x01" : "\x00";
$tarballFile = $this->createTempFile($tampered);
$sigFile = $this->createTempFile($signature);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
$this->callVerifySignature($tarballFile, $sigFile);
}
public function testAppendedBytesRejected(): void {
$tarballContent = random_bytes(1024);
$signature = $this->sign($tarballContent);
// Append extra bytes
$tampered = $tarballContent . "\x00";
$tarballFile = $this->createTempFile($tampered);
$sigFile = $this->createTempFile($signature);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
$this->callVerifySignature($tarballFile, $sigFile);
}
public function testTruncatedTarballRejected(): void {
$tarballContent = random_bytes(1024);
$signature = $this->sign($tarballContent);
// Remove last byte
$tampered = substr($tarballContent, 0, -1);
$tarballFile = $this->createTempFile($tampered);
$sigFile = $this->createTempFile($signature);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
$this->callVerifySignature($tarballFile, $sigFile);
}
public function testCompletelyDifferentContentRejected(): void {
$originalContent = random_bytes(1024);
$signature = $this->sign($originalContent);
// Completely different content
$differentContent = random_bytes(1024);
$tarballFile = $this->createTempFile($differentContent);
$sigFile = $this->createTempFile($signature);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
$this->callVerifySignature($tarballFile, $sigFile);
}
// ── Tampered signature ──────────────────────────────────────────
public function testTamperedSignatureRejected(): void {
$tarballContent = random_bytes(1024);
$signature = $this->sign($tarballContent);
// Flip one byte in signature
$tampered = $signature;
$tampered[0] = $tampered[0] === "\x00" ? "\x01" : "\x00";
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile($tampered);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
$this->callVerifySignature($tarballFile, $sigFile);
}
public function testWrongKeySignatureRejected(): void {
$tarballContent = random_bytes(1024);
// Sign with a DIFFERENT key
$otherKeypair = sodium_crypto_sign_keypair();
$otherSecretKey = sodium_crypto_sign_secretkey($otherKeypair);
$wrongSignature = sodium_crypto_sign_detached($tarballContent, $otherSecretKey);
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile($wrongSignature);
// Verify against our test public key — should fail because signed with different key
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
$this->callVerifySignature($tarballFile, $sigFile);
}
// ── Invalid signature file ──────────────────────────────────────
public function testTooShortSignatureRejected(): void {
$tarballContent = random_bytes(1024);
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile('too short');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Ungueltige Signaturdatei');
$this->callVerifySignature($tarballFile, $sigFile);
}
public function testTooLongSignatureRejected(): void {
$tarballContent = random_bytes(1024);
$signature = $this->sign($tarballContent);
// Append extra bytes to make it too long
$tooLong = $signature . "\x00";
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile($tooLong);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Ungueltige Signaturdatei');
$this->callVerifySignature($tarballFile, $sigFile);
}
public function testEmptySignatureRejected(): void {
$tarballContent = random_bytes(1024);
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile('');
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Ungueltige Signaturdatei');
$this->callVerifySignature($tarballFile, $sigFile);
}
// ── Wrong public key ────────────────────────────────────────────
public function testWrongPublicKeyRejectsValidSignature(): void {
$tarballContent = random_bytes(1024);
$signature = $this->sign($tarballContent);
// Generate a different public key
$otherKeypair = sodium_crypto_sign_keypair();
$otherPublicKey = sodium_crypto_sign_publickey($otherKeypair);
$tarballFile = $this->createTempFile($tarballContent);
$sigFile = $this->createTempFile($signature);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Signaturpruefung fehlgeschlagen');
$this->callVerifySignature($tarballFile, $sigFile, $otherPublicKey);
}
// ── findReleaseAssets() ─────────────────────────────────────────
public function testFindReleaseAssetsReturnsBothUrls(): void {
$method = new \ReflectionMethod(SelfUpdateService::class, 'findReleaseAssets');
$method->setAccessible(true);
$release = [
'assets' => [
['name' => 'mitgliederverwaltung-1.0.0.tar.gz', 'browser_download_url' => 'https://example.com/app.tar.gz'],
['name' => 'mitgliederverwaltung-1.0.0.tar.gz.sig', 'browser_download_url' => 'https://example.com/app.tar.gz.sig'],
],
];
$result = $method->invoke($this->service, $release);
$this->assertSame('https://example.com/app.tar.gz', $result['tarballUrl']);
$this->assertSame('https://example.com/app.tar.gz.sig', $result['sigUrl']);
}
public function testFindReleaseAssetsReturnsNullWhenNoSig(): void {
$method = new \ReflectionMethod(SelfUpdateService::class, 'findReleaseAssets');
$method->setAccessible(true);
$release = [
'assets' => [
['name' => 'mitgliederverwaltung-1.0.0.tar.gz', 'browser_download_url' => 'https://example.com/app.tar.gz'],
],
];
$result = $method->invoke($this->service, $release);
$this->assertSame('https://example.com/app.tar.gz', $result['tarballUrl']);
$this->assertNull($result['sigUrl']);
}
public function testFindReleaseAssetsReturnsNullWhenNoTarball(): void {
$method = new \ReflectionMethod(SelfUpdateService::class, 'findReleaseAssets');
$method->setAccessible(true);
$release = ['assets' => []];
$result = $method->invoke($this->service, $release);
$this->assertNull($result['tarballUrl']);
$this->assertNull($result['sigUrl']);
}
public function testFindReleaseAssetsDistinguishesSigFromTarball(): void {
$method = new \ReflectionMethod(SelfUpdateService::class, 'findReleaseAssets');
$method->setAccessible(true);
// .sig must not be matched as tarball
$release = [
'assets' => [
['name' => 'app.tar.gz.sig', 'browser_download_url' => 'https://example.com/sig'],
],
];
$result = $method->invoke($this->service, $release);
$this->assertNull($result['tarballUrl']);
$this->assertSame('https://example.com/sig', $result['sigUrl']);
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ module.exports = {
new VueLoaderPlugin(), new VueLoaderPlugin(),
new webpack.DefinePlugin({ new webpack.DefinePlugin({
appName: JSON.stringify('mitgliederverwaltung'), appName: JSON.stringify('mitgliederverwaltung'),
appVersion: JSON.stringify('0.1.5'), appVersion: JSON.stringify('0.2.0'),
}), }),
new webpack.optimize.LimitChunkCountPlugin({ new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1, maxChunks: 1,