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:
@@ -30,5 +30,9 @@ package-lock.json
|
||||
|
||||
# Test artifacts
|
||||
.playwright-mcp/
|
||||
.phpunit.cache/
|
||||
screenshots/
|
||||
test-results/
|
||||
|
||||
# Release artifacts
|
||||
artifacts/
|
||||
|
||||
@@ -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)
|
||||
deps:
|
||||
@@ -70,6 +70,65 @@ down:
|
||||
logs:
|
||||
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)
|
||||
clean:
|
||||
docker compose down -v
|
||||
|
||||
+8
-1
@@ -5,7 +5,7 @@
|
||||
<name>Mitgliederverwaltung</name>
|
||||
<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>
|
||||
<version>0.1.5</version>
|
||||
<version>0.2.0</version>
|
||||
<licence>agpl</licence>
|
||||
<author>shahondin1624</author>
|
||||
<namespace>Mitgliederverwaltung</namespace>
|
||||
@@ -19,7 +19,14 @@
|
||||
<job>OCA\Mitgliederverwaltung\BackgroundJob\SyncQueueJob</job>
|
||||
<job>OCA\Mitgliederverwaltung\BackgroundJob\CalendarFullSyncJob</job>
|
||||
<job>OCA\Mitgliederverwaltung\BackgroundJob\ContactsFullSyncJob</job>
|
||||
<job>OCA\Mitgliederverwaltung\BackgroundJob\BackupJob</job>
|
||||
</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>
|
||||
<navigation>
|
||||
<name>Mitgliederverwaltung</name>
|
||||
|
||||
@@ -158,6 +158,20 @@ return [
|
||||
['name' => 'query#destroy', 'url' => '/api/v1/queries/{id}', 'verb' => 'DELETE'],
|
||||
['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) ────────────────────
|
||||
['name' => 'file#getSettings', 'url' => '/api/v1/files/settings', 'verb' => 'GET'],
|
||||
['name' => 'file#updateSettings', 'url' => '/api/v1/files/settings', 'verb' => 'PUT'],
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to preview bundle', [
|
||||
@@ -574,8 +576,9 @@ class ImportController extends ApiController {
|
||||
}
|
||||
|
||||
$overrides = $data['overrides'] ?? [];
|
||||
$corrections = $data['corrections'] ?? [];
|
||||
|
||||
$result = $this->bundleImportService->executeBundle($decoded, $overrides);
|
||||
$result = $this->bundleImportService->executeBundle($decoded, $overrides, $corrections);
|
||||
return new JSONResponse($result);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error('Failed to execute bundle import', [
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace OCA\Mitgliederverwaltung\Middleware;
|
||||
|
||||
use OCA\Mitgliederverwaltung\Controller\AuditController;
|
||||
use OCA\Mitgliederverwaltung\Controller\BackupController;
|
||||
use OCA\Mitgliederverwaltung\Controller\DsgvoController;
|
||||
use OCA\Mitgliederverwaltung\Controller\ExportController;
|
||||
use OCA\Mitgliederverwaltung\Controller\FeeController;
|
||||
@@ -89,6 +90,7 @@ class AuthorizationMiddleware extends Middleware {
|
||||
|| $controller instanceof PermissionController
|
||||
|| $controller instanceof AuditController
|
||||
|| $controller instanceof DsgvoController
|
||||
|| $controller instanceof BackupController
|
||||
) {
|
||||
if (!$this->permissionService->isAdmin($userId)) {
|
||||
throw new \RuntimeException('Authorization: admin required');
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ class BundleImportService {
|
||||
* @param array $entityOverrides Optional type overrides: {filename: type}
|
||||
* @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);
|
||||
$analysis = $this->analyzeBundle($zipContent);
|
||||
|
||||
@@ -188,12 +188,16 @@ class BundleImportService {
|
||||
$parsed = $this->entityImportService->parseFile($remappedContent, ';', 'UTF-8');
|
||||
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
|
||||
|
||||
$entityCorrections = $corrections[$type] ?? [];
|
||||
|
||||
$result = $this->entityImportService->execute(
|
||||
$type,
|
||||
$remappedContent,
|
||||
$mapping,
|
||||
';',
|
||||
'UTF-8'
|
||||
'UTF-8',
|
||||
false,
|
||||
$entityCorrections
|
||||
);
|
||||
|
||||
// Collect ID mappings from created entities
|
||||
@@ -236,9 +240,11 @@ class BundleImportService {
|
||||
/**
|
||||
* 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[]}
|
||||
*/
|
||||
public function previewBundle(string $zipContent): array {
|
||||
public function previewBundle(string $zipContent, array $corrections = []): array {
|
||||
$csvFiles = $this->extractCsvFiles($zipContent);
|
||||
$analysis = $this->analyzeBundle($zipContent);
|
||||
$results = [];
|
||||
@@ -262,14 +268,21 @@ class BundleImportService {
|
||||
$parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8');
|
||||
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
|
||||
|
||||
$entityCorrections = $corrections[$type] ?? [];
|
||||
|
||||
$preview = $this->entityImportService->preview(
|
||||
$type,
|
||||
$content,
|
||||
$mapping,
|
||||
';',
|
||||
'UTF-8'
|
||||
'UTF-8',
|
||||
false,
|
||||
$entityCorrections
|
||||
);
|
||||
|
||||
// Include target fields so the frontend can render editable inputs
|
||||
$schema = $this->entityImportService->getImportSchema($type);
|
||||
|
||||
$results[] = [
|
||||
'type' => $type,
|
||||
'label' => $entityEntry['label'],
|
||||
@@ -277,6 +290,7 @@ class BundleImportService {
|
||||
'fkWarnings' => $preview['fkWarnings'],
|
||||
'errorCount' => count($preview['errors']),
|
||||
'errors' => array_slice($preview['errors'], 0, 10),
|
||||
'targetFields' => $schema['targetFields'],
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
$results[] = [
|
||||
@@ -286,6 +300,7 @@ class BundleImportService {
|
||||
'fkWarnings' => [],
|
||||
'errorCount' => 1,
|
||||
'errors' => [['_rowIndex' => 0, '_errors' => [$e->getMessage()]]],
|
||||
'targetFields' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use OCA\Mitgliederverwaltung\Db\Permission;
|
||||
use OCA\Mitgliederverwaltung\Db\PermissionMapper;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\DB\Exception;
|
||||
use OCP\IGroupManager;
|
||||
|
||||
/**
|
||||
* Permission checking logic for all access levels.
|
||||
@@ -26,13 +27,16 @@ class PermissionService {
|
||||
|
||||
private PermissionMapper $permissionMapper;
|
||||
private MemberMapper $memberMapper;
|
||||
private IGroupManager $groupManager;
|
||||
|
||||
public function __construct(
|
||||
PermissionMapper $permissionMapper,
|
||||
MemberMapper $memberMapper
|
||||
MemberMapper $memberMapper,
|
||||
IGroupManager $groupManager
|
||||
) {
|
||||
$this->permissionMapper = $permissionMapper;
|
||||
$this->memberMapper = $memberMapper;
|
||||
$this->groupManager = $groupManager;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +58,9 @@ class PermissionService {
|
||||
* @throws Exception
|
||||
*/
|
||||
public function canAccess(string $userId): bool {
|
||||
if ($this->groupManager->isInGroup($userId, 'admin')) {
|
||||
return true;
|
||||
}
|
||||
$perm = $this->getUserPermission($userId);
|
||||
return $perm !== null && $perm->getLevel() !== 'none';
|
||||
}
|
||||
@@ -64,6 +71,9 @@ class PermissionService {
|
||||
* @throws Exception
|
||||
*/
|
||||
public function canRead(string $userId): bool {
|
||||
if ($this->groupManager->isInGroup($userId, 'admin')) {
|
||||
return true;
|
||||
}
|
||||
$perm = $this->getUserPermission($userId);
|
||||
if ($perm === null) {
|
||||
return false;
|
||||
@@ -79,6 +89,9 @@ class PermissionService {
|
||||
* @throws Exception
|
||||
*/
|
||||
public function canWrite(string $userId, ?int $memberId = null): bool {
|
||||
if ($this->groupManager->isInGroup($userId, 'admin')) {
|
||||
return true;
|
||||
}
|
||||
$perm = $this->getUserPermission($userId);
|
||||
if ($perm === null) {
|
||||
return false;
|
||||
@@ -136,7 +149,11 @@ class PermissionService {
|
||||
*/
|
||||
public function isAdmin(string $userId): bool {
|
||||
$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
|
||||
*/
|
||||
public function canSeeBanking(string $userId): bool {
|
||||
if ($this->groupManager->isInGroup($userId, 'admin')) {
|
||||
return true;
|
||||
}
|
||||
$perm = $this->getUserPermission($userId);
|
||||
return $perm !== null && $perm->getCanSeeBanking();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -65,6 +65,13 @@
|
||||
<ClipboardText :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Backup"
|
||||
:to="{ name: 'backup' }"
|
||||
:active="currentRoute === 'backup'">
|
||||
<template #icon>
|
||||
<BackupRestore :size="20" />
|
||||
</template>
|
||||
</NcAppNavigationItem>
|
||||
<NcAppNavigationItem name="Einstellungen"
|
||||
:to="{ name: '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 MedicalBag from 'vue-material-design-icons/MedicalBag.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'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -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
@@ -31,7 +31,7 @@ app.use(router)
|
||||
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
|
||||
// not via webpack DefinePlugin globals.
|
||||
app.provide('appName', 'mitgliederverwaltung')
|
||||
app.provide('appVersion', '0.1.5')
|
||||
app.provide('appVersion', '0.2.0')
|
||||
|
||||
app.mount('#mitgliederverwaltung')
|
||||
|
||||
|
||||
@@ -73,6 +73,11 @@ const routes = [
|
||||
name: 'settings',
|
||||
component: () => import('./views/Settings.vue'),
|
||||
},
|
||||
{
|
||||
path: '/backup',
|
||||
name: 'backup',
|
||||
component: () => import('./views/Backup.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -69,6 +69,8 @@ export const useImportStore = defineStore('import', {
|
||||
bundlePreviewResult: null,
|
||||
/** @type {Object|null} Bundle execution result */
|
||||
bundleExecuteResult: null,
|
||||
/** @type {Object} Per-entity corrections for bundle errors: { entityType: { rowIndex: { fieldKey: value } } } */
|
||||
bundleCorrections: {},
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -305,6 +307,7 @@ export const useImportStore = defineStore('import', {
|
||||
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/preview')
|
||||
const response = await axios.post(url, {
|
||||
content: this.fileContent,
|
||||
corrections: this.bundleCorrections,
|
||||
})
|
||||
|
||||
this.bundlePreviewResult = response.data
|
||||
@@ -328,6 +331,7 @@ export const useImportStore = defineStore('import', {
|
||||
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/execute')
|
||||
const response = await axios.post(url, {
|
||||
content: this.fileContent,
|
||||
corrections: this.bundleCorrections,
|
||||
})
|
||||
|
||||
this.bundleExecuteResult = response.data
|
||||
@@ -366,6 +370,7 @@ export const useImportStore = defineStore('import', {
|
||||
this.bundleAnalysis = null
|
||||
this.bundlePreviewResult = null
|
||||
this.bundleExecuteResult = null
|
||||
this.bundleCorrections = {}
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
+72
-204
@@ -1,6 +1,11 @@
|
||||
<template>
|
||||
<div class="audit-log">
|
||||
<div class="audit-log__header">
|
||||
<h2>Audit-Log</h2>
|
||||
<ColumnPicker :columns="allColumns"
|
||||
:model-value="visibleKeys"
|
||||
@update:model-value="setVisibleKeys" />
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="audit-log__filters">
|
||||
@@ -77,49 +82,39 @@
|
||||
<table v-else class="audit-log__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="audit-log__th">Zeitpunkt</th>
|
||||
<th class="audit-log__th">Benutzer</th>
|
||||
<th class="audit-log__th">Aktion</th>
|
||||
<th class="audit-log__th">Entität</th>
|
||||
<th class="audit-log__th">Feld</th>
|
||||
<th class="audit-log__th">Alter Wert</th>
|
||||
<th class="audit-log__th">Neuer Wert</th>
|
||||
<th v-for="col in visibleColumns" :key="col.key"
|
||||
class="audit-log__th">
|
||||
{{ col.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in store.entries"
|
||||
:key="entry.id"
|
||||
class="audit-log__row">
|
||||
<td class="audit-log__td audit-log__td--time">
|
||||
{{ formatDateTime(entry.zeitpunkt) }}
|
||||
</td>
|
||||
<td class="audit-log__td">
|
||||
{{ entry.ncUserId }}
|
||||
</td>
|
||||
<td class="audit-log__td">
|
||||
<td v-for="col in visibleColumns" :key="col.key"
|
||||
class="audit-log__td"
|
||||
:class="{
|
||||
'audit-log__td--time': col.key === 'zeitpunkt',
|
||||
'audit-log__td--value': col.key === 'alterWert' || col.key === 'neuerWert',
|
||||
}">
|
||||
<template v-if="col.key === 'aktion'">
|
||||
<span :class="'audit-log__action audit-log__action--' + entry.aktion">
|
||||
{{ formatAktion(entry.aktion) }}
|
||||
{{ col.render(entry) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="audit-log__td">
|
||||
</template>
|
||||
<template v-else-if="col.key === 'entitaet'">
|
||||
<span class="audit-log__entity-link"
|
||||
@click="navigateToEntity(entry.entitaet, entry.entitaetId)">
|
||||
{{ formatEntitaet(entry.entitaet) }}
|
||||
#{{ entry.entitaetId }}
|
||||
{{ col.render(entry) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="audit-log__td">
|
||||
{{ entry.feld || '—' }}
|
||||
</td>
|
||||
<td class="audit-log__td audit-log__td--value">
|
||||
<span :class="{ 'audit-log__encrypted': isEncrypted(entry.alterWert) }">
|
||||
{{ entry.alterWert || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="audit-log__td audit-log__td--value">
|
||||
<span :class="{ 'audit-log__encrypted': isEncrypted(entry.neuerWert) }">
|
||||
{{ entry.neuerWert || '—' }}
|
||||
</template>
|
||||
<template v-else-if="col.key === 'alterWert' || col.key === 'neuerWert'">
|
||||
<span :class="{ 'audit-log__encrypted': isEncrypted(col.render(entry)) }">
|
||||
{{ col.render(entry) }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>{{ col.render(entry) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -148,6 +143,8 @@ import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||
import { useAuditLogStore } from '../stores/auditlog.js'
|
||||
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||
import ColumnPicker from '../components/ColumnPicker.vue'
|
||||
import { formatDateTime } from '../utils/dateFormat.js'
|
||||
|
||||
const store = useAuditLogStore()
|
||||
@@ -155,6 +152,25 @@ const router = useRouter()
|
||||
|
||||
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(() => {
|
||||
store.fetchEntries()
|
||||
})
|
||||
@@ -171,40 +187,12 @@ function reload() {
|
||||
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) {
|
||||
return value === '[verschluesselt]'
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the referenced entity.
|
||||
*/
|
||||
function navigateToEntity(entitaet, entitaetId) {
|
||||
if (!entitaetId) return
|
||||
|
||||
switch (entitaet) {
|
||||
case 'member':
|
||||
router.push({ name: 'member-detail', params: { id: entitaetId } })
|
||||
@@ -213,155 +201,35 @@ function navigateToEntity(entitaet, entitaetId) {
|
||||
router.push({ name: 'family-detail', params: { id: entitaetId } })
|
||||
break
|
||||
default:
|
||||
// No navigation for other entity types yet
|
||||
break
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.audit-log {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.audit-log h2 {
|
||||
margin: 0 0 20px 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__filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
.audit-log { 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__filter { display: flex; flex-direction: column; gap: 4px; }
|
||||
.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>
|
||||
|
||||
@@ -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
@@ -9,6 +9,9 @@
|
||||
trailing-button-icon="close"
|
||||
@trailing-button-click="clearSearch"
|
||||
@update:model-value="onSearch" />
|
||||
<ColumnPicker :columns="allColumns"
|
||||
:model-value="visibleKeys"
|
||||
@update:model-value="setVisibleKeys" />
|
||||
<NcButton type="primary"
|
||||
@click="$router.push({ name: 'family-detail', params: { id: 'new' } })">
|
||||
<template #icon>
|
||||
@@ -59,16 +62,15 @@
|
||||
<table v-else class="family-list__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="family-list__th family-list__th--sortable"
|
||||
@click="toggleSort('name')">
|
||||
Familienname
|
||||
<SortIcon :field="'name'" :current-sort="sortField" :sort-asc="sortAsc" />
|
||||
</th>
|
||||
<th class="family-list__th">
|
||||
Mitglieder
|
||||
</th>
|
||||
<th class="family-list__th">
|
||||
Ansprechpartner
|
||||
<th v-for="col in visibleColumns" :key="col.key"
|
||||
class="family-list__th"
|
||||
:class="{ 'family-list__th--sortable': col.sortable }"
|
||||
@click="col.sortable && toggleSort(col.sortField || col.key)">
|
||||
{{ col.label }}
|
||||
<SortIcon v-if="col.sortable"
|
||||
:field="col.sortField || col.key"
|
||||
:current-sort="sortField"
|
||||
:sort-asc="sortAsc" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -77,17 +79,9 @@
|
||||
:key="family.id"
|
||||
class="family-list__row"
|
||||
@click="$router.push({ name: 'family-detail', params: { id: family.id } })">
|
||||
<td class="family-list__td">
|
||||
{{ family.name }}
|
||||
</td>
|
||||
<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 v-for="col in visibleColumns" :key="col.key"
|
||||
class="family-list__td">
|
||||
{{ col.render(family) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -115,6 +109,8 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||
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 AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
|
||||
@@ -127,6 +123,27 @@ const sortField = ref('name')
|
||||
const sortAsc = ref(true)
|
||||
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(() => {
|
||||
store.fetchFamilies()
|
||||
})
|
||||
@@ -181,105 +198,21 @@ function reload() {
|
||||
store.clearError()
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.family-list {
|
||||
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__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);
|
||||
}
|
||||
.family-list { 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__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__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>
|
||||
|
||||
+111
-339
@@ -18,6 +18,10 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ColumnPicker :columns="allColumns"
|
||||
:model-value="visibleKeys"
|
||||
@update:model-value="setVisibleKeys" />
|
||||
|
||||
<!-- Batch calculate -->
|
||||
<NcButton type="primary"
|
||||
:disabled="feesStore.loading"
|
||||
@@ -105,54 +109,56 @@
|
||||
<table v-else class="fee-overview__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="fee-overview__th">Mitglied</th>
|
||||
<th class="fee-overview__th">Betrag</th>
|
||||
<th class="fee-overview__th">Bezahlt</th>
|
||||
<th class="fee-overview__th">Zahlungsdatum</th>
|
||||
<th class="fee-overview__th">Manuell</th>
|
||||
<th class="fee-overview__th">Notizen</th>
|
||||
<th class="fee-overview__th">Aktionen</th>
|
||||
<th v-for="col in visibleColumns" :key="col.key"
|
||||
class="fee-overview__th">
|
||||
{{ col.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in feesStore.records"
|
||||
:key="record.id"
|
||||
:class="{ 'fee-overview__row--manual': record.manuellAngepasst }">
|
||||
<td class="fee-overview__td">
|
||||
<td v-for="col in visibleColumns" :key="col.key"
|
||||
class="fee-overview__td"
|
||||
:class="{
|
||||
'fee-overview__td--amount': col.key === 'amount',
|
||||
'fee-overview__td--notes': col.key === 'notes',
|
||||
'fee-overview__td--actions': col.key === 'aktionen',
|
||||
}">
|
||||
<!-- Mitglied column with link -->
|
||||
<template v-if="col.key === 'mitglied'">
|
||||
<router-link :to="{ name: 'member-detail', params: { id: record.memberId } }"
|
||||
class="fee-overview__member-link">
|
||||
{{ getMemberName(record.memberId) }}
|
||||
{{ col.render(record) }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="fee-overview__td fee-overview__td--amount">
|
||||
</template>
|
||||
<!-- Amount with inline editing -->
|
||||
<template v-else-if="col.key === 'amount'">
|
||||
<template v-if="editingRecord === record.id">
|
||||
<input v-model="editAmount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
type="number" min="0" step="0.01"
|
||||
class="fee-overview__inline-input"
|
||||
@keyup.enter="saveOverride(record.id)"
|
||||
@keyup.escape="cancelEdit">
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatCurrency(parseFloat(record.amount)) }}
|
||||
<template v-else>{{ col.render(record) }}</template>
|
||||
</template>
|
||||
</td>
|
||||
<td class="fee-overview__td">
|
||||
<!-- Bezahlt badge -->
|
||||
<template v-else-if="col.key === 'paid'">
|
||||
<span :class="record.paid ? 'fee-overview__badge--paid' : 'fee-overview__badge--unpaid'"
|
||||
class="fee-overview__badge">
|
||||
{{ record.paid ? 'Ja' : 'Nein' }}
|
||||
{{ col.render(record) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="fee-overview__td">
|
||||
{{ formatDate(record.paymentDate) }}
|
||||
</td>
|
||||
<td class="fee-overview__td">
|
||||
</template>
|
||||
<!-- Manuell badge -->
|
||||
<template v-else-if="col.key === 'manuell'">
|
||||
<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>
|
||||
<!-- Notes with inline editing -->
|
||||
<template v-else-if="col.key === 'notes'">
|
||||
<template v-if="editingNotes === record.id">
|
||||
<input v-model="editNotesText"
|
||||
type="text"
|
||||
@@ -161,41 +167,26 @@
|
||||
@keyup.enter="saveNotes(record.id)"
|
||||
@keyup.escape="cancelNotesEdit">
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ record.notes || '--' }}
|
||||
<template v-else>{{ col.render(record) }}</template>
|
||||
</template>
|
||||
</td>
|
||||
<td class="fee-overview__td fee-overview__td--actions">
|
||||
<!-- 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>
|
||||
<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>
|
||||
<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>
|
||||
<NcButton type="primary" @click="saveNotes(record.id)">Speichern</NcButton>
|
||||
<NcButton @click="cancelNotesEdit">Abbrechen</NcButton>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Default -->
|
||||
<template v-else>{{ col.render(record) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -208,14 +199,13 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { NcButton, NcLoadingIcon, NcEmptyContent } from '@nextcloud/vue'
|
||||
import { useFeesStore } from '../stores/fees.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'
|
||||
|
||||
const feesStore = useFeesStore()
|
||||
const membersStore = useMembersStore()
|
||||
|
||||
/**
|
||||
* Build a lookup map from member ID to "Nachname, Vorname".
|
||||
*/
|
||||
const memberNameMap = computed(() => {
|
||||
const map = {}
|
||||
for (const m of membersStore.members) {
|
||||
@@ -228,6 +218,27 @@ function getMemberName(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 editingRecord = ref(null)
|
||||
const editAmount = ref('')
|
||||
@@ -235,7 +246,6 @@ const editingNotes = ref(null)
|
||||
const editNotesText = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
// Fetch all members for the name lookup (not just first page)
|
||||
const savedLimit = membersStore.limit
|
||||
membersStore.limit = 9999
|
||||
await Promise.all([
|
||||
@@ -246,40 +256,23 @@ onMounted(async () => {
|
||||
membersStore.limit = savedLimit
|
||||
})
|
||||
|
||||
// ── Year change ─────────────────────────────────────────────────────
|
||||
|
||||
function onYearChange(year) {
|
||||
feesStore.fetchRecords(parseInt(year))
|
||||
}
|
||||
|
||||
// ── Batch calculate ─────────────────────────────────────────────────
|
||||
|
||||
async function executeBatchCalculate() {
|
||||
try {
|
||||
await feesStore.batchCalculate(feesStore.selectedYear)
|
||||
} catch {
|
||||
// Error displayed by store
|
||||
}
|
||||
try { await feesStore.batchCalculate(feesStore.selectedYear) } catch { /* store shows error */ }
|
||||
showBatchConfirm.value = false
|
||||
}
|
||||
|
||||
// ── Mark as paid ────────────────────────────────────────────────────
|
||||
|
||||
async function markPaid(recordId) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
try {
|
||||
await feesStore.markAsPaid(recordId, today)
|
||||
} catch {
|
||||
// Error displayed by store
|
||||
try { await feesStore.markAsPaid(recordId, today) } catch { /* store shows error */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manual override ─────────────────────────────────────────────────
|
||||
|
||||
function startEdit(record) {
|
||||
editingRecord.value = record.id
|
||||
editAmount.value = record.amount || ''
|
||||
// Cancel any notes editing
|
||||
editingNotes.value = null
|
||||
}
|
||||
|
||||
@@ -290,22 +283,14 @@ function cancelEdit() {
|
||||
|
||||
async function saveOverride(recordId) {
|
||||
if (!editAmount.value || parseFloat(editAmount.value) < 0) return
|
||||
|
||||
try {
|
||||
await feesStore.manualOverride(recordId, String(editAmount.value), null)
|
||||
} catch {
|
||||
// Error displayed by store
|
||||
}
|
||||
try { await feesStore.manualOverride(recordId, String(editAmount.value), null) } catch { /* store shows error */ }
|
||||
editingRecord.value = null
|
||||
editAmount.value = ''
|
||||
}
|
||||
|
||||
// ── Notes editing ───────────────────────────────────────────────────
|
||||
|
||||
function startNotesEdit(record) {
|
||||
editingNotes.value = record.id
|
||||
editNotesText.value = record.notes || ''
|
||||
// Cancel any amount editing
|
||||
editingRecord.value = null
|
||||
}
|
||||
|
||||
@@ -319,263 +304,50 @@ async function saveNotes(recordId) {
|
||||
const record = feesStore.records.find(r => r.id === recordId)
|
||||
const amount = record ? record.amount : '0'
|
||||
await feesStore.manualOverride(recordId, amount, editNotesText.value)
|
||||
} catch {
|
||||
// Error displayed by store
|
||||
}
|
||||
} catch { /* store shows error */ }
|
||||
editingNotes.value = null
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.fee-overview {
|
||||
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__year-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fee-overview__year-selector label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
.fee-overview { 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__year-selector { display: flex; align-items: center; gap: 6px; }
|
||||
.fee-overview__year-selector label { font-weight: 500; }
|
||||
.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); }
|
||||
.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); }
|
||||
.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; }
|
||||
.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); }
|
||||
.fee-overview__loading { display: flex; justify-content: center; margin-top: 60px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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>
|
||||
|
||||
+87
-5
@@ -367,15 +367,53 @@
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
class="import-wizard__error-row">
|
||||
<h4>Fehler -- Daten korrigieren</h4>
|
||||
<p class="import-wizard__hint">
|
||||
Klicke auf eine Zeile, um die Daten zu bearbeiten und fehlende Felder zu ergaenzen.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
<ul>
|
||||
<li v-for="(msg, mIdx) in err._errors" :key="mIdx">{{ msg }}</li>
|
||||
</ul>
|
||||
<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 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">
|
||||
<NcButton @click="importStore.step = 2">Zurueck</NcButton>
|
||||
@@ -715,6 +753,50 @@ function hasCorrections(rowIndex) {
|
||||
const hasCorrectedRows = computed(() => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+31
-20
@@ -15,6 +15,9 @@
|
||||
placeholder="Bis"
|
||||
@change="doFilter">
|
||||
</div>
|
||||
<ColumnPicker :columns="allColumns"
|
||||
:model-value="visibleKeys"
|
||||
@update:model-value="setVisibleKeys" />
|
||||
<NcButton type="primary" @click="showCreate = true">
|
||||
Neue Verletzung
|
||||
</NcButton>
|
||||
@@ -42,40 +45,33 @@
|
||||
<table v-else class="injury-list__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Mitglied</th>
|
||||
<th>Lager / Aktivität</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Beteiligte</th>
|
||||
<th>Aktionen</th>
|
||||
<th v-for="col in visibleColumns" :key="col.key">{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="injury in injuryStore.injuries"
|
||||
:key="injury.id"
|
||||
class="injury-list__row">
|
||||
<td>{{ formatDate(injury.datum) }}</td>
|
||||
<td>
|
||||
<td v-for="col in visibleColumns" :key="col.key"
|
||||
:class="{ 'injury-list__beschreibung': col.key === 'beschreibung' }">
|
||||
<template v-if="col.key === 'mitglied'">
|
||||
<router-link :to="{ name: 'member-detail', params: { id: injury.memberId } }">
|
||||
{{ injury.memberVorname }} {{ injury.memberNachname }}
|
||||
{{ col.render(injury) }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ injury.lagerName || injury.activityName || '-' }}</td>
|
||||
<td class="injury-list__beschreibung">
|
||||
{{ truncate(injury.beschreibung, 80) }}
|
||||
</td>
|
||||
<td>{{ injury.involvedMembers?.length || 0 }}</td>
|
||||
<td>
|
||||
</template>
|
||||
<template v-else-if="col.key === 'aktionen'">
|
||||
<NcButton @click="editInjury(injury)">
|
||||
Bearbeiten
|
||||
</NcButton>
|
||||
<NcButton @click="confirmDelete(injury)">
|
||||
Löschen
|
||||
</NcButton>
|
||||
</template>
|
||||
<template v-else>{{ col.render(injury) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
<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
|
||||
</td>
|
||||
</tr>
|
||||
@@ -94,6 +90,8 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { NcButton } from '@nextcloud/vue'
|
||||
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 { formatDate } from '../utils/dateFormat.js'
|
||||
|
||||
@@ -104,13 +102,26 @@ const editingInjury = ref(null)
|
||||
const filterDateFrom = ref('')
|
||||
const filterDateTo = ref('')
|
||||
|
||||
// formatDate imported from utils/dateFormat.js
|
||||
|
||||
function truncate(text, maxLen) {
|
||||
if (!text) return ''
|
||||
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() {
|
||||
const params = {}
|
||||
if (filterDateFrom.value) params.dateFrom = filterDateFrom.value
|
||||
@@ -137,7 +148,7 @@ async function handleSubmit(data) {
|
||||
}
|
||||
closeForm()
|
||||
injuryStore.fetchInjuries()
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
}
|
||||
|
||||
+28
-16
@@ -27,6 +27,9 @@
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<ColumnPicker :columns="allColumns"
|
||||
:model-value="visibleKeys"
|
||||
@update:model-value="setVisibleKeys" />
|
||||
<NcButton type="primary" @click="showCreate = true">
|
||||
Neues Lager
|
||||
</NcButton>
|
||||
@@ -48,12 +51,7 @@
|
||||
<table v-else class="lager-list__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Startdatum</th>
|
||||
<th>Enddatum</th>
|
||||
<th>Ort</th>
|
||||
<th>Teilnehmer</th>
|
||||
<th>Aktionen</th>
|
||||
<th v-for="col in visibleColumns" :key="col.key">{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -61,19 +59,17 @@
|
||||
:key="camp.id"
|
||||
class="lager-list__row"
|
||||
@click="$router.push({ name: 'lager-detail', params: { id: camp.id } })">
|
||||
<td>{{ camp.name }}</td>
|
||||
<td>{{ formatDate(camp.startdatum) }}</td>
|
||||
<td>{{ formatDate(camp.enddatum) }}</td>
|
||||
<td>{{ camp.ort || '-' }}</td>
|
||||
<td>{{ camp.teilnehmer?.length || 0 }}</td>
|
||||
<td>
|
||||
<td v-for="col in visibleColumns" :key="col.key">
|
||||
<template v-if="col.key === 'aktionen'">
|
||||
<NcButton @click.stop="confirmDelete(camp)">
|
||||
Löschen
|
||||
</NcButton>
|
||||
</template>
|
||||
<template v-else>{{ col.render(camp) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
<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
|
||||
</td>
|
||||
</tr>
|
||||
@@ -121,6 +117,8 @@ import { ref, onMounted } from 'vue'
|
||||
import { NcButton } from '@nextcloud/vue'
|
||||
import { useLagerStore } from '../stores/lager.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'
|
||||
|
||||
const lagerStore = useLagerStore()
|
||||
@@ -129,7 +127,21 @@ const stufenStore = useStufenStore()
|
||||
const showCreate = ref(false)
|
||||
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) {
|
||||
lagerStore.filterYear = val ? parseInt(val) : null
|
||||
@@ -143,10 +155,10 @@ function onStufeChange(val) {
|
||||
|
||||
async function doCreate() {
|
||||
try {
|
||||
const camp = await lagerStore.createCamp(newCamp.value)
|
||||
await lagerStore.createCamp(newCamp.value)
|
||||
showCreate.value = false
|
||||
newCamp.value = { name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' }
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Error handled by store
|
||||
}
|
||||
}
|
||||
|
||||
+113
-240
@@ -3,6 +3,9 @@
|
||||
<div class="member-list__header">
|
||||
<h2>Mitglieder</h2>
|
||||
<div class="member-list__actions">
|
||||
<ColumnPicker :columns="allColumns"
|
||||
:model-value="visibleKeys"
|
||||
@update:model-value="setVisibleKeys" />
|
||||
<NcButton type="primary"
|
||||
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
|
||||
<template #icon>
|
||||
@@ -63,31 +66,15 @@
|
||||
<table v-else class="member-list__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="member-list__th member-list__th--sortable"
|
||||
@click="toggleSort('nachname')">
|
||||
Name
|
||||
<SortIcon :field="'nachname'" :current-sort="sortField" :sort-asc="sortAsc" />
|
||||
</th>
|
||||
<th class="member-list__th member-list__th--sortable"
|
||||
@click="toggleSort('stufeId')">
|
||||
Stufe
|
||||
<SortIcon :field="'stufeId'" :current-sort="sortField" :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 v-for="col in visibleColumns" :key="col.key"
|
||||
class="member-list__th"
|
||||
:class="{ 'member-list__th--sortable': col.sortable }"
|
||||
@click="col.sortable && toggleSort(col.sortField || col.key)">
|
||||
{{ col.label }}
|
||||
<SortIcon v-if="col.sortable"
|
||||
:field="col.sortField || col.key"
|
||||
:current-sort="sortField"
|
||||
:sort-asc="sortAsc" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -96,25 +83,13 @@
|
||||
:key="member.id"
|
||||
class="member-list__row"
|
||||
@click="$router.push({ name: 'member-detail', params: { id: member.id } })">
|
||||
<td class="member-list__td">
|
||||
{{ member.nachname }}, {{ member.vorname }}
|
||||
</td>
|
||||
<td class="member-list__td">
|
||||
{{ getStufeNameById(member.stufeId) }}
|
||||
</td>
|
||||
<td class="member-list__td">
|
||||
<span :class="'member-list__status member-list__status--' + member.status">
|
||||
{{ formatStatus(member.status) }}
|
||||
<td v-for="col in visibleColumns" :key="col.key"
|
||||
class="member-list__td">
|
||||
<span v-if="col.key === 'status'"
|
||||
:class="'member-list__status member-list__status--' + member.status">
|
||||
{{ col.render(member) }}
|
||||
</span>
|
||||
</td>
|
||||
<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) }}
|
||||
<template v-else>{{ col.render(member) }}</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -143,6 +118,8 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||
import { useMembersStore } from '../stores/members.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 AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
|
||||
@@ -155,9 +132,74 @@ const stufenStore = useStufenStore()
|
||||
const sortField = ref('nachname')
|
||||
const sortAsc = ref(true)
|
||||
|
||||
/**
|
||||
* Preset filter definitions (Issue #34).
|
||||
*/
|
||||
// ── Column definitions ──
|
||||
|
||||
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 = [
|
||||
{ key: null, label: 'Alle' },
|
||||
{ key: 'aktiv', label: 'Aktive' },
|
||||
@@ -171,17 +213,12 @@ onMounted(() => {
|
||||
stufenStore.fetchStufen()
|
||||
})
|
||||
|
||||
/**
|
||||
* Apply a preset filter.
|
||||
*/
|
||||
function applyFilter(filterKey) {
|
||||
searchQuery.value = ''
|
||||
store.fetchWithFilter(filterKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort members locally by the selected field.
|
||||
*/
|
||||
// ── Sorting ──
|
||||
|
||||
const sortedMembers = computed(() => {
|
||||
const members = [...store.members]
|
||||
const field = sortField.value
|
||||
@@ -191,9 +228,7 @@ const sortedMembers = computed(() => {
|
||||
let valA = a[field] ?? ''
|
||||
let valB = b[field] ?? ''
|
||||
|
||||
// Special handling for age sorting (by geburtsdatum, reversed)
|
||||
if (field === 'geburtsdatum') {
|
||||
// Older birthday = older person = higher age
|
||||
return asc
|
||||
? String(valA).localeCompare(String(valB))
|
||||
: String(valB).localeCompare(String(valA))
|
||||
@@ -223,192 +258,30 @@ function reload() {
|
||||
store.clearError()
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
.member-list {
|
||||
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__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);
|
||||
}
|
||||
.member-list { 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__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); }
|
||||
.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>
|
||||
|
||||
+41
-15
@@ -84,15 +84,16 @@
|
||||
name="Keine Ergebnisse"
|
||||
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>
|
||||
<tr>
|
||||
<th>Nachname</th>
|
||||
<th>Vorname</th>
|
||||
<th>Geburtsdatum</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th>Eintritt</th>
|
||||
<th v-for="col in visibleResultColumns" :key="col.key">{{ col.label }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -100,19 +101,17 @@
|
||||
:key="member.id"
|
||||
class="query-view__result-row"
|
||||
@click="openMember(member.id)">
|
||||
<td>{{ member.nachname }}</td>
|
||||
<td>{{ member.vorname }}</td>
|
||||
<td>{{ formatDate(member.geburtsdatum) }}</td>
|
||||
<td>{{ member.rolle }}</td>
|
||||
<td>
|
||||
<span :class="'query-view__status query-view__status--' + member.status">
|
||||
{{ member.status }}
|
||||
<td v-for="col in visibleResultColumns" :key="col.key">
|
||||
<span v-if="col.key === 'status'"
|
||||
:class="'query-view__status query-view__status--' + member.status">
|
||||
{{ col.render(member) }}
|
||||
</span>
|
||||
<template v-else>{{ col.render(member) }}</template>
|
||||
</td>
|
||||
<td>{{ formatDate(member.eintritt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,6 +123,8 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||
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 Delete from 'vue-material-design-icons/Delete.vue'
|
||||
import Play from 'vue-material-design-icons/Play.vue'
|
||||
@@ -139,6 +140,25 @@ const showSaveDialog = ref(false)
|
||||
const saveName = ref('')
|
||||
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 () => {
|
||||
await Promise.all([
|
||||
store.fetchFields(),
|
||||
@@ -324,6 +344,12 @@ function openMember(id) {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.query-view__results-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.query-view__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
@@ -11,6 +11,7 @@ use OCA\Mitgliederverwaltung\Db\PermissionMapper;
|
||||
use OCA\Mitgliederverwaltung\Service\PermissionService;
|
||||
use OCA\Mitgliederverwaltung\Service\ValidationException;
|
||||
use OCP\AppFramework\Db\DoesNotExistException;
|
||||
use OCP\IGroupManager;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -19,14 +20,19 @@ class PermissionServiceTest extends TestCase {
|
||||
private PermissionService $service;
|
||||
private PermissionMapper&MockObject $permissionMapper;
|
||||
private MemberMapper&MockObject $memberMapper;
|
||||
private IGroupManager&MockObject $groupManager;
|
||||
|
||||
protected function setUp(): void {
|
||||
$this->permissionMapper = $this->createMock(PermissionMapper::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->permissionMapper,
|
||||
$this->memberMapper
|
||||
$this->memberMapper,
|
||||
$this->groupManager
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -41,7 +41,7 @@ module.exports = {
|
||||
new VueLoaderPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
appName: JSON.stringify('mitgliederverwaltung'),
|
||||
appVersion: JSON.stringify('0.1.5'),
|
||||
appVersion: JSON.stringify('0.2.0'),
|
||||
}),
|
||||
new webpack.optimize.LimitChunkCountPlugin({
|
||||
maxChunks: 1,
|
||||
|
||||
Reference in New Issue
Block a user