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
|
# Test artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
.phpunit.cache/
|
||||||
screenshots/
|
screenshots/
|
||||||
test-results/
|
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)
|
# Install dependencies (composer via Docker since PHP may not be local)
|
||||||
deps:
|
deps:
|
||||||
@@ -70,6 +70,65 @@ down:
|
|||||||
logs:
|
logs:
|
||||||
docker compose logs -f nextcloud
|
docker compose logs -f nextcloud
|
||||||
|
|
||||||
|
# Create a distributable tar.gz for production installation
|
||||||
|
package: build
|
||||||
|
$(eval VERSION := $(shell grep '<version>' appinfo/info.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'))
|
||||||
|
@echo "Packaging mitgliederverwaltung v$(VERSION)..."
|
||||||
|
mkdir -p artifacts
|
||||||
|
rm -rf /tmp/mitgliederverwaltung
|
||||||
|
mkdir -p /tmp/mitgliederverwaltung
|
||||||
|
cp -a appinfo lib templates js vendor img /tmp/mitgliederverwaltung/
|
||||||
|
@for f in LICENSE README.md CHANGELOG.md; do \
|
||||||
|
[ -f "$$f" ] && cp "$$f" /tmp/mitgliederverwaltung/ || true; \
|
||||||
|
done
|
||||||
|
cd /tmp && tar -czf mitgliederverwaltung-$(VERSION).tar.gz mitgliederverwaltung
|
||||||
|
mv /tmp/mitgliederverwaltung-$(VERSION).tar.gz artifacts/
|
||||||
|
rm -rf /tmp/mitgliederverwaltung
|
||||||
|
@# Sign the tarball with Ed25519
|
||||||
|
@if [ -f "$$HOME/.mv-release-key" ]; then \
|
||||||
|
docker run --rm -v "$$(pwd):/app" -v "$$HOME/.mv-release-key:$$HOME/.mv-release-key:ro" -e HOME="$$HOME" -w /app php:8.1-cli php scripts/sign-release.php artifacts/mitgliederverwaltung-$(VERSION).tar.gz; \
|
||||||
|
else \
|
||||||
|
echo "WARNING: ~/.mv-release-key not found — tarball NOT signed."; \
|
||||||
|
echo " Run: php scripts/generate-keypair.php"; \
|
||||||
|
fi
|
||||||
|
@echo ""
|
||||||
|
@echo "Package created: artifacts/mitgliederverwaltung-$(VERSION).tar.gz"
|
||||||
|
@echo "Size: $$(du -h artifacts/mitgliederverwaltung-$(VERSION).tar.gz | cut -f1)"
|
||||||
|
|
||||||
|
# Build, package, tag, and create a Gitea release with the tarball attached
|
||||||
|
release: package
|
||||||
|
$(eval VERSION := $(shell grep '<version>' appinfo/info.xml | sed 's/.*<version>\(.*\)<\/version>.*/\1/'))
|
||||||
|
@echo "Creating release v$(VERSION) on Gitea..."
|
||||||
|
git tag -a "v$(VERSION)" -m "Release v$(VERSION)" 2>/dev/null || echo "Tag v$(VERSION) already exists, skipping"
|
||||||
|
git push origin "v$(VERSION)" 2>/dev/null || echo "Tag already pushed"
|
||||||
|
@# Create Gitea release via API
|
||||||
|
@RELEASE_ID=$$(curl -s -X POST \
|
||||||
|
"https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \
|
||||||
|
-d '{"tag_name": "v$(VERSION)", "name": "v$(VERSION)", "body": "Release v$(VERSION)", "draft": false, "prerelease": false}' \
|
||||||
|
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null) && \
|
||||||
|
if [ -n "$$RELEASE_ID" ] && [ "$$RELEASE_ID" != "" ]; then \
|
||||||
|
echo "Uploading tarball to release $$RELEASE_ID..."; \
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases/$$RELEASE_ID/assets?name=mitgliederverwaltung-$(VERSION).tar.gz" \
|
||||||
|
-H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \
|
||||||
|
-F "attachment=@artifacts/mitgliederverwaltung-$(VERSION).tar.gz" \
|
||||||
|
>/dev/null && echo " Tarball uploaded."; \
|
||||||
|
if [ -f "artifacts/mitgliederverwaltung-$(VERSION).tar.gz.sig" ]; then \
|
||||||
|
curl -s -X POST \
|
||||||
|
"https://git.shahondin1624.de/api/v1/repos/shahondin1624/Mitgliederverwaltung/releases/$$RELEASE_ID/assets?name=mitgliederverwaltung-$(VERSION).tar.gz.sig" \
|
||||||
|
-H "Authorization: token $$(cat ~/.gitea-token 2>/dev/null || echo '')" \
|
||||||
|
-F "attachment=@artifacts/mitgliederverwaltung-$(VERSION).tar.gz.sig" \
|
||||||
|
>/dev/null && echo " Signature uploaded."; \
|
||||||
|
else \
|
||||||
|
echo " WARNING: No .sig file found — release is UNSIGNED."; \
|
||||||
|
fi; \
|
||||||
|
echo "Release v$(VERSION) created."; \
|
||||||
|
else \
|
||||||
|
echo "WARNING: Could not create Gitea release (missing ~/.gitea-token?). Tarball is in artifacts/."; \
|
||||||
|
fi
|
||||||
|
|
||||||
# Remove volumes (full reset)
|
# Remove volumes (full reset)
|
||||||
clean:
|
clean:
|
||||||
docker compose down -v
|
docker compose down -v
|
||||||
|
|||||||
+8
-1
@@ -5,7 +5,7 @@
|
|||||||
<name>Mitgliederverwaltung</name>
|
<name>Mitgliederverwaltung</name>
|
||||||
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
|
<summary>Mitgliederverwaltung für Pfadfindervereine</summary>
|
||||||
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
|
<description><![CDATA[Verwaltung von Mitgliedern, Familien, Beiträgen, Lagern und mehr für Pfadfindervereine. Integriert sich in Nextcloud Kalender, Kontakte und Dateien.]]></description>
|
||||||
<version>0.1.5</version>
|
<version>0.2.0</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>shahondin1624</author>
|
<author>shahondin1624</author>
|
||||||
<namespace>Mitgliederverwaltung</namespace>
|
<namespace>Mitgliederverwaltung</namespace>
|
||||||
@@ -19,7 +19,14 @@
|
|||||||
<job>OCA\Mitgliederverwaltung\BackgroundJob\SyncQueueJob</job>
|
<job>OCA\Mitgliederverwaltung\BackgroundJob\SyncQueueJob</job>
|
||||||
<job>OCA\Mitgliederverwaltung\BackgroundJob\CalendarFullSyncJob</job>
|
<job>OCA\Mitgliederverwaltung\BackgroundJob\CalendarFullSyncJob</job>
|
||||||
<job>OCA\Mitgliederverwaltung\BackgroundJob\ContactsFullSyncJob</job>
|
<job>OCA\Mitgliederverwaltung\BackgroundJob\ContactsFullSyncJob</job>
|
||||||
|
<job>OCA\Mitgliederverwaltung\BackgroundJob\BackupJob</job>
|
||||||
</background-jobs>
|
</background-jobs>
|
||||||
|
<commands>
|
||||||
|
<command>OCA\Mitgliederverwaltung\Command\BackupCreateCommand</command>
|
||||||
|
<command>OCA\Mitgliederverwaltung\Command\BackupRestoreCommand</command>
|
||||||
|
<command>OCA\Mitgliederverwaltung\Command\BackupListCommand</command>
|
||||||
|
<command>OCA\Mitgliederverwaltung\Command\AppUpdateCommand</command>
|
||||||
|
</commands>
|
||||||
<navigations>
|
<navigations>
|
||||||
<navigation>
|
<navigation>
|
||||||
<name>Mitgliederverwaltung</name>
|
<name>Mitgliederverwaltung</name>
|
||||||
|
|||||||
@@ -158,6 +158,20 @@ return [
|
|||||||
['name' => 'query#destroy', 'url' => '/api/v1/queries/{id}', 'verb' => 'DELETE'],
|
['name' => 'query#destroy', 'url' => '/api/v1/queries/{id}', 'verb' => 'DELETE'],
|
||||||
['name' => 'query#executeSaved', 'url' => '/api/v1/queries/{id}/execute', 'verb' => 'POST'],
|
['name' => 'query#executeSaved', 'url' => '/api/v1/queries/{id}/execute', 'verb' => 'POST'],
|
||||||
|
|
||||||
|
// ── Backup & Restore (admin-only) ─────────────────────────
|
||||||
|
['name' => 'backup#getSettings', 'url' => '/api/v1/backups/settings', 'verb' => 'GET'],
|
||||||
|
['name' => 'backup#updateSettings', 'url' => '/api/v1/backups/settings', 'verb' => 'PUT'],
|
||||||
|
['name' => 'backup#index', 'url' => '/api/v1/backups', 'verb' => 'GET'],
|
||||||
|
['name' => 'backup#create', 'url' => '/api/v1/backups', 'verb' => 'POST'],
|
||||||
|
['name' => 'backup#show', 'url' => '/api/v1/backups/{filename}', 'verb' => 'GET'],
|
||||||
|
['name' => 'backup#download', 'url' => '/api/v1/backups/{filename}/download', 'verb' => 'GET'],
|
||||||
|
['name' => 'backup#restore', 'url' => '/api/v1/backups/{filename}/restore', 'verb' => 'POST'],
|
||||||
|
['name' => 'backup#destroy', 'url' => '/api/v1/backups/{filename}', 'verb' => 'DELETE'],
|
||||||
|
|
||||||
|
// ── Self-update (admin-only) ───────────────────────────────
|
||||||
|
['name' => 'backup#checkUpdate', 'url' => '/api/v1/update/check', 'verb' => 'GET'],
|
||||||
|
['name' => 'backup#installUpdate', 'url' => '/api/v1/update/install', 'verb' => 'POST'],
|
||||||
|
|
||||||
// ── Files integration (NC Files browser) ────────────────────
|
// ── Files integration (NC Files browser) ────────────────────
|
||||||
['name' => 'file#getSettings', 'url' => '/api/v1/files/settings', 'verb' => 'GET'],
|
['name' => 'file#getSettings', 'url' => '/api/v1/files/settings', 'verb' => 'GET'],
|
||||||
['name' => 'file#updateSettings', 'url' => '/api/v1/files/settings', 'verb' => 'PUT'],
|
['name' => 'file#updateSettings', 'url' => '/api/v1/files/settings', 'verb' => 'PUT'],
|
||||||
|
|||||||
@@ -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);
|
return new JSONResponse($result);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error('Failed to preview bundle', [
|
$this->logger->error('Failed to preview bundle', [
|
||||||
@@ -574,8 +576,9 @@ class ImportController extends ApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$overrides = $data['overrides'] ?? [];
|
$overrides = $data['overrides'] ?? [];
|
||||||
|
$corrections = $data['corrections'] ?? [];
|
||||||
|
|
||||||
$result = $this->bundleImportService->executeBundle($decoded, $overrides);
|
$result = $this->bundleImportService->executeBundle($decoded, $overrides, $corrections);
|
||||||
return new JSONResponse($result);
|
return new JSONResponse($result);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error('Failed to execute bundle import', [
|
$this->logger->error('Failed to execute bundle import', [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace OCA\Mitgliederverwaltung\Middleware;
|
namespace OCA\Mitgliederverwaltung\Middleware;
|
||||||
|
|
||||||
use OCA\Mitgliederverwaltung\Controller\AuditController;
|
use OCA\Mitgliederverwaltung\Controller\AuditController;
|
||||||
|
use OCA\Mitgliederverwaltung\Controller\BackupController;
|
||||||
use OCA\Mitgliederverwaltung\Controller\DsgvoController;
|
use OCA\Mitgliederverwaltung\Controller\DsgvoController;
|
||||||
use OCA\Mitgliederverwaltung\Controller\ExportController;
|
use OCA\Mitgliederverwaltung\Controller\ExportController;
|
||||||
use OCA\Mitgliederverwaltung\Controller\FeeController;
|
use OCA\Mitgliederverwaltung\Controller\FeeController;
|
||||||
@@ -89,6 +90,7 @@ class AuthorizationMiddleware extends Middleware {
|
|||||||
|| $controller instanceof PermissionController
|
|| $controller instanceof PermissionController
|
||||||
|| $controller instanceof AuditController
|
|| $controller instanceof AuditController
|
||||||
|| $controller instanceof DsgvoController
|
|| $controller instanceof DsgvoController
|
||||||
|
|| $controller instanceof BackupController
|
||||||
) {
|
) {
|
||||||
if (!$this->permissionService->isAdmin($userId)) {
|
if (!$this->permissionService->isAdmin($userId)) {
|
||||||
throw new \RuntimeException('Authorization: admin required');
|
throw new \RuntimeException('Authorization: admin required');
|
||||||
|
|||||||
@@ -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}
|
* @param array $entityOverrides Optional type overrides: {filename: type}
|
||||||
* @return array{results: array[], idMap: array}
|
* @return array{results: array[], idMap: array}
|
||||||
*/
|
*/
|
||||||
public function executeBundle(string $zipContent, array $entityOverrides = []): array {
|
public function executeBundle(string $zipContent, array $entityOverrides = [], array $corrections = []): array {
|
||||||
$csvFiles = $this->extractCsvFiles($zipContent);
|
$csvFiles = $this->extractCsvFiles($zipContent);
|
||||||
$analysis = $this->analyzeBundle($zipContent);
|
$analysis = $this->analyzeBundle($zipContent);
|
||||||
|
|
||||||
@@ -188,12 +188,16 @@ class BundleImportService {
|
|||||||
$parsed = $this->entityImportService->parseFile($remappedContent, ';', 'UTF-8');
|
$parsed = $this->entityImportService->parseFile($remappedContent, ';', 'UTF-8');
|
||||||
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
|
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
|
||||||
|
|
||||||
|
$entityCorrections = $corrections[$type] ?? [];
|
||||||
|
|
||||||
$result = $this->entityImportService->execute(
|
$result = $this->entityImportService->execute(
|
||||||
$type,
|
$type,
|
||||||
$remappedContent,
|
$remappedContent,
|
||||||
$mapping,
|
$mapping,
|
||||||
';',
|
';',
|
||||||
'UTF-8'
|
'UTF-8',
|
||||||
|
false,
|
||||||
|
$entityCorrections
|
||||||
);
|
);
|
||||||
|
|
||||||
// Collect ID mappings from created entities
|
// Collect ID mappings from created entities
|
||||||
@@ -236,9 +240,11 @@ class BundleImportService {
|
|||||||
/**
|
/**
|
||||||
* Preview bundle import (dry-run for all entities).
|
* Preview bundle import (dry-run for all entities).
|
||||||
*
|
*
|
||||||
|
* @param string $zipContent Raw ZIP binary content
|
||||||
|
* @param array $corrections Per-entity corrections: { entityType: { rowNum: { fieldKey: value } } }
|
||||||
* @return array{results: array[]}
|
* @return array{results: array[]}
|
||||||
*/
|
*/
|
||||||
public function previewBundle(string $zipContent): array {
|
public function previewBundle(string $zipContent, array $corrections = []): array {
|
||||||
$csvFiles = $this->extractCsvFiles($zipContent);
|
$csvFiles = $this->extractCsvFiles($zipContent);
|
||||||
$analysis = $this->analyzeBundle($zipContent);
|
$analysis = $this->analyzeBundle($zipContent);
|
||||||
$results = [];
|
$results = [];
|
||||||
@@ -262,14 +268,21 @@ class BundleImportService {
|
|||||||
$parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8');
|
$parsed = $this->entityImportService->parseFile($content, ';', 'UTF-8');
|
||||||
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
|
$mapping = $this->entityImportService->autoMapHeaders($type, $parsed['columns']);
|
||||||
|
|
||||||
|
$entityCorrections = $corrections[$type] ?? [];
|
||||||
|
|
||||||
$preview = $this->entityImportService->preview(
|
$preview = $this->entityImportService->preview(
|
||||||
$type,
|
$type,
|
||||||
$content,
|
$content,
|
||||||
$mapping,
|
$mapping,
|
||||||
';',
|
';',
|
||||||
'UTF-8'
|
'UTF-8',
|
||||||
|
false,
|
||||||
|
$entityCorrections
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Include target fields so the frontend can render editable inputs
|
||||||
|
$schema = $this->entityImportService->getImportSchema($type);
|
||||||
|
|
||||||
$results[] = [
|
$results[] = [
|
||||||
'type' => $type,
|
'type' => $type,
|
||||||
'label' => $entityEntry['label'],
|
'label' => $entityEntry['label'],
|
||||||
@@ -277,6 +290,7 @@ class BundleImportService {
|
|||||||
'fkWarnings' => $preview['fkWarnings'],
|
'fkWarnings' => $preview['fkWarnings'],
|
||||||
'errorCount' => count($preview['errors']),
|
'errorCount' => count($preview['errors']),
|
||||||
'errors' => array_slice($preview['errors'], 0, 10),
|
'errors' => array_slice($preview['errors'], 0, 10),
|
||||||
|
'targetFields' => $schema['targetFields'],
|
||||||
];
|
];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$results[] = [
|
$results[] = [
|
||||||
@@ -286,6 +300,7 @@ class BundleImportService {
|
|||||||
'fkWarnings' => [],
|
'fkWarnings' => [],
|
||||||
'errorCount' => 1,
|
'errorCount' => 1,
|
||||||
'errors' => [['_rowIndex' => 0, '_errors' => [$e->getMessage()]]],
|
'errors' => [['_rowIndex' => 0, '_errors' => [$e->getMessage()]]],
|
||||||
|
'targetFields' => [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use OCA\Mitgliederverwaltung\Db\Permission;
|
|||||||
use OCA\Mitgliederverwaltung\Db\PermissionMapper;
|
use OCA\Mitgliederverwaltung\Db\PermissionMapper;
|
||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
use OCP\DB\Exception;
|
use OCP\DB\Exception;
|
||||||
|
use OCP\IGroupManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Permission checking logic for all access levels.
|
* Permission checking logic for all access levels.
|
||||||
@@ -26,13 +27,16 @@ class PermissionService {
|
|||||||
|
|
||||||
private PermissionMapper $permissionMapper;
|
private PermissionMapper $permissionMapper;
|
||||||
private MemberMapper $memberMapper;
|
private MemberMapper $memberMapper;
|
||||||
|
private IGroupManager $groupManager;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
PermissionMapper $permissionMapper,
|
PermissionMapper $permissionMapper,
|
||||||
MemberMapper $memberMapper
|
MemberMapper $memberMapper,
|
||||||
|
IGroupManager $groupManager
|
||||||
) {
|
) {
|
||||||
$this->permissionMapper = $permissionMapper;
|
$this->permissionMapper = $permissionMapper;
|
||||||
$this->memberMapper = $memberMapper;
|
$this->memberMapper = $memberMapper;
|
||||||
|
$this->groupManager = $groupManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +58,9 @@ class PermissionService {
|
|||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function canAccess(string $userId): bool {
|
public function canAccess(string $userId): bool {
|
||||||
|
if ($this->groupManager->isInGroup($userId, 'admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$perm = $this->getUserPermission($userId);
|
$perm = $this->getUserPermission($userId);
|
||||||
return $perm !== null && $perm->getLevel() !== 'none';
|
return $perm !== null && $perm->getLevel() !== 'none';
|
||||||
}
|
}
|
||||||
@@ -64,6 +71,9 @@ class PermissionService {
|
|||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function canRead(string $userId): bool {
|
public function canRead(string $userId): bool {
|
||||||
|
if ($this->groupManager->isInGroup($userId, 'admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$perm = $this->getUserPermission($userId);
|
$perm = $this->getUserPermission($userId);
|
||||||
if ($perm === null) {
|
if ($perm === null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -79,6 +89,9 @@ class PermissionService {
|
|||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function canWrite(string $userId, ?int $memberId = null): bool {
|
public function canWrite(string $userId, ?int $memberId = null): bool {
|
||||||
|
if ($this->groupManager->isInGroup($userId, 'admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$perm = $this->getUserPermission($userId);
|
$perm = $this->getUserPermission($userId);
|
||||||
if ($perm === null) {
|
if ($perm === null) {
|
||||||
return false;
|
return false;
|
||||||
@@ -136,7 +149,11 @@ class PermissionService {
|
|||||||
*/
|
*/
|
||||||
public function isAdmin(string $userId): bool {
|
public function isAdmin(string $userId): bool {
|
||||||
$perm = $this->getUserPermission($userId);
|
$perm = $this->getUserPermission($userId);
|
||||||
return $perm !== null && $perm->getLevel() === 'admin';
|
if ($perm !== null && $perm->getLevel() === 'admin') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fallback: Nextcloud system admins are always app admins
|
||||||
|
return $this->groupManager->isInGroup($userId, 'admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +162,9 @@ class PermissionService {
|
|||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function canSeeBanking(string $userId): bool {
|
public function canSeeBanking(string $userId): bool {
|
||||||
|
if ($this->groupManager->isInGroup($userId, 'admin')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$perm = $this->getUserPermission($userId);
|
$perm = $this->getUserPermission($userId);
|
||||||
return $perm !== null && $perm->getCanSeeBanking();
|
return $perm !== null && $perm->getCanSeeBanking();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
<ClipboardText :size="20" />
|
||||||
</template>
|
</template>
|
||||||
</NcAppNavigationItem>
|
</NcAppNavigationItem>
|
||||||
|
<NcAppNavigationItem name="Backup"
|
||||||
|
:to="{ name: 'backup' }"
|
||||||
|
:active="currentRoute === 'backup'">
|
||||||
|
<template #icon>
|
||||||
|
<BackupRestore :size="20" />
|
||||||
|
</template>
|
||||||
|
</NcAppNavigationItem>
|
||||||
<NcAppNavigationItem name="Einstellungen"
|
<NcAppNavigationItem name="Einstellungen"
|
||||||
:to="{ name: 'settings' }"
|
:to="{ name: 'settings' }"
|
||||||
:active="currentRoute === 'settings'">
|
:active="currentRoute === 'settings'">
|
||||||
@@ -97,6 +104,7 @@ import DatabaseSearch from 'vue-material-design-icons/DatabaseSearch.vue'
|
|||||||
import SwapVertical from 'vue-material-design-icons/SwapVertical.vue'
|
import SwapVertical from 'vue-material-design-icons/SwapVertical.vue'
|
||||||
import MedicalBag from 'vue-material-design-icons/MedicalBag.vue'
|
import MedicalBag from 'vue-material-design-icons/MedicalBag.vue'
|
||||||
import Tent from 'vue-material-design-icons/Campfire.vue'
|
import Tent from 'vue-material-design-icons/Campfire.vue'
|
||||||
|
import BackupRestore from 'vue-material-design-icons/BackupRestore.vue'
|
||||||
import SearchBar from './components/SearchBar.vue'
|
import SearchBar from './components/SearchBar.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|||||||
@@ -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(),
|
// @nextcloud/vue v9 reads appName/appVersion via Vue's inject(),
|
||||||
// not via webpack DefinePlugin globals.
|
// not via webpack DefinePlugin globals.
|
||||||
app.provide('appName', 'mitgliederverwaltung')
|
app.provide('appName', 'mitgliederverwaltung')
|
||||||
app.provide('appVersion', '0.1.5')
|
app.provide('appVersion', '0.2.0')
|
||||||
|
|
||||||
app.mount('#mitgliederverwaltung')
|
app.mount('#mitgliederverwaltung')
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ const routes = [
|
|||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: () => import('./views/Settings.vue'),
|
component: () => import('./views/Settings.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/backup',
|
||||||
|
name: 'backup',
|
||||||
|
component: () => import('./views/Backup.vue'),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|||||||
@@ -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,
|
bundlePreviewResult: null,
|
||||||
/** @type {Object|null} Bundle execution result */
|
/** @type {Object|null} Bundle execution result */
|
||||||
bundleExecuteResult: null,
|
bundleExecuteResult: null,
|
||||||
|
/** @type {Object} Per-entity corrections for bundle errors: { entityType: { rowIndex: { fieldKey: value } } } */
|
||||||
|
bundleCorrections: {},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -305,6 +307,7 @@ export const useImportStore = defineStore('import', {
|
|||||||
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/preview')
|
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/preview')
|
||||||
const response = await axios.post(url, {
|
const response = await axios.post(url, {
|
||||||
content: this.fileContent,
|
content: this.fileContent,
|
||||||
|
corrections: this.bundleCorrections,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.bundlePreviewResult = response.data
|
this.bundlePreviewResult = response.data
|
||||||
@@ -328,6 +331,7 @@ export const useImportStore = defineStore('import', {
|
|||||||
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/execute')
|
const url = generateUrl('/apps/mitgliederverwaltung/api/v1/import/bundle/execute')
|
||||||
const response = await axios.post(url, {
|
const response = await axios.post(url, {
|
||||||
content: this.fileContent,
|
content: this.fileContent,
|
||||||
|
corrections: this.bundleCorrections,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.bundleExecuteResult = response.data
|
this.bundleExecuteResult = response.data
|
||||||
@@ -366,6 +370,7 @@ export const useImportStore = defineStore('import', {
|
|||||||
this.bundleAnalysis = null
|
this.bundleAnalysis = null
|
||||||
this.bundlePreviewResult = null
|
this.bundlePreviewResult = null
|
||||||
this.bundleExecuteResult = null
|
this.bundleExecuteResult = null
|
||||||
|
this.bundleCorrections = {}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for persisted column visibility.
|
||||||
|
*
|
||||||
|
* @param {string} storageKey - localStorage key
|
||||||
|
* @param {Array} allColumns - Column definitions with { key, label, ... }
|
||||||
|
* @param {string[]} defaultVisible - Keys visible by default
|
||||||
|
* @returns {{ visibleKeys, visibleColumns, setVisibleKeys }}
|
||||||
|
*/
|
||||||
|
export function useColumnVisibility(storageKey, allColumns, defaultVisible) {
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(storageKey)
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored)
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return [...defaultVisible]
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleKeys = ref(load())
|
||||||
|
|
||||||
|
const visibleColumns = computed(() =>
|
||||||
|
allColumns.filter(col => visibleKeys.value.includes(col.key)),
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(visibleKeys, (val) => {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(val))
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
function setVisibleKeys(newKeys) {
|
||||||
|
visibleKeys.value = newKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
return { visibleKeys, visibleColumns, setVisibleKeys }
|
||||||
|
}
|
||||||
+79
-211
@@ -1,6 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="audit-log">
|
<div class="audit-log">
|
||||||
<h2>Audit-Log</h2>
|
<div class="audit-log__header">
|
||||||
|
<h2>Audit-Log</h2>
|
||||||
|
<ColumnPicker :columns="allColumns"
|
||||||
|
:model-value="visibleKeys"
|
||||||
|
@update:model-value="setVisibleKeys" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="audit-log__filters">
|
<div class="audit-log__filters">
|
||||||
@@ -77,49 +82,39 @@
|
|||||||
<table v-else class="audit-log__table">
|
<table v-else class="audit-log__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="audit-log__th">Zeitpunkt</th>
|
<th v-for="col in visibleColumns" :key="col.key"
|
||||||
<th class="audit-log__th">Benutzer</th>
|
class="audit-log__th">
|
||||||
<th class="audit-log__th">Aktion</th>
|
{{ col.label }}
|
||||||
<th class="audit-log__th">Entität</th>
|
</th>
|
||||||
<th class="audit-log__th">Feld</th>
|
|
||||||
<th class="audit-log__th">Alter Wert</th>
|
|
||||||
<th class="audit-log__th">Neuer Wert</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="entry in store.entries"
|
<tr v-for="entry in store.entries"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
class="audit-log__row">
|
class="audit-log__row">
|
||||||
<td class="audit-log__td audit-log__td--time">
|
<td v-for="col in visibleColumns" :key="col.key"
|
||||||
{{ formatDateTime(entry.zeitpunkt) }}
|
class="audit-log__td"
|
||||||
</td>
|
:class="{
|
||||||
<td class="audit-log__td">
|
'audit-log__td--time': col.key === 'zeitpunkt',
|
||||||
{{ entry.ncUserId }}
|
'audit-log__td--value': col.key === 'alterWert' || col.key === 'neuerWert',
|
||||||
</td>
|
}">
|
||||||
<td class="audit-log__td">
|
<template v-if="col.key === 'aktion'">
|
||||||
<span :class="'audit-log__action audit-log__action--' + entry.aktion">
|
<span :class="'audit-log__action audit-log__action--' + entry.aktion">
|
||||||
{{ formatAktion(entry.aktion) }}
|
{{ col.render(entry) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</template>
|
||||||
<td class="audit-log__td">
|
<template v-else-if="col.key === 'entitaet'">
|
||||||
<span class="audit-log__entity-link"
|
<span class="audit-log__entity-link"
|
||||||
@click="navigateToEntity(entry.entitaet, entry.entitaetId)">
|
@click="navigateToEntity(entry.entitaet, entry.entitaetId)">
|
||||||
{{ formatEntitaet(entry.entitaet) }}
|
{{ col.render(entry) }}
|
||||||
#{{ entry.entitaetId }}
|
</span>
|
||||||
</span>
|
</template>
|
||||||
</td>
|
<template v-else-if="col.key === 'alterWert' || col.key === 'neuerWert'">
|
||||||
<td class="audit-log__td">
|
<span :class="{ 'audit-log__encrypted': isEncrypted(col.render(entry)) }">
|
||||||
{{ entry.feld || '—' }}
|
{{ col.render(entry) }}
|
||||||
</td>
|
</span>
|
||||||
<td class="audit-log__td audit-log__td--value">
|
</template>
|
||||||
<span :class="{ 'audit-log__encrypted': isEncrypted(entry.alterWert) }">
|
<template v-else>{{ col.render(entry) }}</template>
|
||||||
{{ entry.alterWert || '—' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="audit-log__td audit-log__td--value">
|
|
||||||
<span :class="{ 'audit-log__encrypted': isEncrypted(entry.neuerWert) }">
|
|
||||||
{{ entry.neuerWert || '—' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -148,6 +143,8 @@ import { onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||||
import { useAuditLogStore } from '../stores/auditlog.js'
|
import { useAuditLogStore } from '../stores/auditlog.js'
|
||||||
|
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||||
|
import ColumnPicker from '../components/ColumnPicker.vue'
|
||||||
import { formatDateTime } from '../utils/dateFormat.js'
|
import { formatDateTime } from '../utils/dateFormat.js'
|
||||||
|
|
||||||
const store = useAuditLogStore()
|
const store = useAuditLogStore()
|
||||||
@@ -155,6 +152,25 @@ const router = useRouter()
|
|||||||
|
|
||||||
let filterTimeout = null
|
let filterTimeout = null
|
||||||
|
|
||||||
|
const aktionMap = { create: 'Erstellt', update: 'Geändert', delete: 'Gelöscht', 'soft-delete': 'Soft-Delete' }
|
||||||
|
const entitaetMap = { member: 'Mitglied', family: 'Familie', stufe: 'Stufe', fee_rule: 'Beitragsregel', fee_record: 'Beitragsposition', permission: 'Berechtigung' }
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
{ key: 'zeitpunkt', label: 'Zeitpunkt', alwaysVisible: true, render: e => formatDateTime(e.zeitpunkt) },
|
||||||
|
{ key: 'ncUserId', label: 'Benutzer', render: e => e.ncUserId },
|
||||||
|
{ key: 'aktion', label: 'Aktion', render: e => aktionMap[e.aktion] || e.aktion },
|
||||||
|
{ key: 'entitaet', label: 'Entität', render: e => (entitaetMap[e.entitaet] || e.entitaet) + ' #' + e.entitaetId },
|
||||||
|
{ key: 'feld', label: 'Feld', render: e => e.feld || '\u2014' },
|
||||||
|
{ key: 'alterWert', label: 'Alter Wert', render: e => e.alterWert || '\u2014' },
|
||||||
|
{ key: 'neuerWert', label: 'Neuer Wert', render: e => e.neuerWert || '\u2014' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
|
||||||
|
'mv_audit_columns',
|
||||||
|
allColumns,
|
||||||
|
['zeitpunkt', 'ncUserId', 'aktion', 'entitaet', 'feld', 'alterWert', 'neuerWert'],
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.fetchEntries()
|
store.fetchEntries()
|
||||||
})
|
})
|
||||||
@@ -171,40 +187,12 @@ function reload() {
|
|||||||
store.fetchEntries()
|
store.fetchEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatDateTime imported from utils/dateFormat.js
|
|
||||||
|
|
||||||
function formatAktion(aktion) {
|
|
||||||
const map = {
|
|
||||||
create: 'Erstellt',
|
|
||||||
update: 'Geändert',
|
|
||||||
delete: 'Gelöscht',
|
|
||||||
'soft-delete': 'Soft-Delete',
|
|
||||||
}
|
|
||||||
return map[aktion] || aktion
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatEntitaet(entitaet) {
|
|
||||||
const map = {
|
|
||||||
member: 'Mitglied',
|
|
||||||
family: 'Familie',
|
|
||||||
stufe: 'Stufe',
|
|
||||||
fee_rule: 'Beitragsregel',
|
|
||||||
fee_record: 'Beitragsposition',
|
|
||||||
permission: 'Berechtigung',
|
|
||||||
}
|
|
||||||
return map[entitaet] || entitaet
|
|
||||||
}
|
|
||||||
|
|
||||||
function isEncrypted(value) {
|
function isEncrypted(value) {
|
||||||
return value === '[verschluesselt]'
|
return value === '[verschluesselt]'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to the referenced entity.
|
|
||||||
*/
|
|
||||||
function navigateToEntity(entitaet, entitaetId) {
|
function navigateToEntity(entitaet, entitaetId) {
|
||||||
if (!entitaetId) return
|
if (!entitaetId) return
|
||||||
|
|
||||||
switch (entitaet) {
|
switch (entitaet) {
|
||||||
case 'member':
|
case 'member':
|
||||||
router.push({ name: 'member-detail', params: { id: entitaetId } })
|
router.push({ name: 'member-detail', params: { id: entitaetId } })
|
||||||
@@ -213,155 +201,35 @@ function navigateToEntity(entitaet, entitaetId) {
|
|||||||
router.push({ name: 'family-detail', params: { id: entitaetId } })
|
router.push({ name: 'family-detail', params: { id: entitaetId } })
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
// No navigation for other entity types yet
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.audit-log {
|
.audit-log { padding: 20px; }
|
||||||
padding: 20px;
|
.audit-log__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
}
|
.audit-log__header h2 { margin: 0; }
|
||||||
|
.audit-log__filters { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 20px; padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius-large); }
|
||||||
.audit-log h2 {
|
.audit-log__filter { display: flex; flex-direction: column; gap: 4px; }
|
||||||
margin: 0 0 20px 0;
|
.audit-log__filter label { font-size: 0.8em; color: var(--color-text-lighter); font-weight: 500; }
|
||||||
}
|
.audit-log__select { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); color: var(--color-text); }
|
||||||
|
.audit-log__date-input { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); color: var(--color-text); }
|
||||||
.audit-log__filters {
|
.audit-log__loading { display: flex; justify-content: center; margin-top: 60px; }
|
||||||
display: flex;
|
.audit-log__table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
|
||||||
gap: 12px;
|
.audit-log__th { text-align: left; padding: 10px 12px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
|
||||||
align-items: flex-end;
|
.audit-log__row { transition: background-color 0.15s ease; }
|
||||||
flex-wrap: wrap;
|
.audit-log__row:hover { background-color: var(--color-background-hover); }
|
||||||
margin-bottom: 20px;
|
.audit-log__td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
|
||||||
padding: 12px 16px;
|
.audit-log__td--time { white-space: nowrap; font-size: 0.9em; color: var(--color-text-lighter); }
|
||||||
background: var(--color-background-dark);
|
.audit-log__td--value { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
border-radius: var(--border-radius-large);
|
.audit-log__action { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
|
||||||
}
|
.audit-log__action--create { background-color: var(--color-success); color: white; }
|
||||||
|
.audit-log__action--update { background-color: var(--color-primary); color: white; }
|
||||||
.audit-log__filter {
|
.audit-log__action--delete, .audit-log__action--soft-delete { background-color: var(--color-error); color: white; }
|
||||||
display: flex;
|
.audit-log__entity-link { cursor: pointer; color: var(--color-primary); }
|
||||||
flex-direction: column;
|
.audit-log__entity-link:hover { text-decoration: underline; }
|
||||||
gap: 4px;
|
.audit-log__encrypted { font-style: italic; color: var(--color-text-lighter); }
|
||||||
}
|
.audit-log__pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 20px; padding: 12px 0; }
|
||||||
|
.audit-log__page-info { color: var(--color-text-lighter); }
|
||||||
.audit-log__filter label {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__select {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background: var(--color-main-background);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__date-input {
|
|
||||||
padding: 6px 8px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background: var(--color-main-background);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 2px solid var(--color-border-dark);
|
|
||||||
font-weight: bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__row {
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__row:hover {
|
|
||||||
background-color: var(--color-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__td {
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__td--time {
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__td--value {
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__action {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--border-radius-pill);
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__action--create {
|
|
||||||
background-color: var(--color-success);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__action--update {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__action--delete,
|
|
||||||
.audit-log__action--soft-delete {
|
|
||||||
background-color: var(--color-error);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__entity-link {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__entity-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__encrypted {
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audit-log__page-info {
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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-icon="close"
|
||||||
@trailing-button-click="clearSearch"
|
@trailing-button-click="clearSearch"
|
||||||
@update:model-value="onSearch" />
|
@update:model-value="onSearch" />
|
||||||
|
<ColumnPicker :columns="allColumns"
|
||||||
|
:model-value="visibleKeys"
|
||||||
|
@update:model-value="setVisibleKeys" />
|
||||||
<NcButton type="primary"
|
<NcButton type="primary"
|
||||||
@click="$router.push({ name: 'family-detail', params: { id: 'new' } })">
|
@click="$router.push({ name: 'family-detail', params: { id: 'new' } })">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -59,16 +62,15 @@
|
|||||||
<table v-else class="family-list__table">
|
<table v-else class="family-list__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="family-list__th family-list__th--sortable"
|
<th v-for="col in visibleColumns" :key="col.key"
|
||||||
@click="toggleSort('name')">
|
class="family-list__th"
|
||||||
Familienname
|
:class="{ 'family-list__th--sortable': col.sortable }"
|
||||||
<SortIcon :field="'name'" :current-sort="sortField" :sort-asc="sortAsc" />
|
@click="col.sortable && toggleSort(col.sortField || col.key)">
|
||||||
</th>
|
{{ col.label }}
|
||||||
<th class="family-list__th">
|
<SortIcon v-if="col.sortable"
|
||||||
Mitglieder
|
:field="col.sortField || col.key"
|
||||||
</th>
|
:current-sort="sortField"
|
||||||
<th class="family-list__th">
|
:sort-asc="sortAsc" />
|
||||||
Ansprechpartner
|
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -77,17 +79,9 @@
|
|||||||
:key="family.id"
|
:key="family.id"
|
||||||
class="family-list__row"
|
class="family-list__row"
|
||||||
@click="$router.push({ name: 'family-detail', params: { id: family.id } })">
|
@click="$router.push({ name: 'family-detail', params: { id: family.id } })">
|
||||||
<td class="family-list__td">
|
<td v-for="col in visibleColumns" :key="col.key"
|
||||||
{{ family.name }}
|
class="family-list__td">
|
||||||
</td>
|
{{ col.render(family) }}
|
||||||
<td class="family-list__td">
|
|
||||||
{{ family.members ? family.members.length : 0 }}
|
|
||||||
<span v-if="family.activeChildrenCount" class="family-list__children-count">
|
|
||||||
({{ family.activeChildrenCount }} aktive Kinder)
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="family-list__td">
|
|
||||||
{{ getAnsprechpartner(family) }}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -115,6 +109,8 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
import { NcButton, NcTextField, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||||
import { useFamiliesStore } from '../stores/families.js'
|
import { useFamiliesStore } from '../stores/families.js'
|
||||||
|
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||||
|
import ColumnPicker from '../components/ColumnPicker.vue'
|
||||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||||
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||||
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
|
import AccountMultiple from 'vue-material-design-icons/AccountMultiple.vue'
|
||||||
@@ -127,6 +123,27 @@ const sortField = ref('name')
|
|||||||
const sortAsc = ref(true)
|
const sortAsc = ref(true)
|
||||||
let searchTimeout = null
|
let searchTimeout = null
|
||||||
|
|
||||||
|
function getAnsprechpartner(family) {
|
||||||
|
if (!family.members || family.members.length === 0) return '\u2014'
|
||||||
|
const contact = family.members.find(m => m.rolle === 'erziehungsberechtigter')
|
||||||
|
if (contact) return `${contact.vorname} ${contact.nachname}`
|
||||||
|
const first = family.members[0]
|
||||||
|
return `${first.vorname} ${first.nachname}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
{ key: 'name', label: 'Familienname', sortable: true, sortField: 'name', alwaysVisible: true, render: f => f.name },
|
||||||
|
{ key: 'members', label: 'Mitglieder', sortable: false, render: f => f.members ? f.members.length : 0 },
|
||||||
|
{ key: 'activeChildren', label: 'Aktive Kinder', sortable: false, render: f => f.activeChildrenCount || 0 },
|
||||||
|
{ key: 'ansprechpartner', label: 'Ansprechpartner', sortable: false, render: f => getAnsprechpartner(f) },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
|
||||||
|
'mv_family_columns',
|
||||||
|
allColumns,
|
||||||
|
['name', 'members', 'ansprechpartner'],
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.fetchFamilies()
|
store.fetchFamilies()
|
||||||
})
|
})
|
||||||
@@ -181,105 +198,21 @@ function reload() {
|
|||||||
store.clearError()
|
store.clearError()
|
||||||
store.fetchFamilies()
|
store.fetchFamilies()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the primary contact (Erziehungsberechtigter) from a family's members.
|
|
||||||
*/
|
|
||||||
function getAnsprechpartner(family) {
|
|
||||||
if (!family.members || family.members.length === 0) {
|
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
const contact = family.members.find(m => m.rolle === 'erziehungsberechtigter')
|
|
||||||
if (contact) {
|
|
||||||
return `${contact.vorname} ${contact.nachname}`
|
|
||||||
}
|
|
||||||
// Fallback to first member
|
|
||||||
const first = family.members[0]
|
|
||||||
return `${first.vorname} ${first.nachname}`
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.family-list {
|
.family-list { padding: 20px; }
|
||||||
padding: 20px;
|
.family-list__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
||||||
}
|
.family-list__header h2 { margin: 0; }
|
||||||
|
.family-list__actions { display: flex; gap: 12px; align-items: center; }
|
||||||
.family-list__header {
|
.family-list__loading { display: flex; justify-content: center; margin-top: 60px; }
|
||||||
display: flex;
|
.family-list__table { width: 100%; border-collapse: collapse; }
|
||||||
justify-content: space-between;
|
.family-list__th { text-align: left; padding: 12px 16px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
|
||||||
align-items: center;
|
.family-list__th--sortable { cursor: pointer; user-select: none; }
|
||||||
margin-bottom: 20px;
|
.family-list__th--sortable:hover { background-color: var(--color-background-hover); }
|
||||||
flex-wrap: wrap;
|
.family-list__row { cursor: pointer; transition: background-color 0.15s ease; }
|
||||||
gap: 12px;
|
.family-list__row:hover { background-color: var(--color-background-hover); }
|
||||||
}
|
.family-list__td { padding: 10px 16px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.family-list__pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 20px; padding: 12px 0; }
|
||||||
.family-list__header h2 {
|
.family-list__page-info { color: var(--color-text-lighter); }
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 2px solid var(--color-border-dark);
|
|
||||||
font-weight: bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__th--sortable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__th--sortable:hover {
|
|
||||||
background-color: var(--color-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__row {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__row:hover {
|
|
||||||
background-color: var(--color-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__td {
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__children-count {
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.family-list__page-info {
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+135
-363
@@ -18,6 +18,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ColumnPicker :columns="allColumns"
|
||||||
|
:model-value="visibleKeys"
|
||||||
|
@update:model-value="setVisibleKeys" />
|
||||||
|
|
||||||
<!-- Batch calculate -->
|
<!-- Batch calculate -->
|
||||||
<NcButton type="primary"
|
<NcButton type="primary"
|
||||||
:disabled="feesStore.loading"
|
:disabled="feesStore.loading"
|
||||||
@@ -105,97 +109,84 @@
|
|||||||
<table v-else class="fee-overview__table">
|
<table v-else class="fee-overview__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="fee-overview__th">Mitglied</th>
|
<th v-for="col in visibleColumns" :key="col.key"
|
||||||
<th class="fee-overview__th">Betrag</th>
|
class="fee-overview__th">
|
||||||
<th class="fee-overview__th">Bezahlt</th>
|
{{ col.label }}
|
||||||
<th class="fee-overview__th">Zahlungsdatum</th>
|
</th>
|
||||||
<th class="fee-overview__th">Manuell</th>
|
|
||||||
<th class="fee-overview__th">Notizen</th>
|
|
||||||
<th class="fee-overview__th">Aktionen</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="record in feesStore.records"
|
<tr v-for="record in feesStore.records"
|
||||||
:key="record.id"
|
:key="record.id"
|
||||||
:class="{ 'fee-overview__row--manual': record.manuellAngepasst }">
|
:class="{ 'fee-overview__row--manual': record.manuellAngepasst }">
|
||||||
<td class="fee-overview__td">
|
<td v-for="col in visibleColumns" :key="col.key"
|
||||||
<router-link :to="{ name: 'member-detail', params: { id: record.memberId } }"
|
class="fee-overview__td"
|
||||||
class="fee-overview__member-link">
|
:class="{
|
||||||
{{ getMemberName(record.memberId) }}
|
'fee-overview__td--amount': col.key === 'amount',
|
||||||
</router-link>
|
'fee-overview__td--notes': col.key === 'notes',
|
||||||
</td>
|
'fee-overview__td--actions': col.key === 'aktionen',
|
||||||
<td class="fee-overview__td fee-overview__td--amount">
|
}">
|
||||||
<template v-if="editingRecord === record.id">
|
<!-- Mitglied column with link -->
|
||||||
<input v-model="editAmount"
|
<template v-if="col.key === 'mitglied'">
|
||||||
type="number"
|
<router-link :to="{ name: 'member-detail', params: { id: record.memberId } }"
|
||||||
min="0"
|
class="fee-overview__member-link">
|
||||||
step="0.01"
|
{{ col.render(record) }}
|
||||||
class="fee-overview__inline-input"
|
</router-link>
|
||||||
@keyup.enter="saveOverride(record.id)"
|
|
||||||
@keyup.escape="cancelEdit">
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<!-- Amount with inline editing -->
|
||||||
{{ formatCurrency(parseFloat(record.amount)) }}
|
<template v-else-if="col.key === 'amount'">
|
||||||
|
<template v-if="editingRecord === record.id">
|
||||||
|
<input v-model="editAmount"
|
||||||
|
type="number" min="0" step="0.01"
|
||||||
|
class="fee-overview__inline-input"
|
||||||
|
@keyup.enter="saveOverride(record.id)"
|
||||||
|
@keyup.escape="cancelEdit">
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ col.render(record) }}</template>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
<!-- Bezahlt badge -->
|
||||||
<td class="fee-overview__td">
|
<template v-else-if="col.key === 'paid'">
|
||||||
<span :class="record.paid ? 'fee-overview__badge--paid' : 'fee-overview__badge--unpaid'"
|
<span :class="record.paid ? 'fee-overview__badge--paid' : 'fee-overview__badge--unpaid'"
|
||||||
class="fee-overview__badge">
|
class="fee-overview__badge">
|
||||||
{{ record.paid ? 'Ja' : 'Nein' }}
|
{{ col.render(record) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td class="fee-overview__td">
|
|
||||||
{{ formatDate(record.paymentDate) }}
|
|
||||||
</td>
|
|
||||||
<td class="fee-overview__td">
|
|
||||||
<span v-if="record.manuellAngepasst" class="fee-overview__badge fee-overview__badge--manual">
|
|
||||||
Manuell
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="fee-overview__td fee-overview__td--notes">
|
|
||||||
<template v-if="editingNotes === record.id">
|
|
||||||
<input v-model="editNotesText"
|
|
||||||
type="text"
|
|
||||||
class="fee-overview__inline-input"
|
|
||||||
placeholder="Notiz..."
|
|
||||||
@keyup.enter="saveNotes(record.id)"
|
|
||||||
@keyup.escape="cancelNotesEdit">
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<!-- Manuell badge -->
|
||||||
{{ record.notes || '--' }}
|
<template v-else-if="col.key === 'manuell'">
|
||||||
|
<span v-if="record.manuellAngepasst" class="fee-overview__badge fee-overview__badge--manual">
|
||||||
|
Manuell
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
<!-- Notes with inline editing -->
|
||||||
<td class="fee-overview__td fee-overview__td--actions">
|
<template v-else-if="col.key === 'notes'">
|
||||||
<template v-if="editingRecord === record.id">
|
|
||||||
<NcButton type="primary" @click="saveOverride(record.id)">
|
|
||||||
Speichern
|
|
||||||
</NcButton>
|
|
||||||
<NcButton @click="cancelEdit">
|
|
||||||
Abbrechen
|
|
||||||
</NcButton>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<NcButton v-if="!record.paid"
|
|
||||||
type="success"
|
|
||||||
@click="markPaid(record.id)">
|
|
||||||
Bezahlt
|
|
||||||
</NcButton>
|
|
||||||
<NcButton @click="startEdit(record)">
|
|
||||||
Betrag ändern
|
|
||||||
</NcButton>
|
|
||||||
<NcButton v-if="!editingNotes || editingNotes !== record.id"
|
|
||||||
@click="startNotesEdit(record)">
|
|
||||||
Notiz
|
|
||||||
</NcButton>
|
|
||||||
<template v-if="editingNotes === record.id">
|
<template v-if="editingNotes === record.id">
|
||||||
<NcButton type="primary" @click="saveNotes(record.id)">
|
<input v-model="editNotesText"
|
||||||
Speichern
|
type="text"
|
||||||
</NcButton>
|
class="fee-overview__inline-input"
|
||||||
<NcButton @click="cancelNotesEdit">
|
placeholder="Notiz..."
|
||||||
Abbrechen
|
@keyup.enter="saveNotes(record.id)"
|
||||||
</NcButton>
|
@keyup.escape="cancelNotesEdit">
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ col.render(record) }}</template>
|
||||||
|
</template>
|
||||||
|
<!-- Actions -->
|
||||||
|
<template v-else-if="col.key === 'aktionen'">
|
||||||
|
<template v-if="editingRecord === record.id">
|
||||||
|
<NcButton type="primary" @click="saveOverride(record.id)">Speichern</NcButton>
|
||||||
|
<NcButton @click="cancelEdit">Abbrechen</NcButton>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<NcButton v-if="!record.paid" type="success" @click="markPaid(record.id)">Bezahlt</NcButton>
|
||||||
|
<NcButton @click="startEdit(record)">Betrag ändern</NcButton>
|
||||||
|
<NcButton v-if="!editingNotes || editingNotes !== record.id" @click="startNotesEdit(record)">Notiz</NcButton>
|
||||||
|
<template v-if="editingNotes === record.id">
|
||||||
|
<NcButton type="primary" @click="saveNotes(record.id)">Speichern</NcButton>
|
||||||
|
<NcButton @click="cancelNotesEdit">Abbrechen</NcButton>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- Default -->
|
||||||
|
<template v-else>{{ col.render(record) }}</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -208,14 +199,13 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { NcButton, NcLoadingIcon, NcEmptyContent } from '@nextcloud/vue'
|
import { NcButton, NcLoadingIcon, NcEmptyContent } from '@nextcloud/vue'
|
||||||
import { useFeesStore } from '../stores/fees.js'
|
import { useFeesStore } from '../stores/fees.js'
|
||||||
import { useMembersStore } from '../stores/members.js'
|
import { useMembersStore } from '../stores/members.js'
|
||||||
|
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||||
|
import ColumnPicker from '../components/ColumnPicker.vue'
|
||||||
import { formatDate } from '../utils/dateFormat.js'
|
import { formatDate } from '../utils/dateFormat.js'
|
||||||
|
|
||||||
const feesStore = useFeesStore()
|
const feesStore = useFeesStore()
|
||||||
const membersStore = useMembersStore()
|
const membersStore = useMembersStore()
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a lookup map from member ID to "Nachname, Vorname".
|
|
||||||
*/
|
|
||||||
const memberNameMap = computed(() => {
|
const memberNameMap = computed(() => {
|
||||||
const map = {}
|
const map = {}
|
||||||
for (const m of membersStore.members) {
|
for (const m of membersStore.members) {
|
||||||
@@ -228,6 +218,27 @@ function getMemberName(memberId) {
|
|||||||
return memberNameMap.value[memberId] || `Mitglied #${memberId}`
|
return memberNameMap.value[memberId] || `Mitglied #${memberId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatCurrency(value) {
|
||||||
|
if (value === null || value === undefined || isNaN(value)) return '--'
|
||||||
|
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
{ key: 'mitglied', label: 'Mitglied', alwaysVisible: true, render: r => getMemberName(r.memberId) },
|
||||||
|
{ key: 'amount', label: 'Betrag', render: r => formatCurrency(parseFloat(r.amount)) },
|
||||||
|
{ key: 'paid', label: 'Bezahlt', render: r => r.paid ? 'Ja' : 'Nein' },
|
||||||
|
{ key: 'paymentDate', label: 'Zahlungsdatum', render: r => formatDate(r.paymentDate) },
|
||||||
|
{ key: 'manuell', label: 'Manuell', render: r => r.manuellAngepasst ? 'Ja' : '' },
|
||||||
|
{ key: 'notes', label: 'Notizen', render: r => r.notes || '--' },
|
||||||
|
{ key: 'aktionen', label: 'Aktionen', render: () => '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
|
||||||
|
'mv_fee_columns',
|
||||||
|
allColumns,
|
||||||
|
['mitglied', 'amount', 'paid', 'paymentDate', 'manuell', 'notes', 'aktionen'],
|
||||||
|
)
|
||||||
|
|
||||||
const showBatchConfirm = ref(false)
|
const showBatchConfirm = ref(false)
|
||||||
const editingRecord = ref(null)
|
const editingRecord = ref(null)
|
||||||
const editAmount = ref('')
|
const editAmount = ref('')
|
||||||
@@ -235,7 +246,6 @@ const editingNotes = ref(null)
|
|||||||
const editNotesText = ref('')
|
const editNotesText = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Fetch all members for the name lookup (not just first page)
|
|
||||||
const savedLimit = membersStore.limit
|
const savedLimit = membersStore.limit
|
||||||
membersStore.limit = 9999
|
membersStore.limit = 9999
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -246,40 +256,23 @@ onMounted(async () => {
|
|||||||
membersStore.limit = savedLimit
|
membersStore.limit = savedLimit
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Year change ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function onYearChange(year) {
|
function onYearChange(year) {
|
||||||
feesStore.fetchRecords(parseInt(year))
|
feesStore.fetchRecords(parseInt(year))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Batch calculate ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function executeBatchCalculate() {
|
async function executeBatchCalculate() {
|
||||||
try {
|
try { await feesStore.batchCalculate(feesStore.selectedYear) } catch { /* store shows error */ }
|
||||||
await feesStore.batchCalculate(feesStore.selectedYear)
|
|
||||||
} catch {
|
|
||||||
// Error displayed by store
|
|
||||||
}
|
|
||||||
showBatchConfirm.value = false
|
showBatchConfirm.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mark as paid ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function markPaid(recordId) {
|
async function markPaid(recordId) {
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
try {
|
try { await feesStore.markAsPaid(recordId, today) } catch { /* store shows error */ }
|
||||||
await feesStore.markAsPaid(recordId, today)
|
|
||||||
} catch {
|
|
||||||
// Error displayed by store
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Manual override ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function startEdit(record) {
|
function startEdit(record) {
|
||||||
editingRecord.value = record.id
|
editingRecord.value = record.id
|
||||||
editAmount.value = record.amount || ''
|
editAmount.value = record.amount || ''
|
||||||
// Cancel any notes editing
|
|
||||||
editingNotes.value = null
|
editingNotes.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,22 +283,14 @@ function cancelEdit() {
|
|||||||
|
|
||||||
async function saveOverride(recordId) {
|
async function saveOverride(recordId) {
|
||||||
if (!editAmount.value || parseFloat(editAmount.value) < 0) return
|
if (!editAmount.value || parseFloat(editAmount.value) < 0) return
|
||||||
|
try { await feesStore.manualOverride(recordId, String(editAmount.value), null) } catch { /* store shows error */ }
|
||||||
try {
|
|
||||||
await feesStore.manualOverride(recordId, String(editAmount.value), null)
|
|
||||||
} catch {
|
|
||||||
// Error displayed by store
|
|
||||||
}
|
|
||||||
editingRecord.value = null
|
editingRecord.value = null
|
||||||
editAmount.value = ''
|
editAmount.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Notes editing ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function startNotesEdit(record) {
|
function startNotesEdit(record) {
|
||||||
editingNotes.value = record.id
|
editingNotes.value = record.id
|
||||||
editNotesText.value = record.notes || ''
|
editNotesText.value = record.notes || ''
|
||||||
// Cancel any amount editing
|
|
||||||
editingRecord.value = null
|
editingRecord.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,263 +304,50 @@ async function saveNotes(recordId) {
|
|||||||
const record = feesStore.records.find(r => r.id === recordId)
|
const record = feesStore.records.find(r => r.id === recordId)
|
||||||
const amount = record ? record.amount : '0'
|
const amount = record ? record.amount : '0'
|
||||||
await feesStore.manualOverride(recordId, amount, editNotesText.value)
|
await feesStore.manualOverride(recordId, amount, editNotesText.value)
|
||||||
} catch {
|
} catch { /* store shows error */ }
|
||||||
// Error displayed by store
|
|
||||||
}
|
|
||||||
editingNotes.value = null
|
editingNotes.value = null
|
||||||
editNotesText.value = ''
|
editNotesText.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Formatting ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// formatDate imported from utils/dateFormat.js
|
|
||||||
|
|
||||||
function formatCurrency(value) {
|
|
||||||
if (value === null || value === undefined || isNaN(value)) return '--'
|
|
||||||
return new Intl.NumberFormat('de-DE', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR',
|
|
||||||
}).format(value)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fee-overview {
|
.fee-overview { padding: 20px; }
|
||||||
padding: 20px;
|
.fee-overview__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
||||||
}
|
.fee-overview__header h2 { margin: 0; }
|
||||||
|
.fee-overview__actions { display: flex; gap: 12px; align-items: center; }
|
||||||
.fee-overview__header {
|
.fee-overview__year-selector { display: flex; align-items: center; gap: 6px; }
|
||||||
display: flex;
|
.fee-overview__year-selector label { font-weight: 500; }
|
||||||
justify-content: space-between;
|
.fee-overview__select { padding: 6px 8px; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-main-background); color: var(--color-text); }
|
||||||
align-items: center;
|
.fee-overview__message { display: flex; align-items: center; gap: 12px; padding: 8px 12px; border-radius: var(--border-radius); margin-bottom: 12px; color: white; }
|
||||||
margin-bottom: 20px;
|
.fee-overview__message--error { background: var(--color-error); }
|
||||||
flex-wrap: wrap;
|
.fee-overview__message--success { background: var(--color-success); }
|
||||||
gap: 12px;
|
.fee-overview__dialog-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||||
}
|
.fee-overview__dialog { background: var(--color-main-background); padding: 24px; border-radius: var(--border-radius-large); max-width: 480px; width: 90%; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2); }
|
||||||
|
.fee-overview__dialog h3 { margin: 0 0 12px 0; }
|
||||||
.fee-overview__header h2 {
|
.fee-overview__dialog ul { margin: 8px 0 16px 0; padding-left: 20px; }
|
||||||
margin: 0;
|
.fee-overview__dialog li { margin-bottom: 4px; color: var(--color-text-lighter); }
|
||||||
}
|
.fee-overview__dialog-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.fee-overview__summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px; padding: 12px 16px; background: var(--color-background-dark); border-radius: var(--border-radius); }
|
||||||
.fee-overview__actions {
|
.fee-overview__summary-item { display: flex; flex-direction: column; gap: 2px; }
|
||||||
display: flex;
|
.fee-overview__summary-label { font-size: 0.8em; color: var(--color-text-lighter); font-weight: 500; }
|
||||||
gap: 12px;
|
.fee-overview__summary-value { font-size: 1.1em; font-weight: 600; }
|
||||||
align-items: center;
|
.fee-overview__summary-item--paid .fee-overview__summary-value { color: var(--color-success); }
|
||||||
}
|
.fee-overview__summary-item--outstanding .fee-overview__summary-value { color: var(--color-warning); }
|
||||||
|
.fee-overview__summary-item--manual .fee-overview__summary-value { color: var(--color-primary); }
|
||||||
.fee-overview__year-selector {
|
.fee-overview__loading { display: flex; justify-content: center; margin-top: 60px; }
|
||||||
display: flex;
|
.fee-overview__table { width: 100%; border-collapse: collapse; }
|
||||||
align-items: center;
|
.fee-overview__th { text-align: left; padding: 12px 12px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; font-size: 0.9em; }
|
||||||
gap: 6px;
|
.fee-overview__td { padding: 10px 12px; border-bottom: 1px solid var(--color-border); vertical-align: middle; }
|
||||||
}
|
.fee-overview__td--amount { font-variant-numeric: tabular-nums; }
|
||||||
|
.fee-overview__td--notes { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.fee-overview__year-selector label {
|
.fee-overview__td--actions { white-space: normal; }
|
||||||
font-weight: 500;
|
.fee-overview__td--actions :deep(.button-vue) { display: inline-flex; margin: 2px; }
|
||||||
}
|
.fee-overview__row--manual { background: var(--color-primary-element-light); }
|
||||||
|
.fee-overview__member-link { color: var(--color-primary); text-decoration: none; font-weight: 500; }
|
||||||
.fee-overview__select {
|
.fee-overview__member-link:hover { text-decoration: underline; }
|
||||||
padding: 6px 8px;
|
.fee-overview__badge { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
|
||||||
border: 1px solid var(--color-border);
|
.fee-overview__badge--paid { background-color: var(--color-success); color: white; }
|
||||||
border-radius: var(--border-radius);
|
.fee-overview__badge--unpaid { background-color: var(--color-warning); color: white; }
|
||||||
background: var(--color-main-background);
|
.fee-overview__badge--manual { background-color: var(--color-primary); color: white; }
|
||||||
color: var(--color-text);
|
.fee-overview__inline-input { padding: 4px 6px; border: 1px solid var(--color-primary); border-radius: var(--border-radius); background: var(--color-main-background); color: var(--color-text); width: 100px; }
|
||||||
}
|
|
||||||
|
|
||||||
/* Messages */
|
|
||||||
.fee-overview__message {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__message--error {
|
|
||||||
background: var(--color-error);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__message--success {
|
|
||||||
background: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Batch confirm dialog */
|
|
||||||
.fee-overview__dialog-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__dialog {
|
|
||||||
background: var(--color-main-background);
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: var(--border-radius-large);
|
|
||||||
max-width: 480px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__dialog h3 {
|
|
||||||
margin: 0 0 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__dialog ul {
|
|
||||||
margin: 8px 0 16px 0;
|
|
||||||
padding-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__dialog li {
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__dialog-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Summary bar */
|
|
||||||
.fee-overview__summary {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: var(--color-background-dark);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__summary-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__summary-label {
|
|
||||||
font-size: 0.8em;
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__summary-value {
|
|
||||||
font-size: 1.1em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__summary-item--paid .fee-overview__summary-value {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__summary-item--outstanding .fee-overview__summary-value {
|
|
||||||
color: var(--color-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__summary-item--manual .fee-overview__summary-value {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading */
|
|
||||||
.fee-overview__loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Table */
|
|
||||||
.fee-overview__table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 12px 12px;
|
|
||||||
border-bottom: 2px solid var(--color-border-dark);
|
|
||||||
font-weight: bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__td {
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__td--amount {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__td--notes {
|
|
||||||
max-width: 200px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__td--actions {
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__td--actions :deep(.button-vue) {
|
|
||||||
display: inline-flex;
|
|
||||||
margin: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__row--manual {
|
|
||||||
background: var(--color-primary-element-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__member-link {
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__member-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badges */
|
|
||||||
.fee-overview__badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--border-radius-pill);
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__badge--paid {
|
|
||||||
background-color: var(--color-success);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__badge--unpaid {
|
|
||||||
background-color: var(--color-warning);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fee-overview__badge--manual {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline edit */
|
|
||||||
.fee-overview__inline-input {
|
|
||||||
padding: 4px 6px;
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background: var(--color-main-background);
|
|
||||||
color: var(--color-text);
|
|
||||||
width: 100px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+88
-6
@@ -367,16 +367,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="res.errors && res.errors.length > 0" class="import-wizard__section">
|
<div v-if="res.errors && res.errors.length > 0" class="import-wizard__section">
|
||||||
<div v-for="(err, eIdx) in res.errors.slice(0, 5)" :key="'be-' + eIdx"
|
<h4>Fehler -- Daten korrigieren</h4>
|
||||||
class="import-wizard__error-row">
|
<p class="import-wizard__hint">
|
||||||
<strong>Zeile {{ err._rowIndex }}:</strong>
|
Klicke auf eine Zeile, um die Daten zu bearbeiten und fehlende Felder zu ergaenzen.
|
||||||
<ul>
|
</p>
|
||||||
<li v-for="(msg, mIdx) in err._errors" :key="mIdx">{{ msg }}</li>
|
|
||||||
</ul>
|
<div v-for="(err, eIdx) in res.errors.slice(0, 10)" :key="'be-' + eIdx"
|
||||||
|
class="import-wizard__error-row import-wizard__error-row--fixable">
|
||||||
|
<div class="import-wizard__error-header"
|
||||||
|
@click="toggleBundleErrorExpand(res.type, err._rowIndex)">
|
||||||
|
<span class="import-wizard__error-toggle">
|
||||||
|
{{ isBundleErrorExpanded(res.type, err._rowIndex) ? '\u25BC' : '\u25B6' }}
|
||||||
|
</span>
|
||||||
|
<strong>Zeile {{ err._rowIndex }}:</strong>
|
||||||
|
<span class="import-wizard__error-msgs">
|
||||||
|
{{ err._errors.join('; ') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="hasBundleCorrections(res.type, err._rowIndex)" class="import-wizard__error-badge">
|
||||||
|
bearbeitet
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isBundleErrorExpanded(res.type, err._rowIndex)" class="import-wizard__error-fields">
|
||||||
|
<div v-for="f in getBundleEditableFields(res)" :key="f.key"
|
||||||
|
class="import-wizard__error-field">
|
||||||
|
<label :class="{ 'import-wizard__error-field--required': f.required }">
|
||||||
|
{{ f.label }}{{ f.required ? ' *' : '' }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
:value="getBundleCorrectedValue(res.type, err._rowIndex, f.key, err[f.key])"
|
||||||
|
:placeholder="f.type === 'date' ? 'YYYY-MM-DD' : ''"
|
||||||
|
class="import-wizard__error-input"
|
||||||
|
:class="{ 'import-wizard__error-input--missing': f.required && !getBundleCorrectedValue(res.type, err._rowIndex, f.key, err[f.key]) }"
|
||||||
|
@input="onBundleCorrectionInput(res.type, err._rowIndex, f.key, $event.target.value)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasBundleCorrectedRows" class="import-wizard__revalidate">
|
||||||
|
<NcButton
|
||||||
|
:disabled="importStore.loading"
|
||||||
|
@click="importStore.runBundlePreview()">
|
||||||
|
<NcLoadingIcon v-if="importStore.loading" :size="20" />
|
||||||
|
<template v-else>Erneut pruefen</template>
|
||||||
|
</NcButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="import-wizard__nav">
|
<div class="import-wizard__nav">
|
||||||
<NcButton @click="importStore.step = 2">Zurueck</NcButton>
|
<NcButton @click="importStore.step = 2">Zurueck</NcButton>
|
||||||
<NcButton type="primary"
|
<NcButton type="primary"
|
||||||
@@ -715,6 +753,50 @@ function hasCorrections(rowIndex) {
|
|||||||
const hasCorrectedRows = computed(() => {
|
const hasCorrectedRows = computed(() => {
|
||||||
return Object.keys(importStore.corrections).length > 0
|
return Object.keys(importStore.corrections).length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Bundle inline error correction ──
|
||||||
|
|
||||||
|
const expandedBundleErrors = reactive({})
|
||||||
|
|
||||||
|
function toggleBundleErrorExpand(entityType, rowIndex) {
|
||||||
|
const key = entityType + ':' + rowIndex
|
||||||
|
expandedBundleErrors[key] = !expandedBundleErrors[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBundleErrorExpanded(entityType, rowIndex) {
|
||||||
|
return !!expandedBundleErrors[entityType + ':' + rowIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBundleEditableFields(res) {
|
||||||
|
return (res.targetFields || []).filter(f => f.key !== 'id')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBundleCorrectedValue(entityType, rowIndex, fieldKey, originalValue) {
|
||||||
|
const entityCorr = importStore.bundleCorrections[entityType]
|
||||||
|
if (entityCorr && entityCorr[rowIndex] && fieldKey in entityCorr[rowIndex]) {
|
||||||
|
return entityCorr[rowIndex][fieldKey]
|
||||||
|
}
|
||||||
|
return originalValue || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBundleCorrectionInput(entityType, rowIndex, fieldKey, value) {
|
||||||
|
if (!importStore.bundleCorrections[entityType]) {
|
||||||
|
importStore.bundleCorrections[entityType] = {}
|
||||||
|
}
|
||||||
|
if (!importStore.bundleCorrections[entityType][rowIndex]) {
|
||||||
|
importStore.bundleCorrections[entityType][rowIndex] = {}
|
||||||
|
}
|
||||||
|
importStore.bundleCorrections[entityType][rowIndex][fieldKey] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBundleCorrections(entityType, rowIndex) {
|
||||||
|
const c = importStore.bundleCorrections[entityType]
|
||||||
|
return c && c[rowIndex] && Object.keys(c[rowIndex]).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBundleCorrectedRows = computed(() => {
|
||||||
|
return Object.keys(importStore.bundleCorrections).length > 0
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
+39
-28
@@ -15,6 +15,9 @@
|
|||||||
placeholder="Bis"
|
placeholder="Bis"
|
||||||
@change="doFilter">
|
@change="doFilter">
|
||||||
</div>
|
</div>
|
||||||
|
<ColumnPicker :columns="allColumns"
|
||||||
|
:model-value="visibleKeys"
|
||||||
|
@update:model-value="setVisibleKeys" />
|
||||||
<NcButton type="primary" @click="showCreate = true">
|
<NcButton type="primary" @click="showCreate = true">
|
||||||
Neue Verletzung
|
Neue Verletzung
|
||||||
</NcButton>
|
</NcButton>
|
||||||
@@ -42,40 +45,33 @@
|
|||||||
<table v-else class="injury-list__table">
|
<table v-else class="injury-list__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Datum</th>
|
<th v-for="col in visibleColumns" :key="col.key">{{ col.label }}</th>
|
||||||
<th>Mitglied</th>
|
|
||||||
<th>Lager / Aktivität</th>
|
|
||||||
<th>Beschreibung</th>
|
|
||||||
<th>Beteiligte</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="injury in injuryStore.injuries"
|
<tr v-for="injury in injuryStore.injuries"
|
||||||
:key="injury.id"
|
:key="injury.id"
|
||||||
class="injury-list__row">
|
class="injury-list__row">
|
||||||
<td>{{ formatDate(injury.datum) }}</td>
|
<td v-for="col in visibleColumns" :key="col.key"
|
||||||
<td>
|
:class="{ 'injury-list__beschreibung': col.key === 'beschreibung' }">
|
||||||
<router-link :to="{ name: 'member-detail', params: { id: injury.memberId } }">
|
<template v-if="col.key === 'mitglied'">
|
||||||
{{ injury.memberVorname }} {{ injury.memberNachname }}
|
<router-link :to="{ name: 'member-detail', params: { id: injury.memberId } }">
|
||||||
</router-link>
|
{{ col.render(injury) }}
|
||||||
</td>
|
</router-link>
|
||||||
<td>{{ injury.lagerName || injury.activityName || '-' }}</td>
|
</template>
|
||||||
<td class="injury-list__beschreibung">
|
<template v-else-if="col.key === 'aktionen'">
|
||||||
{{ truncate(injury.beschreibung, 80) }}
|
<NcButton @click="editInjury(injury)">
|
||||||
</td>
|
Bearbeiten
|
||||||
<td>{{ injury.involvedMembers?.length || 0 }}</td>
|
</NcButton>
|
||||||
<td>
|
<NcButton @click="confirmDelete(injury)">
|
||||||
<NcButton @click="editInjury(injury)">
|
Löschen
|
||||||
Bearbeiten
|
</NcButton>
|
||||||
</NcButton>
|
</template>
|
||||||
<NcButton @click="confirmDelete(injury)">
|
<template v-else>{{ col.render(injury) }}</template>
|
||||||
Löschen
|
|
||||||
</NcButton>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="injuryStore.injuries.length === 0">
|
<tr v-if="injuryStore.injuries.length === 0">
|
||||||
<td colspan="6" class="injury-list__empty">
|
<td :colspan="visibleColumns.length" class="injury-list__empty">
|
||||||
Keine Verletzungen dokumentiert
|
Keine Verletzungen dokumentiert
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -94,6 +90,8 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { NcButton } from '@nextcloud/vue'
|
import { NcButton } from '@nextcloud/vue'
|
||||||
import { useInjuryStore } from '../stores/injuries.js'
|
import { useInjuryStore } from '../stores/injuries.js'
|
||||||
|
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||||
|
import ColumnPicker from '../components/ColumnPicker.vue'
|
||||||
import InjuryForm from '../components/InjuryForm.vue'
|
import InjuryForm from '../components/InjuryForm.vue'
|
||||||
import { formatDate } from '../utils/dateFormat.js'
|
import { formatDate } from '../utils/dateFormat.js'
|
||||||
|
|
||||||
@@ -104,13 +102,26 @@ const editingInjury = ref(null)
|
|||||||
const filterDateFrom = ref('')
|
const filterDateFrom = ref('')
|
||||||
const filterDateTo = ref('')
|
const filterDateTo = ref('')
|
||||||
|
|
||||||
// formatDate imported from utils/dateFormat.js
|
|
||||||
|
|
||||||
function truncate(text, maxLen) {
|
function truncate(text, maxLen) {
|
||||||
if (!text) return ''
|
if (!text) return ''
|
||||||
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text
|
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
{ key: 'datum', label: 'Datum', alwaysVisible: true, render: i => formatDate(i.datum) },
|
||||||
|
{ key: 'mitglied', label: 'Mitglied', render: i => `${i.memberVorname} ${i.memberNachname}` },
|
||||||
|
{ key: 'lager', label: 'Lager / Aktivität', render: i => i.lagerName || i.activityName || '-' },
|
||||||
|
{ key: 'beschreibung', label: 'Beschreibung', render: i => truncate(i.beschreibung, 80) },
|
||||||
|
{ key: 'beteiligte', label: 'Beteiligte', render: i => i.involvedMembers?.length || 0 },
|
||||||
|
{ key: 'aktionen', label: 'Aktionen', render: () => '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
|
||||||
|
'mv_injury_columns',
|
||||||
|
allColumns,
|
||||||
|
['datum', 'mitglied', 'lager', 'beschreibung', 'beteiligte', 'aktionen'],
|
||||||
|
)
|
||||||
|
|
||||||
function doFilter() {
|
function doFilter() {
|
||||||
const params = {}
|
const params = {}
|
||||||
if (filterDateFrom.value) params.dateFrom = filterDateFrom.value
|
if (filterDateFrom.value) params.dateFrom = filterDateFrom.value
|
||||||
@@ -137,7 +148,7 @@ async function handleSubmit(data) {
|
|||||||
}
|
}
|
||||||
closeForm()
|
closeForm()
|
||||||
injuryStore.fetchInjuries()
|
injuryStore.fetchInjuries()
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Error handled by store
|
// Error handled by store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-19
@@ -27,6 +27,9 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<ColumnPicker :columns="allColumns"
|
||||||
|
:model-value="visibleKeys"
|
||||||
|
@update:model-value="setVisibleKeys" />
|
||||||
<NcButton type="primary" @click="showCreate = true">
|
<NcButton type="primary" @click="showCreate = true">
|
||||||
Neues Lager
|
Neues Lager
|
||||||
</NcButton>
|
</NcButton>
|
||||||
@@ -48,12 +51,7 @@
|
|||||||
<table v-else class="lager-list__table">
|
<table v-else class="lager-list__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th v-for="col in visibleColumns" :key="col.key">{{ col.label }}</th>
|
||||||
<th>Startdatum</th>
|
|
||||||
<th>Enddatum</th>
|
|
||||||
<th>Ort</th>
|
|
||||||
<th>Teilnehmer</th>
|
|
||||||
<th>Aktionen</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -61,19 +59,17 @@
|
|||||||
:key="camp.id"
|
:key="camp.id"
|
||||||
class="lager-list__row"
|
class="lager-list__row"
|
||||||
@click="$router.push({ name: 'lager-detail', params: { id: camp.id } })">
|
@click="$router.push({ name: 'lager-detail', params: { id: camp.id } })">
|
||||||
<td>{{ camp.name }}</td>
|
<td v-for="col in visibleColumns" :key="col.key">
|
||||||
<td>{{ formatDate(camp.startdatum) }}</td>
|
<template v-if="col.key === 'aktionen'">
|
||||||
<td>{{ formatDate(camp.enddatum) }}</td>
|
<NcButton @click.stop="confirmDelete(camp)">
|
||||||
<td>{{ camp.ort || '-' }}</td>
|
Löschen
|
||||||
<td>{{ camp.teilnehmer?.length || 0 }}</td>
|
</NcButton>
|
||||||
<td>
|
</template>
|
||||||
<NcButton @click.stop="confirmDelete(camp)">
|
<template v-else>{{ col.render(camp) }}</template>
|
||||||
Löschen
|
|
||||||
</NcButton>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="lagerStore.camps.length === 0">
|
<tr v-if="lagerStore.camps.length === 0">
|
||||||
<td colspan="6" class="lager-list__empty">
|
<td :colspan="visibleColumns.length" class="lager-list__empty">
|
||||||
Keine Lager gefunden
|
Keine Lager gefunden
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -121,6 +117,8 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { NcButton } from '@nextcloud/vue'
|
import { NcButton } from '@nextcloud/vue'
|
||||||
import { useLagerStore } from '../stores/lager.js'
|
import { useLagerStore } from '../stores/lager.js'
|
||||||
import { useStufenStore } from '../stores/stufen.js'
|
import { useStufenStore } from '../stores/stufen.js'
|
||||||
|
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||||
|
import ColumnPicker from '../components/ColumnPicker.vue'
|
||||||
import { formatDate } from '../utils/dateFormat.js'
|
import { formatDate } from '../utils/dateFormat.js'
|
||||||
|
|
||||||
const lagerStore = useLagerStore()
|
const lagerStore = useLagerStore()
|
||||||
@@ -129,7 +127,21 @@ const stufenStore = useStufenStore()
|
|||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const newCamp = ref({ name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' })
|
const newCamp = ref({ name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' })
|
||||||
|
|
||||||
// formatDate imported from utils/dateFormat.js
|
const allColumns = [
|
||||||
|
{ key: 'name', label: 'Name', alwaysVisible: true, render: c => c.name },
|
||||||
|
{ key: 'startdatum', label: 'Startdatum', render: c => formatDate(c.startdatum) },
|
||||||
|
{ key: 'enddatum', label: 'Enddatum', render: c => formatDate(c.enddatum) },
|
||||||
|
{ key: 'ort', label: 'Ort', render: c => c.ort || '-' },
|
||||||
|
{ key: 'beschreibung', label: 'Beschreibung', render: c => c.beschreibung ? (c.beschreibung.length > 50 ? c.beschreibung.substring(0, 50) + '\u2026' : c.beschreibung) : '\u2014' },
|
||||||
|
{ key: 'teilnehmer', label: 'Teilnehmer', render: c => c.teilnehmer?.length || 0 },
|
||||||
|
{ key: 'aktionen', label: 'Aktionen', render: () => '' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
|
||||||
|
'mv_lager_columns',
|
||||||
|
allColumns,
|
||||||
|
['name', 'startdatum', 'enddatum', 'ort', 'teilnehmer', 'aktionen'],
|
||||||
|
)
|
||||||
|
|
||||||
function onYearChange(val) {
|
function onYearChange(val) {
|
||||||
lagerStore.filterYear = val ? parseInt(val) : null
|
lagerStore.filterYear = val ? parseInt(val) : null
|
||||||
@@ -143,10 +155,10 @@ function onStufeChange(val) {
|
|||||||
|
|
||||||
async function doCreate() {
|
async function doCreate() {
|
||||||
try {
|
try {
|
||||||
const camp = await lagerStore.createCamp(newCamp.value)
|
await lagerStore.createCamp(newCamp.value)
|
||||||
showCreate.value = false
|
showCreate.value = false
|
||||||
newCamp.value = { name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' }
|
newCamp.value = { name: '', startdatum: '', enddatum: '', ort: '', beschreibung: '' }
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Error handled by store
|
// Error handled by store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-240
@@ -3,6 +3,9 @@
|
|||||||
<div class="member-list__header">
|
<div class="member-list__header">
|
||||||
<h2>Mitglieder</h2>
|
<h2>Mitglieder</h2>
|
||||||
<div class="member-list__actions">
|
<div class="member-list__actions">
|
||||||
|
<ColumnPicker :columns="allColumns"
|
||||||
|
:model-value="visibleKeys"
|
||||||
|
@update:model-value="setVisibleKeys" />
|
||||||
<NcButton type="primary"
|
<NcButton type="primary"
|
||||||
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
|
@click="$router.push({ name: 'member-detail', params: { id: 'new' } })">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -63,31 +66,15 @@
|
|||||||
<table v-else class="member-list__table">
|
<table v-else class="member-list__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="member-list__th member-list__th--sortable"
|
<th v-for="col in visibleColumns" :key="col.key"
|
||||||
@click="toggleSort('nachname')">
|
class="member-list__th"
|
||||||
Name
|
:class="{ 'member-list__th--sortable': col.sortable }"
|
||||||
<SortIcon :field="'nachname'" :current-sort="sortField" :sort-asc="sortAsc" />
|
@click="col.sortable && toggleSort(col.sortField || col.key)">
|
||||||
</th>
|
{{ col.label }}
|
||||||
<th class="member-list__th member-list__th--sortable"
|
<SortIcon v-if="col.sortable"
|
||||||
@click="toggleSort('stufeId')">
|
:field="col.sortField || col.key"
|
||||||
Stufe
|
:current-sort="sortField"
|
||||||
<SortIcon :field="'stufeId'" :current-sort="sortField" :sort-asc="sortAsc" />
|
:sort-asc="sortAsc" />
|
||||||
</th>
|
|
||||||
<th class="member-list__th member-list__th--sortable"
|
|
||||||
@click="toggleSort('status')">
|
|
||||||
Status
|
|
||||||
<SortIcon :field="'status'" :current-sort="sortField" :sort-asc="sortAsc" />
|
|
||||||
</th>
|
|
||||||
<th class="member-list__th member-list__th--sortable"
|
|
||||||
@click="toggleSort('geburtsdatum')">
|
|
||||||
Geburtsdatum
|
|
||||||
<SortIcon :field="'geburtsdatum'" :current-sort="sortField" :sort-asc="sortAsc" />
|
|
||||||
</th>
|
|
||||||
<th class="member-list__th">
|
|
||||||
Alter
|
|
||||||
</th>
|
|
||||||
<th class="member-list__th">
|
|
||||||
Rolle
|
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -96,25 +83,13 @@
|
|||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="member-list__row"
|
class="member-list__row"
|
||||||
@click="$router.push({ name: 'member-detail', params: { id: member.id } })">
|
@click="$router.push({ name: 'member-detail', params: { id: member.id } })">
|
||||||
<td class="member-list__td">
|
<td v-for="col in visibleColumns" :key="col.key"
|
||||||
{{ member.nachname }}, {{ member.vorname }}
|
class="member-list__td">
|
||||||
</td>
|
<span v-if="col.key === 'status'"
|
||||||
<td class="member-list__td">
|
:class="'member-list__status member-list__status--' + member.status">
|
||||||
{{ getStufeNameById(member.stufeId) }}
|
{{ col.render(member) }}
|
||||||
</td>
|
|
||||||
<td class="member-list__td">
|
|
||||||
<span :class="'member-list__status member-list__status--' + member.status">
|
|
||||||
{{ formatStatus(member.status) }}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
<template v-else>{{ col.render(member) }}</template>
|
||||||
<td class="member-list__td">
|
|
||||||
{{ formatDate(member.geburtsdatum) }}
|
|
||||||
</td>
|
|
||||||
<td class="member-list__td">
|
|
||||||
{{ calculateAge(member.geburtsdatum) }}
|
|
||||||
</td>
|
|
||||||
<td class="member-list__td">
|
|
||||||
{{ formatRolle(member.rolle) }}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -143,6 +118,8 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||||
import { useMembersStore } from '../stores/members.js'
|
import { useMembersStore } from '../stores/members.js'
|
||||||
import { useStufenStore } from '../stores/stufen.js'
|
import { useStufenStore } from '../stores/stufen.js'
|
||||||
|
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||||
|
import ColumnPicker from '../components/ColumnPicker.vue'
|
||||||
import Plus from 'vue-material-design-icons/Plus.vue'
|
import Plus from 'vue-material-design-icons/Plus.vue'
|
||||||
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
|
||||||
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
|
import AccountGroup from 'vue-material-design-icons/AccountGroup.vue'
|
||||||
@@ -155,9 +132,74 @@ const stufenStore = useStufenStore()
|
|||||||
const sortField = ref('nachname')
|
const sortField = ref('nachname')
|
||||||
const sortAsc = ref(true)
|
const sortAsc = ref(true)
|
||||||
|
|
||||||
/**
|
// ── Column definitions ──
|
||||||
* Preset filter definitions (Issue #34).
|
|
||||||
*/
|
function getStufeNameById(stufeId) {
|
||||||
|
if (!stufeId) return '\u2014'
|
||||||
|
const stufe = stufenStore.stufen.find(s => s.id === stufeId)
|
||||||
|
return stufe ? stufe.name : '\u2014'
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateAge(geburtsdatum) {
|
||||||
|
if (!geburtsdatum) return '\u2014'
|
||||||
|
const birth = new Date(geburtsdatum)
|
||||||
|
const today = new Date()
|
||||||
|
let age = today.getFullYear() - birth.getFullYear()
|
||||||
|
const monthDiff = today.getMonth() - birth.getMonth()
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||||
|
age--
|
||||||
|
}
|
||||||
|
return age
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateMitgliedsdauer(eintritt) {
|
||||||
|
if (!eintritt) return '\u2014'
|
||||||
|
const start = new Date(eintritt)
|
||||||
|
const today = new Date()
|
||||||
|
let years = today.getFullYear() - start.getFullYear()
|
||||||
|
const monthDiff = today.getMonth() - start.getMonth()
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < start.getDate())) {
|
||||||
|
years--
|
||||||
|
}
|
||||||
|
if (years < 1) {
|
||||||
|
const months = (today.getFullYear() - start.getFullYear()) * 12 + today.getMonth() - start.getMonth()
|
||||||
|
return months <= 0 ? '< 1 Monat' : months + (months === 1 ? ' Monat' : ' Monate')
|
||||||
|
}
|
||||||
|
return years + (years === 1 ? ' Jahr' : ' Jahre')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMap = { aktiv: 'Aktiv', inaktiv: 'Inaktiv', geloescht: 'Gelöscht' }
|
||||||
|
const rolleMap = { mitglied: 'Mitglied', erziehungsberechtigter: 'Erziehungsberechtigter' }
|
||||||
|
const geschlechtMap = { maennlich: 'Männlich', weiblich: 'Weiblich', divers: 'Divers' }
|
||||||
|
const kvTypMap = { gesetzlich: 'Gesetzlich', privat: 'Privat' }
|
||||||
|
|
||||||
|
const allColumns = [
|
||||||
|
{ key: 'name', label: 'Name', sortable: true, sortField: 'nachname', alwaysVisible: true, render: m => m.nachname + ', ' + m.vorname },
|
||||||
|
{ key: 'stufe', label: 'Stufe', sortable: true, sortField: 'stufeId', render: m => getStufeNameById(m.stufeId) },
|
||||||
|
{ key: 'status', label: 'Status', sortable: true, sortField: 'status', render: m => statusMap[m.status] || m.status },
|
||||||
|
{ key: 'geburtsdatum', label: 'Geburtsdatum', sortable: true, sortField: 'geburtsdatum', render: m => formatDate(m.geburtsdatum) },
|
||||||
|
{ key: 'alter', label: 'Alter', sortable: false, render: m => calculateAge(m.geburtsdatum) },
|
||||||
|
{ key: 'rolle', label: 'Rolle', sortable: false, render: m => rolleMap[m.rolle] || m.rolle },
|
||||||
|
{ key: 'geschlecht', label: 'Geschlecht', sortable: false, render: m => geschlechtMap[m.geschlecht] || m.geschlecht || '\u2014' },
|
||||||
|
{ key: 'eintritt', label: 'Eintrittsdatum', sortable: true, sortField: 'eintritt', render: m => formatDate(m.eintritt) },
|
||||||
|
{ key: 'austritt', label: 'Austrittsdatum', sortable: true, sortField: 'austritt', render: m => formatDate(m.austritt) },
|
||||||
|
{ key: 'mitgliedsdauer', label: 'Mitgliedsdauer', sortable: true, sortField: 'eintritt', render: m => calculateMitgliedsdauer(m.eintritt) },
|
||||||
|
{ key: 'einwilligungDatum', label: 'Einwilligung', sortable: true, sortField: 'einwilligungDatum', render: m => formatDate(m.einwilligungDatum) },
|
||||||
|
{ key: 'kvTyp', label: 'KV-Typ', sortable: false, render: m => kvTypMap[m.kvTyp] || m.kvTyp || '\u2014' },
|
||||||
|
{ key: 'kvName', label: 'Krankenkasse', sortable: false, render: m => m.kvName || '\u2014' },
|
||||||
|
{ key: 'juleicaNummer', label: 'Juleica-Nr.', sortable: false, render: m => m.juleicaNummer || '\u2014' },
|
||||||
|
{ key: 'juleicaAblaufdatum', label: 'Juleica-Ablauf', sortable: true, sortField: 'juleicaAblaufdatum', render: m => formatDate(m.juleicaAblaufdatum) },
|
||||||
|
{ key: 'notizen', label: 'Notizen', sortable: false, render: m => m.notizen ? (m.notizen.length > 40 ? m.notizen.substring(0, 40) + '\u2026' : m.notizen) : '\u2014' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { visibleKeys, visibleColumns, setVisibleKeys } = useColumnVisibility(
|
||||||
|
'mv_member_columns',
|
||||||
|
allColumns,
|
||||||
|
['name', 'stufe', 'status', 'geburtsdatum', 'alter', 'rolle'],
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Filters ──
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
{ key: null, label: 'Alle' },
|
{ key: null, label: 'Alle' },
|
||||||
{ key: 'aktiv', label: 'Aktive' },
|
{ key: 'aktiv', label: 'Aktive' },
|
||||||
@@ -171,17 +213,12 @@ onMounted(() => {
|
|||||||
stufenStore.fetchStufen()
|
stufenStore.fetchStufen()
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a preset filter.
|
|
||||||
*/
|
|
||||||
function applyFilter(filterKey) {
|
function applyFilter(filterKey) {
|
||||||
searchQuery.value = ''
|
|
||||||
store.fetchWithFilter(filterKey)
|
store.fetchWithFilter(filterKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── Sorting ──
|
||||||
* Sort members locally by the selected field.
|
|
||||||
*/
|
|
||||||
const sortedMembers = computed(() => {
|
const sortedMembers = computed(() => {
|
||||||
const members = [...store.members]
|
const members = [...store.members]
|
||||||
const field = sortField.value
|
const field = sortField.value
|
||||||
@@ -191,9 +228,7 @@ const sortedMembers = computed(() => {
|
|||||||
let valA = a[field] ?? ''
|
let valA = a[field] ?? ''
|
||||||
let valB = b[field] ?? ''
|
let valB = b[field] ?? ''
|
||||||
|
|
||||||
// Special handling for age sorting (by geburtsdatum, reversed)
|
|
||||||
if (field === 'geburtsdatum') {
|
if (field === 'geburtsdatum') {
|
||||||
// Older birthday = older person = higher age
|
|
||||||
return asc
|
return asc
|
||||||
? String(valA).localeCompare(String(valB))
|
? String(valA).localeCompare(String(valB))
|
||||||
: String(valB).localeCompare(String(valA))
|
: String(valB).localeCompare(String(valA))
|
||||||
@@ -223,192 +258,30 @@ function reload() {
|
|||||||
store.clearError()
|
store.clearError()
|
||||||
store.fetchMembers()
|
store.fetchMembers()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a stufeId to the Stufe name, or return a dash if not found.
|
|
||||||
*/
|
|
||||||
function getStufeNameById(stufeId) {
|
|
||||||
if (!stufeId) return '\u2014'
|
|
||||||
const stufe = stufenStore.stufen.find(s => s.id === stufeId)
|
|
||||||
return stufe ? stufe.name : '\u2014'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate age from birthday string.
|
|
||||||
*/
|
|
||||||
// formatDate imported from utils/dateFormat.js
|
|
||||||
|
|
||||||
function calculateAge(geburtsdatum) {
|
|
||||||
if (!geburtsdatum) return '—'
|
|
||||||
const birth = new Date(geburtsdatum)
|
|
||||||
const today = new Date()
|
|
||||||
let age = today.getFullYear() - birth.getFullYear()
|
|
||||||
const monthDiff = today.getMonth() - birth.getMonth()
|
|
||||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
|
||||||
age--
|
|
||||||
}
|
|
||||||
return age
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format status for display.
|
|
||||||
*/
|
|
||||||
function formatStatus(status) {
|
|
||||||
const map = {
|
|
||||||
aktiv: 'Aktiv',
|
|
||||||
inaktiv: 'Inaktiv',
|
|
||||||
geloescht: 'Gelöscht',
|
|
||||||
}
|
|
||||||
return map[status] || status
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format rolle for display.
|
|
||||||
*/
|
|
||||||
function formatRolle(rolle) {
|
|
||||||
const map = {
|
|
||||||
mitglied: 'Mitglied',
|
|
||||||
erziehungsberechtigter: 'Erziehungsberechtigter',
|
|
||||||
}
|
|
||||||
return map[rolle] || rolle
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.member-list {
|
.member-list { padding: 20px; }
|
||||||
padding: 20px;
|
.member-list__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; flex-wrap: wrap; gap: 12px; }
|
||||||
}
|
.member-list__header h2 { margin: 0; }
|
||||||
|
.member-list__actions { display: flex; gap: 12px; align-items: center; }
|
||||||
.member-list__header {
|
.member-list__loading { display: flex; justify-content: center; margin-top: 60px; }
|
||||||
display: flex;
|
.member-list__table { width: 100%; border-collapse: collapse; }
|
||||||
justify-content: space-between;
|
.member-list__th { text-align: left; padding: 12px 16px; border-bottom: 2px solid var(--color-border-dark); font-weight: bold; white-space: nowrap; }
|
||||||
align-items: center;
|
.member-list__th--sortable { cursor: pointer; user-select: none; }
|
||||||
margin-bottom: 20px;
|
.member-list__th--sortable:hover { background-color: var(--color-background-hover); }
|
||||||
flex-wrap: wrap;
|
.member-list__row { cursor: pointer; transition: background-color 0.15s ease; }
|
||||||
gap: 12px;
|
.member-list__row:hover { background-color: var(--color-background-hover); }
|
||||||
}
|
.member-list__td { padding: 10px 16px; border-bottom: 1px solid var(--color-border); }
|
||||||
|
.member-list__status { display: inline-block; padding: 2px 8px; border-radius: var(--border-radius-pill); font-size: 0.85em; font-weight: 500; }
|
||||||
.member-list__header h2 {
|
.member-list__status--aktiv { background-color: var(--color-success); color: white; }
|
||||||
margin: 0;
|
.member-list__status--inaktiv { background-color: var(--color-warning); color: white; }
|
||||||
}
|
.member-list__status--geloescht { background-color: var(--color-error); color: white; }
|
||||||
|
.member-list__pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 20px; padding: 12px 0; }
|
||||||
.member-list__actions {
|
.member-list__page-info { color: var(--color-text-lighter); }
|
||||||
display: flex;
|
.member-list__filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
gap: 12px;
|
.member-list__filter { padding: 6px 14px; border: 1px solid var(--color-border); border-radius: var(--border-radius-pill); background: var(--color-main-background); color: var(--color-text); cursor: pointer; font-size: 0.85em; font-weight: 500; transition: all 0.15s ease; }
|
||||||
align-items: center;
|
.member-list__filter:hover { background: var(--color-background-hover); border-color: var(--color-primary); }
|
||||||
}
|
.member-list__filter--active { background: var(--color-primary); color: white; border-color: var(--color-primary); }
|
||||||
|
.member-list__filter--active:hover { background: var(--color-primary-element-hover); }
|
||||||
|
|
||||||
.member-list__loading {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-top: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 2px solid var(--color-border-dark);
|
|
||||||
font-weight: bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__th--sortable {
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__th--sortable:hover {
|
|
||||||
background-color: var(--color-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__row {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__row:hover {
|
|
||||||
background-color: var(--color-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__td {
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__status {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--border-radius-pill);
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__status--aktiv {
|
|
||||||
background-color: var(--color-success);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__status--inaktiv {
|
|
||||||
background-color: var(--color-warning);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__status--geloescht {
|
|
||||||
background-color: var(--color-error);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__page-info {
|
|
||||||
color: var(--color-text-lighter);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quick filter chips (Issue #34) */
|
|
||||||
.member-list__filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__filter {
|
|
||||||
padding: 6px 14px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius-pill);
|
|
||||||
background: var(--color-main-background);
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__filter:hover {
|
|
||||||
background: var(--color-background-hover);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__filter--active {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list__filter--active:hover {
|
|
||||||
background: var(--color-primary-element-hover);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+41
-15
@@ -84,15 +84,16 @@
|
|||||||
name="Keine Ergebnisse"
|
name="Keine Ergebnisse"
|
||||||
description="Die Abfrage hat keine Mitglieder gefunden." />
|
description="Die Abfrage hat keine Mitglieder gefunden." />
|
||||||
|
|
||||||
<table v-else class="query-view__table">
|
<template v-else>
|
||||||
|
<div class="query-view__results-actions">
|
||||||
|
<ColumnPicker :columns="resultColumns"
|
||||||
|
:model-value="visibleKeys"
|
||||||
|
@update:model-value="setVisibleKeys" />
|
||||||
|
</div>
|
||||||
|
<table class="query-view__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nachname</th>
|
<th v-for="col in visibleResultColumns" :key="col.key">{{ col.label }}</th>
|
||||||
<th>Vorname</th>
|
|
||||||
<th>Geburtsdatum</th>
|
|
||||||
<th>Rolle</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Eintritt</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -100,19 +101,17 @@
|
|||||||
:key="member.id"
|
:key="member.id"
|
||||||
class="query-view__result-row"
|
class="query-view__result-row"
|
||||||
@click="openMember(member.id)">
|
@click="openMember(member.id)">
|
||||||
<td>{{ member.nachname }}</td>
|
<td v-for="col in visibleResultColumns" :key="col.key">
|
||||||
<td>{{ member.vorname }}</td>
|
<span v-if="col.key === 'status'"
|
||||||
<td>{{ formatDate(member.geburtsdatum) }}</td>
|
:class="'query-view__status query-view__status--' + member.status">
|
||||||
<td>{{ member.rolle }}</td>
|
{{ col.render(member) }}
|
||||||
<td>
|
|
||||||
<span :class="'query-view__status query-view__status--' + member.status">
|
|
||||||
{{ member.status }}
|
|
||||||
</span>
|
</span>
|
||||||
|
<template v-else>{{ col.render(member) }}</template>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDate(member.eintritt) }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,6 +123,8 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
import { NcButton, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
|
||||||
import { useQueriesStore } from '../stores/queries.js'
|
import { useQueriesStore } from '../stores/queries.js'
|
||||||
|
import { useColumnVisibility } from '../utils/useColumnVisibility.js'
|
||||||
|
import ColumnPicker from '../components/ColumnPicker.vue'
|
||||||
import QueryBuilderComponent from '../components/QueryBuilder.vue'
|
import QueryBuilderComponent from '../components/QueryBuilder.vue'
|
||||||
import Delete from 'vue-material-design-icons/Delete.vue'
|
import Delete from 'vue-material-design-icons/Delete.vue'
|
||||||
import Play from 'vue-material-design-icons/Play.vue'
|
import Play from 'vue-material-design-icons/Play.vue'
|
||||||
@@ -139,6 +140,25 @@ const showSaveDialog = ref(false)
|
|||||||
const saveName = ref('')
|
const saveName = ref('')
|
||||||
const hasExecuted = ref(false)
|
const hasExecuted = ref(false)
|
||||||
|
|
||||||
|
const rolleMap = { mitglied: 'Mitglied', erziehungsberechtigter: 'Erziehungsberechtigter' }
|
||||||
|
const statusMap = { aktiv: 'Aktiv', inaktiv: 'Inaktiv', geloescht: 'Gelöscht' }
|
||||||
|
|
||||||
|
const resultColumns = [
|
||||||
|
{ key: 'nachname', label: 'Nachname', alwaysVisible: true, render: m => m.nachname },
|
||||||
|
{ key: 'vorname', label: 'Vorname', render: m => m.vorname },
|
||||||
|
{ key: 'geburtsdatum', label: 'Geburtsdatum', render: m => formatDate(m.geburtsdatum) },
|
||||||
|
{ key: 'rolle', label: 'Rolle', render: m => rolleMap[m.rolle] || m.rolle },
|
||||||
|
{ key: 'status', label: 'Status', render: m => statusMap[m.status] || m.status },
|
||||||
|
{ key: 'eintritt', label: 'Eintritt', render: m => formatDate(m.eintritt) },
|
||||||
|
{ key: 'austritt', label: 'Austritt', render: m => formatDate(m.austritt) },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { visibleKeys, visibleColumns: visibleResultColumns, setVisibleKeys } = useColumnVisibility(
|
||||||
|
'mv_query_columns',
|
||||||
|
resultColumns,
|
||||||
|
['nachname', 'vorname', 'geburtsdatum', 'rolle', 'status', 'eintritt'],
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
store.fetchFields(),
|
store.fetchFields(),
|
||||||
@@ -324,6 +344,12 @@ function openMember(id) {
|
|||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.query-view__results-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.query-view__table {
|
.query-view__table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use OCA\Mitgliederverwaltung\Db\PermissionMapper;
|
|||||||
use OCA\Mitgliederverwaltung\Service\PermissionService;
|
use OCA\Mitgliederverwaltung\Service\PermissionService;
|
||||||
use OCA\Mitgliederverwaltung\Service\ValidationException;
|
use OCA\Mitgliederverwaltung\Service\ValidationException;
|
||||||
use OCP\AppFramework\Db\DoesNotExistException;
|
use OCP\AppFramework\Db\DoesNotExistException;
|
||||||
|
use OCP\IGroupManager;
|
||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
@@ -19,14 +20,19 @@ class PermissionServiceTest extends TestCase {
|
|||||||
private PermissionService $service;
|
private PermissionService $service;
|
||||||
private PermissionMapper&MockObject $permissionMapper;
|
private PermissionMapper&MockObject $permissionMapper;
|
||||||
private MemberMapper&MockObject $memberMapper;
|
private MemberMapper&MockObject $memberMapper;
|
||||||
|
private IGroupManager&MockObject $groupManager;
|
||||||
|
|
||||||
protected function setUp(): void {
|
protected function setUp(): void {
|
||||||
$this->permissionMapper = $this->createMock(PermissionMapper::class);
|
$this->permissionMapper = $this->createMock(PermissionMapper::class);
|
||||||
$this->memberMapper = $this->createMock(MemberMapper::class);
|
$this->memberMapper = $this->createMock(MemberMapper::class);
|
||||||
|
$this->groupManager = $this->createMock(IGroupManager::class);
|
||||||
|
// By default, NC admin group check returns false so existing tests pass unchanged
|
||||||
|
$this->groupManager->method('isInGroup')->willReturn(false);
|
||||||
|
|
||||||
$this->service = new PermissionService(
|
$this->service = new PermissionService(
|
||||||
$this->permissionMapper,
|
$this->permissionMapper,
|
||||||
$this->memberMapper
|
$this->memberMapper,
|
||||||
|
$this->groupManager
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 VueLoaderPlugin(),
|
||||||
new webpack.DefinePlugin({
|
new webpack.DefinePlugin({
|
||||||
appName: JSON.stringify('mitgliederverwaltung'),
|
appName: JSON.stringify('mitgliederverwaltung'),
|
||||||
appVersion: JSON.stringify('0.1.5'),
|
appVersion: JSON.stringify('0.2.0'),
|
||||||
}),
|
}),
|
||||||
new webpack.optimize.LimitChunkCountPlugin({
|
new webpack.optimize.LimitChunkCountPlugin({
|
||||||
maxChunks: 1,
|
maxChunks: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user