feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung

- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten
- Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden
- Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt
- Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:54:58 +02:00
parent 5ef7b0c6cc
commit 6698ef1a92
23 changed files with 2061 additions and 30 deletions
@@ -7,6 +7,7 @@ import { DkvController } from '../dkv/dkv.controller';
import { ModuleGrantsController } from '../groups/module-grants.controller'; import { ModuleGrantsController } from '../groups/module-grants.controller';
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller'; import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller'; import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
import { ProxmoxController } from '../proxmox/proxmox.controller'; import { ProxmoxController } from '../proxmox/proxmox.controller';
import { TendersController } from '../tenders/tenders.controller'; import { TendersController } from '../tenders/tenders.controller';
import { ModuleRegistryController } from './module-registry.controller'; import { ModuleRegistryController } from './module-registry.controller';
@@ -70,6 +71,19 @@ describe('Umgestellte Handler (Verwalten)', () => {
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
}); });
it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])(
'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status',
(name) => {
expectManage(NextcloudStatusController, name, 'nextcloud-status');
},
);
it.each(['list', 'logo'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(NextcloudStatusController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => { it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev'); expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev'); expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { checkLogoUpload, NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
const GIF = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
const WEBP = [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50];
describe('checkLogoUpload', () => {
it.each([
['PNG', PNG, 'image/png'],
['JPEG', JPEG, 'image/jpeg'],
['GIF', GIF, 'image/gif'],
['WebP', WEBP, 'image/webp'],
])('erkennt %s', (_name, bytes, mime) => {
expect(checkLogoUpload(Buffer.from([...bytes, 1, 2, 3]))).toBe(mime);
});
it('lehnt SVG-Text, PDF, leere Dateien und umbenanntes HTML ab', () => {
expect(
checkLogoUpload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
).toBeNull();
expect(checkLogoUpload(Buffer.from('%PDF-1.7 ...'))).toBeNull();
expect(checkLogoUpload(Buffer.alloc(0))).toBeNull();
expect(checkLogoUpload(Buffer.from('<html><script>alert(1)</script></html>'))).toBeNull();
});
it('genau 1 MiB ist erlaubt, ein Byte mehr nicht', () => {
const ok = Buffer.concat([
Buffer.from(PNG),
Buffer.alloc(NEXTCLOUD_LOGO_MAX_BYTES - PNG.length),
]);
expect(ok.length).toBe(NEXTCLOUD_LOGO_MAX_BYTES);
expect(checkLogoUpload(ok)).toBe('image/png');
expect(checkLogoUpload(Buffer.concat([ok, Buffer.from([0])]))).toBeNull();
});
});
@@ -0,0 +1,21 @@
import { type DashboardImageMime, detectImageMime } from '../dashboard/dashboard-image-rules';
/**
* Regeln fuer hochgeladene Cloud-Logos (quick-261002-k67, L-02, D-A).
* Kein Nest, kein Prisma — direkt an den Bytes testbar.
*
* Erlaubt sind PNG, JPEG, GIF und WebP bis 1 MiB. KEIN SVG: ein SVG kann
* Skript tragen und wuerde, direkt im Tab geoeffnet, unter der Adresse von
* Tessera laufen. Der Typ kommt aus den Magic Bytes, nie aus dem vom Browser
* behaupteten `mimetype` oder der Dateiendung (Muster T-PI9-01); der erkannte
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-k67-02).
*/
/** Hoechstgroesse: 1 MiB (multer `limits.fileSize` an der Route + zweites Netz im Dienst). */
export const NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024;
/** Erkannter Bildtyp oder `null`, wenn leer, zu gross oder kein erlaubtes Bildformat. */
export function checkLogoUpload(buffer: Uint8Array): DashboardImageMime | null {
if (buffer.length === 0 || buffer.length > NEXTCLOUD_LOGO_MAX_BYTES) return null;
return detectImageMime(buffer);
}
@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest';
import {
NEXTCLOUD_CRON,
NEXTCLOUD_JOB_NAME,
NextcloudStatusSchedulerService,
} from './nextcloud-status-scheduler.service';
function makeFakeRegistry() {
const jobs = new Map<string, any>();
return {
__jobs: jobs,
addCronJob: vi.fn((name: string, job: any) => {
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
jobs.set(name, job);
}),
};
}
function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: string[] = []) {
const registry = makeFakeRegistry();
const calls: Array<[string, string]> = [];
const service = {
loadAllInstancesForScheduler: vi.fn(async () => rows),
checkInstance: vi.fn(async (tenantId: string, id: string) => {
calls.push([tenantId, id]);
if (failFor.includes(id)) throw new Error(`boom ${id}`);
}),
};
const release = { refresh: vi.fn(async () => undefined) };
const scheduler = new NextcloudStatusSchedulerService(
registry as any,
service as any,
release as any,
);
const logger = (scheduler as any).logger;
const logSpy = vi.spyOn(logger, 'log').mockImplementation(() => undefined);
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
return { registry, service, release, scheduler, calls, logSpy, errorSpy, warnSpy };
}
describe('NextcloudStatusSchedulerService', () => {
it('registriert genau einen stuendlichen Auftrag ohne Datenbankzugriff und startet ihn', async () => {
const { registry, service, release, scheduler } = makeScheduler();
await scheduler.onApplicationBootstrap();
expect(registry.addCronJob).toHaveBeenCalledTimes(1);
expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true);
const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME);
expect(job.cronTime.source).toBe(NEXTCLOUD_CRON);
expect(NEXTCLOUD_CRON).toBe('0 * * * *');
expect(job.isActive ?? job.running).toBeTruthy();
expect(service.loadAllInstancesForScheduler).not.toHaveBeenCalled();
expect(release.refresh).toHaveBeenCalledTimes(1);
job.stop();
});
it('ein Fehler beim Start wird protokolliert und nicht weitergeworfen', async () => {
const { registry, scheduler, errorSpy } = makeScheduler();
registry.addCronJob.mockImplementation(() => {
throw new Error('registry kaputt');
});
await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
});
it('tick prueft jede Cloud an ihren eigenen Mandanten gebunden', async () => {
const rows = [
{ id: 'a1', tenantId: 'tA' },
{ id: 'b1', tenantId: 'tB' },
{ id: 'a2', tenantId: 'tA' },
];
const { scheduler, calls } = makeScheduler(rows);
await scheduler.tick();
expect(calls).toHaveLength(3);
expect(calls).toContainEqual(['tA', 'a1']);
expect(calls).toContainEqual(['tA', 'a2']);
expect(calls).toContainEqual(['tB', 'b1']);
});
it('eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
const rows = [
{ id: 'x', tenantId: 't' },
{ id: 'y', tenantId: 't' },
{ id: 'z', tenantId: 't' },
];
const { scheduler, calls, errorSpy } = makeScheduler(rows, ['x']);
await scheduler.tick();
expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y', 'z']);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('ein Durchlauf, waehrend der vorige noch laeuft, wird uebersprungen', async () => {
const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]);
let release!: () => void;
service.checkInstance.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const first = scheduler.tick();
await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1));
await scheduler.tick();
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalled();
release();
await first;
await scheduler.tick();
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,117 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { NextcloudReleaseService } from './nextcloud-release.service';
import {
CHECK_CONCURRENCY,
NextcloudStatusService,
runWithConcurrency,
} from './nextcloud-status.service';
/**
* CronJob constructor — resolved at runtime via require() because `cron` is
* a transitive dependency of @nestjs/schedule (not a direct api dep under
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
* it as a peer dep. Reuses the exact ProxmoxSchedulerService resolution
* workaround verbatim.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
require('cron').CronJob as new (
cronTime: string,
onTick: () => void,
) => { start(): void };
/** Name des Auftrags in der Registry. */
export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll';
/** Jede volle Stunde (L-08). */
export const NEXTCLOUD_CRON = '0 * * * *';
/**
* NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds
* (quick-261002-k67, L-08, D-C).
*
* Lebenszyklus: `OnApplicationBootstrap`, NICHT `OnModuleInit` — die
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis zum
* zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
*
* Ein einziger globaler Auftrag statt je einem je Mandant: das Intervall ist
* fest (stuendlich), es gibt keine Einstellung je Zeile. Der Auftrag wird
* beim Start OHNE Datenbankzugriff registriert — eine frische Datenbank kann
* daher nie ohne Auftrag enden, auch wenn beim Start noch keine Cloud
* eingetragen ist. Jeder Durchlauf liest Kennung und Mandant aller Clouds
* (ein einziger Systemkontext-Aufruf) und prueft dann jede Cloud an ihren
* eigenen Mandanten gebunden, mit hoechstens vier gleichzeitig. Ein
* Ueberlappungsschutz ueberspringt einen Durchlauf, solange der vorige laeuft.
*/
@Injectable()
export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
private readonly logger = new Logger(NextcloudStatusSchedulerService.name);
private running = false;
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly service: NextcloudStatusService,
private readonly release: NextcloudReleaseService,
) {}
/**
* Registriert und startet den Auftrag und stoesst das Aufwaermen der
* Vergleichsdaten an (ohne zu warten). Ein Fehler wird gefangen und
* protokolliert, nie weitergeworfen — die Anwendung startet trotzdem.
*/
async onApplicationBootstrap(): Promise<void> {
try {
const job = new CronJobClass(NEXTCLOUD_CRON, () => {
this.tick().catch((err) =>
this.logger.error(`Nextcloud poll tick failed: ${(err as Error).message}`),
);
});
// Cast noetig — dasselbe Muster wie ProxmoxSchedulerService.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any);
job.start();
this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`);
void this.release.refresh().catch(() => undefined);
} catch (err) {
this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`);
}
}
/** Ein Durchlauf ueber alle Clouds aller Mandanten. */
async tick(): Promise<void> {
if (this.running) {
this.logger.warn('Nextcloud poll tick skipped — previous run still active');
return;
}
this.running = true;
try {
const rows = await this.service.loadAllInstancesForScheduler();
// Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt.
const byTenant = new Map<string, string[]>();
for (const row of rows) {
const ids = byTenant.get(row.tenantId) ?? [];
ids.push(row.id);
byTenant.set(row.tenantId, ids);
}
const work: { tenantId: string; id: string }[] = [];
for (const [tenantId, ids] of byTenant) {
for (const id of ids) work.push({ tenantId, id });
}
await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => {
try {
await this.service.checkInstance(tenantId, id);
} catch (err) {
this.logger.error(
`Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`,
);
}
});
} finally {
this.running = false;
}
}
}
@@ -15,15 +15,37 @@ describe('NextcloudStatusController Metadaten', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard); expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard);
}); });
it('list bleibt auf Benutzen-Ebene', () => { it.each(['list', 'logo'])('%s bleibt auf Benutzen-Ebene', (name) => {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.list as object)).toBeUndefined(); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto.list as object)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name] as object)).toBeUndefined();
}); });
it.each(['create', 'checkOne'])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => { it.each([
'create',
'update',
'remove',
'checkAll',
'checkOne',
'uploadLogo',
'removeLogo',
])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => {
const fn = proto[name] as object; const fn = proto[name] as object;
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status'); expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status');
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
}); });
it('die statische Route POST instances/check steht vor jedem Handler mit :id (kein 404-Shadowing)', () => {
const names = Object.getOwnPropertyNames(NextcloudStatusController.prototype).filter(
(n) => n !== 'constructor' && typeof proto[n] === 'function',
);
const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string;
expect(pathOf('checkAll')).toBe('instances/check');
const checkIndex = names.indexOf('checkAll');
const idHandlers = names.filter((n) => (pathOf(n) ?? '').includes(':id'));
expect(idHandlers.length).toBeGreaterThan(0);
for (const name of idHandlers) {
expect(checkIndex, `checkAll vor ${name}`).toBeLessThan(names.indexOf(name));
}
});
}); });
@@ -1,7 +1,26 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, Req } from '@nestjs/common'; import {
import type { AuthenticatedRequest } from '../auth/types/auth-user'; Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Post,
Put,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard'; import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto'; import {
CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto';
import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
import { NextcloudStatusService } from './nextcloud-status.service'; import { NextcloudStatusService } from './nextcloud-status.service';
/** /**
@@ -9,14 +28,15 @@ import { NextcloudStatusService } from './nextcloud-status.service';
* Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich * Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich
* aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query. * aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query.
* *
* Rechte (L-09): Lesen (`GET instances`, Logo-Abruf) steht jedem Benutzer mit * Rechte (L-09): Lesen (`GET instances`, `GET instances/:id/logo`) steht jedem
* Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich * Benutzer mit Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
* `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der * `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der
* Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein * Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein
* Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren. * Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren.
* *
* Routenreihenfolge: statische Pfade (`instances/check`) stehen VOR allen * Routenreihenfolge: statische Pfade (`instances/check`) stehen VOR allen
* Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing). * Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing,
* T-k67-08; die Reihenfolge ist im Controller-Spec festgeschrieben).
*/ */
@Controller('modules/nextcloud-status') @Controller('modules/nextcloud-status')
@UseModule('nextcloud-status') @UseModule('nextcloud-status')
@@ -42,9 +62,73 @@ export class NextcloudStatusController {
return this.service.createInstance(this.requireTenantId(req), dto); return this.service.createInstance(this.requireTenantId(req), dto);
} }
/** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */
@Post('instances/check')
@ModuleManage('nextcloud-status')
async checkAll(@Req() req: AuthenticatedRequest) {
return this.service.checkAllForTenant(this.requireTenantId(req));
}
@Put('instances/:id')
@ModuleManage('nextcloud-status')
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateNextcloudInstanceDto,
) {
return this.service.updateInstance(this.requireTenantId(req), id, dto);
}
@Delete('instances/:id')
@ModuleManage('nextcloud-status')
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const deleted = await this.service.deleteInstance(this.requireTenantId(req), id);
return { deleted };
}
@Post('instances/:id/check') @Post('instances/:id/check')
@ModuleManage('nextcloud-status') @ModuleManage('nextcloud-status')
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) { async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.checkInstance(this.requireTenantId(req), id); return this.service.checkInstance(this.requireTenantId(req), id);
} }
/**
* Logo-Abruf fuer jeden Benutzer mit Modulzugriff (die Kachel laedt es per
* <img>). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private
* Zwischenspeicherung (Adresse traegt clientseitig `?v=<logoVersion>`),
* `nosniff` und eine Sandbox-CSP — Muster `favorites.controller.ts` `getIcon`
* (T-k67-02).
*/
@Get('instances/:id/logo')
async logo(@Req() req: AuthenticatedRequest, @Param('id') id: string, @Res() res: Response) {
const { data, mime } = await this.service.getLogo(this.requireTenantId(req), id);
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'private, max-age=86400');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
res.send(data);
}
/**
* Logo hochladen: Groessengrenze JE ROUTE (multers `LIMIT_FILE_SIZE` wird von
* Nest auf 413 abgebildet); Typ und Besitz pruefen im Dienst.
*/
@Post('instances/:id/logo')
@ModuleManage('nextcloud-status')
@UseInterceptors(
FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } }),
)
async uploadLogo(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@UploadedFile() file?: UploadedFileLike,
) {
return this.service.uploadLogo(this.requireTenantId(req), id, file);
}
@Delete('instances/:id/logo')
@ModuleManage('nextcloud-status')
async removeLogo(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.removeLogo(this.requireTenantId(req), id);
}
} }
@@ -5,6 +5,7 @@ import { NextcloudReleaseService } from './nextcloud-release.service';
import { NextcloudStatusController } from './nextcloud-status.controller'; import { NextcloudStatusController } from './nextcloud-status.controller';
import { seedNextcloudStatusModule } from './nextcloud-status.seed'; import { seedNextcloudStatusModule } from './nextcloud-status.seed';
import { NextcloudStatusService } from './nextcloud-status.service'; import { NextcloudStatusService } from './nextcloud-status.service';
import { NextcloudStatusSchedulerService } from './nextcloud-status-scheduler.service';
/** /**
* NestJS module for the Nextcloud-Status feature (quick-261002-k67). * NestJS module for the Nextcloud-Status feature (quick-261002-k67).
@@ -13,7 +14,7 @@ import { NextcloudStatusService } from './nextcloud-status.service';
@Module({ @Module({
imports: [ModuleRegistryModule], imports: [ModuleRegistryModule],
controllers: [NextcloudStatusController], controllers: [NextcloudStatusController],
providers: [NextcloudStatusService, NextcloudReleaseService], providers: [NextcloudStatusService, NextcloudReleaseService, NextcloudStatusSchedulerService],
}) })
export class NextcloudStatusModule implements OnModuleInit { export class NextcloudStatusModule implements OnModuleInit {
private readonly logger = new Logger(NextcloudStatusModule.name); private readonly logger = new Logger(NextcloudStatusModule.name);
@@ -10,7 +10,7 @@ vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
return { ...actual, fetchNextcloudStatus: vi.fn() }; return { ...actual, fetchNextcloudStatus: vi.fn() };
}); });
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import type { NextcloudReference } from './nextcloud-rating'; import type { NextcloudReference } from './nextcloud-rating';
import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service'; import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service';
import { fetchNextcloudStatus } from './nextcloud-status-fetch'; import { fetchNextcloudStatus } from './nextcloud-status-fetch';
@@ -177,4 +177,199 @@ describe('NextcloudStatusService', () => {
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException); await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
expect(fetchNextcloudStatus).not.toHaveBeenCalled(); expect(fetchNextcloudStatus).not.toHaveBeenCalled();
}); });
describe('Schreibwege (Aufgabe 2)', () => {
const OK_RESULT = {
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
productName: null,
errorKind: null,
errorDetail: null,
};
beforeEach(() => {
prisma.nextcloudInstance.delete = vi.fn();
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
});
prisma.nextcloudInstance.update.mockResolvedValue(makeRow());
vi.mocked(fetchNextcloudStatus).mockResolvedValue(OK_RESULT);
});
it('updateInstance: nur Name -> kein Neuabruf', async () => {
await service.updateInstance('t1', 'i1', { customerName: ' Neu ' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
customerName: 'Neu',
});
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
it('updateInstance: neue Adresse wird normalisiert und neu geprueft, unveraenderte nicht', async () => {
await service.updateInstance('t1', 'i1', { baseUrl: 'https://neu.example.de/index.php/' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
baseUrl: 'https://neu.example.de',
});
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
vi.mocked(fetchNextcloudStatus).mockClear();
await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de/' });
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
BadRequestException,
);
});
it('updateInstance: nicht leere Logo-Adresse ersetzt den Upload, leere entfernt nur die Adresse', async () => {
await service.updateInstance('t1', 'i1', { logoUrl: 'https://logo.example.de/a.png' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
logoUrl: 'https://logo.example.de/a.png',
logoData: null,
logoMime: null,
logoVersion: { increment: 1 },
});
await service.updateInstance('t1', 'i1', { logoUrl: '' });
expect(prisma.nextcloudInstance.update.mock.calls[1][0].data).toEqual({
logoUrl: null,
logoVersion: { increment: 1 },
});
});
it('uploadLogo speichert Bytes und erkannten Typ, entfernt die Adresse und erhoeht die Version', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]);
await service.uploadLogo('t1', 'i1', {
buffer: png,
originalname: 'x.exe',
mimetype: 'text/html',
size: png.length,
});
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
expect(Buffer.from(data.logoData)).toEqual(png);
expect(data).toMatchObject({
logoMime: 'image/png',
logoUrl: null,
logoVersion: { increment: 1 },
});
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT);
});
it('uploadLogo lehnt Nicht-Bilder, fehlende Dateien und fremde Kennungen ab', async () => {
const html = Buffer.from('<html></html>');
await expect(
service.uploadLogo('t1', 'i1', {
buffer: html,
originalname: 'a.png',
mimetype: 'image/png',
size: html.length,
}),
).rejects.toThrow(
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
);
await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException);
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
await expect(
service.uploadLogo('t1', 'fremd', {
buffer: png,
originalname: 'a.png',
mimetype: 'image/png',
size: png.length,
}),
).rejects.toThrow(NotFoundException);
expect(prisma.nextcloudInstance.update).not.toHaveBeenCalled();
});
it('removeLogo loescht Bytes und Typ und erhoeht die Version', async () => {
await service.removeLogo('t1', 'i1');
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
logoData: null,
logoMime: null,
logoVersion: { increment: 1 },
});
});
it('getLogo liefert Bytes und Typ, ohne Logo NotFound', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
logoData: new Uint8Array([1, 2, 3]),
logoMime: 'image/png',
});
const logo = await service.getLogo('t1', 'i1');
expect(logo.mime).toBe('image/png');
expect([...logo.data]).toEqual([1, 2, 3]);
expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
id: 'i1',
tenantId: 't1',
});
prisma.nextcloudInstance.findFirst.mockResolvedValue({ logoData: null, logoMime: null });
await expect(service.getLogo('t1', 'i1')).rejects.toThrow(NotFoundException);
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(service.getLogo('t1', 'x')).rejects.toThrow(NotFoundException);
});
it('deleteInstance loescht die Zeile; fremde Kennung -> NotFound', async () => {
expect(await service.deleteInstance('t1', 'i1')).toBe(true);
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledWith({ where: { id: 'i1' } });
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(service.deleteInstance('t1', 'fremd')).rejects.toThrow(NotFoundException);
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledTimes(1);
});
it('checkAllForTenant prueft alle mit hoechstens vier gleichzeitig und liefert die frische Liste', async () => {
const ids = Array.from({ length: 10 }, (_, i) => `i${i}`);
prisma.nextcloudInstance.findMany.mockImplementation(
async (args: { select: Record<string, boolean> }) =>
args.select.id && Object.keys(args.select).length === 1
? ids.map((id) => ({ id }))
: [makeRow()],
);
prisma.nextcloudInstance.findFirst.mockImplementation(
async ({ where }: { where: { id: string } }) => ({
id: where.id,
baseUrl: 'https://cloud.a.de',
}),
);
let inFlight = 0;
let peak = 0;
vi.mocked(fetchNextcloudStatus).mockImplementation(async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
return OK_RESULT;
});
const result = await service.checkAllForTenant('t1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10);
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(1);
expect(result.instances).toHaveLength(1);
});
it('checkAllForTenant: eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
prisma.nextcloudInstance.findMany.mockImplementation(
async (args: { select: Record<string, boolean> }) =>
Object.keys(args.select).length === 1 ? [{ id: 'a' }, { id: 'b' }] : [],
);
prisma.nextcloudInstance.findFirst.mockImplementation(
async ({ where }: { where: { id: string } }) =>
where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' },
);
await service.checkAllForTenant('t1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
});
it('loadAllInstancesForScheduler waehlt nur Kennung und Mandant ueber forSystem', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]);
const rows = await service.loadAllInstancesForScheduler();
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({
select: { id: true, tenantId: true },
});
expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]);
});
});
}); });
@@ -1,7 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateNextcloudInstanceDto } from './dto/nextcloud-instance.dto'; import type {
CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto';
import { checkLogoUpload } from './nextcloud-logo-rules';
import { import {
type NextcloudRating, type NextcloudRating,
type NextcloudReference, type NextcloudReference,
@@ -76,10 +81,34 @@ export interface NextcloudListView {
reference: { newestVersion: string | null; fetchedAt: string | null }; reference: { newestVersion: string | null; fetchedAt: string | null };
} }
/** Hoechstzahl gleichzeitiger Pruefungen (Sammelpruefung und stuendlicher Durchlauf). */
export const CHECK_CONCURRENCY = 4;
/**
* Arbeitet `items` mit hoechstens `limit` gleichzeitig ab. `fn` darf werfen —
* der Aufrufer faengt je Eintrag selbst, ein Fehler stoppt die anderen nicht.
*/
export async function runWithConcurrency<T>(
items: T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> {
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const item = items[next++];
await fn(item);
}
});
await Promise.all(workers);
}
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'; const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
@Injectable() @Injectable()
export class NextcloudStatusService { export class NextcloudStatusService {
private readonly logger = new Logger(NextcloudStatusService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly release: NextcloudReleaseService, private readonly release: NextcloudReleaseService,
@@ -178,4 +207,172 @@ export class NextcloudStatusService {
}); });
return this.toView(updated as PublicRow, await this.release.getReference()); return this.toView(updated as PublicRow, await this.release.getReference());
} }
/**
* Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird
* normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine
* nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere
* entfernt nur die Adresse (D-A).
*/
async updateInstance(
tenantId: string,
id: string,
dto: UpdateNextcloudInstanceDto,
): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true, baseUrl: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const data: Record<string, unknown> = {};
if (dto.customerName !== undefined) data.customerName = dto.customerName.trim();
let urlChanged = false;
if (dto.baseUrl !== undefined) {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
if (baseUrl !== existing.baseUrl) {
data.baseUrl = baseUrl;
urlChanged = true;
}
}
if (dto.logoUrl !== undefined) {
if (dto.logoUrl) {
data.logoUrl = dto.logoUrl;
data.logoData = null;
data.logoMime = null;
} else {
data.logoUrl = null;
}
data.logoVersion = { increment: 1 };
}
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data,
select: PUBLIC_SELECT,
});
if (urlChanged) return this.checkInstance(tenantId, id);
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Loescht eine Cloud. Fremde oder unbekannte Kennung: 404. */
async deleteInstance(tenantId: string, id: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
await tenantPrisma.nextcloudInstance.delete({ where: { id } });
return true;
}
/**
* Speichert ein hochgeladenes Logo. Der Typ kommt aus den Magic Bytes
* (`checkLogoUpload`), nie aus dem Mimetype des Browsers; eine vorhandene
* Logo-Adresse wird entfernt (Upload und Adresse schliessen sich aus).
*/
async uploadLogo(
tenantId: string,
id: string,
file: UploadedFileLike | undefined,
): Promise<NextcloudInstanceView> {
const mime = file ? checkLogoUpload(file.buffer) : null;
if (!file || !mime) {
throw new BadRequestException(
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
);
}
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: {
logoData: new Uint8Array(file.buffer),
logoMime: mime,
logoUrl: null,
logoVersion: { increment: 1 },
},
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Liefert die Bytes des hochgeladenen Logos — die einzige Abfrage, die `logoData` auswaehlt. */
async getLogo(tenantId: string, id: string): Promise<{ data: Buffer; mime: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { logoData: true, logoMime: true },
});
if (!row?.logoData || !row.logoMime) throw new NotFoundException('Kein Logo vorhanden');
return { data: Buffer.from(row.logoData), mime: row.logoMime };
}
/** Entfernt ein hochgeladenes Logo (die Kachel faellt auf Initialen zurueck). */
async removeLogo(tenantId: string, id: string): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } },
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Kennungen aller Clouds des Mandanten. */
async listInstanceIdsForTenant(tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = await tenantPrisma.nextcloudInstance.findMany({
where: { tenantId },
select: { id: true },
});
return rows.map((r: { id: string }) => r.id);
}
/**
* "Jetzt pruefen" fuer die ganze Liste: alle Clouds des Mandanten mit
* hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte
* Cloud stoppt die anderen nicht.
*/
async checkAllForTenant(tenantId: string): Promise<NextcloudListView> {
const ids = await this.listInstanceIdsForTenant(tenantId);
await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => {
try {
await this.checkInstance(tenantId, id);
} catch (err) {
this.logger.warn(
`Nextcloud-Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`,
);
}
});
return this.listForTenant(tenantId);
}
/**
* Startpfad des stuendlichen Planers — der EINZIGE Systemkontext-Aufruf
* dieses Moduls (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`;
* Leserecht ueber `system_read_policy ... FOR SELECT` der Migration
* 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder
* Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen
* Mandanten gebunden (`checkInstance`).
*/
async loadAllInstancesForScheduler(): Promise<{ id: string; tenantId: string }[]> {
const systemPrisma = forSystem(this.prisma);
return systemPrisma.nextcloudInstance.findMany({
select: { id: true, tenantId: true },
});
}
} }
@@ -189,10 +189,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
* gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist * gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist
* `system_read_policy ... FOR SELECT` auf "Reminder" (Migration * `system_read_policy ... FOR SELECT` auf "Reminder" (Migration
* 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe. * 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe.
*
* quick-261002-k67 (Aufgabe 2): eine siebte Datei kommt hinzu —
* `nextcloud-status/nextcloud-status.service.ts`, EIN Aufruf:
* `loadAllInstancesForScheduler()` liest fuer den stuendlichen Planer nur
* Kennung und Mandant ALLER Clouds (`select: { id, tenantId }`, nie Logo-Bytes).
* Geprueft und geschrieben wird danach je Cloud gebunden ueber
* `forTenant(prisma, tenantId)`. Die passende Regel ist
* `system_read_policy ... FOR SELECT` auf "NextcloudInstance" (Migration
* 20261002150000). Summe neu: 7 Dateien, 8 Aufrufe.
*/ */
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([ const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
['apps/api/src/dkv/dkv.service.ts', 1], ['apps/api/src/dkv/dkv.service.ts', 1],
['apps/api/src/ldap/ldap-config.service.ts', 2], ['apps/api/src/ldap/ldap-config.service.ts', 2],
['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1],
['apps/api/src/proxmox/proxmox.service.ts', 1], ['apps/api/src/proxmox/proxmox.service.ts', 1],
['apps/api/src/reminders/reminder-mail.scheduler.ts', 1], ['apps/api/src/reminders/reminder-mail.scheduler.ts', 1],
['apps/api/src/tenders/tender-digest.scheduler.ts', 1], ['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
@@ -0,0 +1,202 @@
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextcloudInstance } from '@/lib/nextcloud-status-api';
import de from '@/messages/de.json';
import { CloudForm } from './CloudForm';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const api = {
createInstance: vi.fn(),
updateInstance: vi.fn(),
deleteInstance: vi.fn(),
uploadLogo: vi.fn(),
removeLogo: vi.fn(),
};
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
return {
...actual,
createInstance: (...a: unknown[]) => api.createInstance(...a),
updateInstance: (...a: unknown[]) => api.updateInstance(...a),
deleteInstance: (...a: unknown[]) => api.deleteInstance(...a),
uploadLogo: (...a: unknown[]) => api.uploadLogo(...a),
removeLogo: (...a: unknown[]) => api.removeLogo(...a),
};
});
const EXISTING: NextcloudInstance = {
id: 'i1',
customerName: 'Kunde A',
baseUrl: 'https://a.example.de',
logoUrl: null,
hasUploadedLogo: false,
logoVersion: 0,
status: {
checkedAt: null,
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
errorKind: null,
errorDetail: null,
},
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
};
function setup(instance: NextcloudInstance | null = null) {
const props = { onClose: vi.fn(), onSaved: vi.fn(), onDeleted: vi.fn() };
render(<CloudForm instance={instance} {...props} />);
return props;
}
describe('CloudForm', () => {
beforeEach(() => {
for (const fn of Object.values(api)) fn.mockReset();
});
afterEach(() => cleanup());
it('verlangt Kundenname und Adresse', async () => {
setup();
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Bitte füllen Sie Kundenname und Adresse aus.',
);
expect(api.createInstance).not.toHaveBeenCalled();
});
it('legt eine Cloud an', async () => {
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
const props = setup();
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: ' Neu GmbH ' } });
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
target: { value: 'https://cloud.neu.de' },
});
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(props.onSaved).toHaveBeenCalled());
expect(api.createInstance).toHaveBeenCalledWith({
customerName: 'Neu GmbH',
baseUrl: 'https://cloud.neu.de',
});
expect(api.uploadLogo).not.toHaveBeenCalled();
expect(props.onClose).toHaveBeenCalled();
});
it('lädt nach dem Anlegen das gewählte Logo hoch', async () => {
api.createInstance.mockResolvedValue({ ...EXISTING, id: 'neu' });
api.uploadLogo.mockResolvedValue({ ...EXISTING, id: 'neu', hasUploadedLogo: true });
const props = setup();
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
target: { value: 'https://cloud.neu.de' },
});
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
const file = new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
target: { files: [file] },
});
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(api.uploadLogo).toHaveBeenCalledWith('neu', file));
expect(props.onSaved).toHaveBeenCalledWith(expect.objectContaining({ hasUploadedLogo: true }));
});
it('sendet eine https-Bildadresse und weist darauf hin', async () => {
api.createInstance.mockResolvedValue(EXISTING);
setup();
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), {
target: { value: 'https://cloud.neu.de' },
});
fireEvent.click(
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="radio"]' }),
);
expect(screen.getByText(/muss mit https:\/\/ beginnen/)).toBeInTheDocument();
fireEvent.change(
screen.getByLabelText('Bildadresse (https)', { selector: 'input[type="url"]' }),
{
target: { value: 'https://logo.example.de/a.png' },
},
);
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(api.createInstance).toHaveBeenCalledWith({
customerName: 'Neu',
baseUrl: 'https://cloud.neu.de',
logoUrl: 'https://logo.example.de/a.png',
}),
);
});
it('lehnt eine zu große Datei sofort ab', async () => {
setup();
fireEvent.click(screen.getByLabelText('Bild hochladen', { selector: 'input[type="radio"]' }));
const big = new File([new Uint8Array(1024 * 1024 + 1)], 'groß.png', { type: 'image/png' });
fireEvent.change(screen.getByLabelText('Bild hochladen', { selector: 'input[type="file"]' }), {
target: { files: [big] },
});
expect(await screen.findByRole('alert')).toHaveTextContent('Die Datei ist größer als 1 MB.');
});
it('ändert eine vorhandene Cloud', async () => {
api.updateInstance.mockResolvedValue(EXISTING);
const props = setup(EXISTING);
expect(screen.getByLabelText('Kundenname')).toHaveValue('Kunde A');
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Kunde B' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(api.updateInstance).toHaveBeenCalledWith('i1', {
customerName: 'Kunde B',
baseUrl: 'https://a.example.de',
}),
);
expect(props.onSaved).toHaveBeenCalled();
});
it('entfernt ein hochgeladenes Logo über „Kein Logo“', async () => {
const withLogo = { ...EXISTING, hasUploadedLogo: true, logoVersion: 2 };
api.updateInstance.mockResolvedValue(withLogo);
api.removeLogo.mockResolvedValue(EXISTING);
setup(withLogo);
fireEvent.click(screen.getByRole('button', { name: 'Logo entfernen' }));
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() => expect(api.removeLogo).toHaveBeenCalledWith('i1'));
});
it('Entfernen fragt erst nach und ruft dann deleteInstance', async () => {
api.deleteInstance.mockResolvedValue(undefined);
const props = setup(EXISTING);
fireEvent.click(screen.getByRole('button', { name: 'Cloud entfernen' }));
expect(
screen.getByText('Möchten Sie die Cloud „Kunde A“ wirklich entfernen?'),
).toBeInTheDocument();
expect(api.deleteInstance).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Endgültig entfernen' }));
await waitFor(() => expect(api.deleteInstance).toHaveBeenCalledWith('i1'));
expect(props.onDeleted).toHaveBeenCalledWith('i1');
});
it('zeigt die Fehlermeldung der API im Formular', async () => {
api.createInstance.mockRejectedValue(
new Error('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'),
);
const props = setup();
fireEvent.change(screen.getByLabelText('Kundenname'), { target: { value: 'Neu' } });
fireEvent.change(screen.getByLabelText('Adresse der Cloud'), { target: { value: 'ftp://x' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Bitte geben Sie eine gültige Adresse',
);
expect(props.onSaved).not.toHaveBeenCalled();
expect(props.onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,297 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import {
createInstance,
deleteInstance,
logoSrc,
type NextcloudInstance,
removeLogo,
updateInstance,
uploadLogo,
} from '@/lib/nextcloud-status-api';
type LogoMode = 'none' | 'upload' | 'url';
/** Obergrenze fuer ein Logo (wie die API: 1 MiB). */
const LOGO_MAX_BYTES = 1024 * 1024;
interface CloudFormProps {
/** `null` = neue Cloud anlegen, sonst bearbeiten. */
instance: NextcloudInstance | null;
onClose: () => void;
/** Liefert die gespeicherte Cloud (mit frischer Pruefung und Bewertung). */
onSaved: (instance: NextcloudInstance) => void;
onDeleted: (id: string) => void;
}
function initialMode(instance: NextcloudInstance | null): LogoMode {
if (instance?.hasUploadedLogo) return 'upload';
if (instance?.logoUrl) return 'url';
return 'none';
}
/**
* Formular „Cloud hinzufuegen / bearbeiten“ (quick-261002-k67, L-02, L-09) —
* Aufbau wie `CustomModuleFormModal`. Nur fuer Verwalter erreichbar (die Seite
* zeigt die Zugaenge nicht fuer Benutzer ohne Verwalten); bindend ist allein
* der ModuleGuard der API. Das Logo ist entweder ein Upload (die API prueft den
* Typ an den Bytes) oder eine https-Bildadresse, die der Browser selbst laedt.
* Anlegen wartet auf die erste Pruefung („Wird geprueft …“).
*/
export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormProps) {
const t = useTranslations('nextcloudStatus.form');
const [customerName, setCustomerName] = useState(instance?.customerName ?? '');
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? '');
const [mode, setMode] = useState<LogoMode>(initialMode(instance));
const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? '');
const [file, setFile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [error, setError] = useState<string | null>(null);
const currentLogo =
instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null;
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0] ?? null;
if (picked && picked.size > LOGO_MAX_BYTES) {
setError(t('logoFileTooLarge'));
setFile(null);
return;
}
setError(null);
setFile(picked);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const name = customerName.trim();
const url = baseUrl.trim();
if (!name || !url) {
setError(t('required'));
return;
}
setSaving(true);
try {
let saved: NextcloudInstance;
if (instance) {
const payload: { customerName: string; baseUrl: string; logoUrl?: string } = {
customerName: name,
baseUrl: url,
};
if (mode === 'url') payload.logoUrl = logoUrl.trim();
else if (mode === 'none' && instance.logoUrl) payload.logoUrl = '';
saved = await updateInstance(instance.id, payload);
if (mode === 'none' && instance.hasUploadedLogo) saved = await removeLogo(instance.id);
if (mode === 'upload' && file) saved = await uploadLogo(instance.id, file);
} else {
saved = await createInstance({
customerName: name,
baseUrl: url,
...(mode === 'url' && logoUrl.trim() ? { logoUrl: logoUrl.trim() } : {}),
});
if (mode === 'upload' && file) saved = await uploadLogo(saved.id, file);
}
onSaved(saved);
onClose();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t('saveError'));
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!instance) return;
setError(null);
setSaving(true);
try {
await deleteInstance(instance.id);
onDeleted(instance.id);
onClose();
} catch (err) {
setError(err instanceof Error && err.message ? err.message : t('deleteError'));
setConfirmDelete(false);
} finally {
setSaving(false);
}
};
const input = 'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="cloud-form-title"
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="cloud-form-title" className="mb-4 text-lg font-semibold text-foreground">
{instance ? t('editTitle') : t('addTitle')}
</h2>
{confirmDelete && instance ? (
<div className="space-y-4">
<p className="text-sm text-foreground">
{t('deleteConfirm', { name: instance.customerName })}
</p>
{error && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="btn btn-secondary"
>
{t('cancel')}
</button>
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="btn btn-primary"
>
{t('deleteConfirmYes')}
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="space-y-2">
<label htmlFor="cloud-customer-name" className="text-sm font-medium text-foreground">
{t('customerName')}
</label>
<input
id="cloud-customer-name"
type="text"
required
maxLength={120}
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className={input}
/>
</div>
<div className="space-y-2">
<label htmlFor="cloud-base-url" className="text-sm font-medium text-foreground">
{t('baseUrl')}
</label>
<input
id="cloud-base-url"
type="text"
required
maxLength={2048}
placeholder={t('baseUrlPlaceholder')}
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className={input}
/>
<p className="text-xs text-muted-foreground">{t('baseUrlHint')}</p>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium text-foreground">{t('logo')}</legend>
{currentLogo && mode !== 'none' && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{/* biome-ignore lint/performance/noImgElement: Vorschau einer externen oder hochgeladenen Adresse */}
<img
src={currentLogo}
alt={t('logoCurrent')}
referrerPolicy="no-referrer"
className="h-10 w-10 rounded-md bg-well object-contain"
/>
<button type="button" onClick={() => setMode('none')} className="btn btn-subtle">
{t('logoRemove')}
</button>
</div>
)}
<div className="flex flex-wrap gap-4 text-sm">
{(['none', 'upload', 'url'] as const).map((value) => (
<label key={value} className="flex items-center gap-2">
<input
type="radio"
name="cloud-logo-mode"
value={value}
checked={mode === value}
onChange={() => setMode(value)}
/>
{t(
value === 'none' ? 'logoNone' : value === 'upload' ? 'logoUpload' : 'logoUrl',
)}
</label>
))}
</div>
{mode === 'upload' && (
<div className="space-y-1">
<input
type="file"
aria-label={t('logoUpload')}
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleFile}
className="block w-full text-sm"
/>
<p className="text-xs text-muted-foreground">{t('logoFileHint')}</p>
</div>
)}
{mode === 'url' && (
<div className="space-y-1">
<input
type="url"
aria-label={t('logoUrl')}
maxLength={2048}
placeholder={t('logoUrlPlaceholder')}
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
className={input}
/>
<p className="text-xs text-muted-foreground">{t('logoUrlHint')}</p>
</div>
)}
</fieldset>
{error && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
<div>
{instance && (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="btn btn-subtle"
>
{t('delete')}
</button>
)}
</div>
<div className="flex gap-3">
<button type="button" onClick={onClose} className="btn btn-secondary">
{t('cancel')}
</button>
<button type="submit" disabled={saving} className="btn btn-primary">
{saving ? t('saving') : t('save')}
</button>
</div>
</div>
</form>
)}
</div>
</div>
);
}
@@ -17,6 +17,12 @@ interface CloudTileProps {
instance: NextcloudInstance; instance: NextcloudInstance;
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */ /** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
now?: number; now?: number;
/** Verwalten-Ebene: zeigt die Knoepfe Pruefen und Bearbeiten (nur Anzeige, bindend ist die API). */
canManage?: boolean;
/** Eine Pruefung dieser Kachel laeuft (Drehsymbol, Knopf gesperrt). */
checking?: boolean;
onCheck?: () => void;
onEdit?: () => void;
} }
/** /**
@@ -26,7 +32,14 @@ interface CloudTileProps {
* Zeitpunkt der letzten Pruefung. Die Logo-Adresse kommt vom Verwalter und * Zeitpunkt der letzten Pruefung. Die Logo-Adresse kommt vom Verwalter und
* wird vom Browser geladen — ohne Referer (`referrerPolicy`, T-k67-05). * wird vom Browser geladen — ohne Referer (`referrerPolicy`, T-k67-05).
*/ */
export function CloudTile({ instance, now = Date.now() }: CloudTileProps) { export function CloudTile({
instance,
now = Date.now(),
canManage = false,
checking = false,
onCheck,
onEdit,
}: CloudTileProps) {
const t = useTranslations('nextcloudStatus'); const t = useTranslations('nextcloudStatus');
const locale = useLocale(); const locale = useLocale();
const [logoFailed, setLogoFailed] = useState(false); const [logoFailed, setLogoFailed] = useState(false);
@@ -79,6 +92,58 @@ export function CloudTile({ instance, now = Date.now() }: CloudTileProps) {
{instance.baseUrl} {instance.baseUrl}
</a> </a>
</div> </div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onCheck}
disabled={checking}
aria-label={t('cardActions.refresh')}
title={t('cardActions.refresh')}
className="btn btn-subtle"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={checking ? 'animate-spin motion-reduce:animate-none' : ''}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<polyline points="21 3 21 8 16 8" />
</svg>
</button>
<button
type="button"
onClick={onEdit}
aria-label={t('cardActions.edit')}
title={t('cardActions.edit')}
className="btn btn-subtle"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
</div>
)}
</div> </div>
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
@@ -1,4 +1,11 @@
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react'; import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -15,12 +22,29 @@ function render(ui: ReactElement) {
} }
const mockListInstances = vi.fn(); const mockListInstances = vi.fn();
const mockCheckAll = vi.fn();
const mockCheckOne = vi.fn();
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => { vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>(); const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
return { ...actual, listInstances: (...args: unknown[]) => mockListInstances(...args) }; return {
...actual,
listInstances: (...args: unknown[]) => mockListInstances(...args),
checkAll: (...args: unknown[]) => mockCheckAll(...args),
checkOne: (...args: unknown[]) => mockCheckOne(...args),
};
}); });
let mockCanManage: boolean | null = false;
vi.mock('@/lib/use-module-capability', () => ({
useCanManageModule: () => mockCanManage,
}));
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: { user: { id: string } }) => unknown) =>
selector({ user: { id: 'u1' } }),
}));
function makeInstance( function makeInstance(
over: Partial<NextcloudInstance> & { id: string; customerName: string }, over: Partial<NextcloudInstance> & { id: string; customerName: string },
): NextcloudInstance { ): NextcloudInstance {
@@ -101,6 +125,10 @@ const LIST: NextcloudList = {
describe('NextcloudStatusPage', () => { describe('NextcloudStatusPage', () => {
beforeEach(() => { beforeEach(() => {
mockListInstances.mockReset(); mockListInstances.mockReset();
mockCheckAll.mockReset();
mockCheckOne.mockReset();
mockCanManage = false;
window.localStorage.clear();
}); });
afterEach(() => cleanup()); afterEach(() => cleanup());
@@ -167,4 +195,94 @@ describe('NextcloudStatusPage', () => {
const tile = await screen.findByTestId('cloud-tile'); const tile = await screen.findByTestId('cloud-tile');
expect(within(tile).getByText('KG')).toBeInTheDocument(); expect(within(tile).getByText('KG')).toBeInTheDocument();
}); });
describe('Rechte und Sortierung', () => {
const tileNames = () =>
screen.getAllByTestId('cloud-tile').map((t) => t.getAttribute('aria-label')?.split(':')[0]);
it('Benutzen-Ebene: Kacheln und Sortierung ja, Verwalten-Knöpfe nein', async () => {
mockCanManage = false;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getByLabelText('Sortieren nach')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Cloud hinzufügen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Jetzt prüfen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Cloud bearbeiten' })).toBeNull();
});
it('Verwalter sehen alle Knöpfe', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(screen.getByRole('button', { name: 'Cloud hinzufügen' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Jetzt prüfen' })).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: 'Diese Cloud jetzt prüfen' })).toHaveLength(4);
expect(screen.getAllByRole('button', { name: 'Cloud bearbeiten' })).toHaveLength(4);
});
it('Status-Sortierung stellt Rot nach vorn und merkt die Auswahl', async () => {
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
expect(tileNames()).toEqual(['Kunde Gelb', 'Kunde Grau', 'Kunde Grün', 'Kunde Rot']);
fireEvent.change(screen.getByLabelText('Sortieren nach'), { target: { value: 'status' } });
expect(tileNames()).toEqual(['Kunde Rot', 'Kunde Gelb', 'Kunde Grau', 'Kunde Grün']);
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
});
it('wendet eine gemerkte Sortierung beim Laden an', async () => {
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'status');
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
await waitFor(() => expect(tileNames()[0]).toBe('Kunde Rot'));
expect((screen.getByLabelText('Sortieren nach') as HTMLSelectElement).value).toBe('status');
});
it('„Jetzt prüfen“ ruft checkAll und ersetzt die Liste', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
mockCheckAll.mockResolvedValue({ ...LIST, instances: [LIST.instances[0]] });
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(screen.getByRole('button', { name: 'Jetzt prüfen' }));
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(1));
expect(mockCheckAll).toHaveBeenCalledTimes(1);
});
it('Kachel-Prüfung ersetzt nur diese Kachel', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
mockCheckOne.mockResolvedValue({
...LIST.instances[2],
customerName: 'Kunde Rot',
rating: { level: 'green', reason: 'current', updateTo: null, eolDate: null, cycle: 35 },
});
render(<NextcloudStatusPage />);
const tiles = await screen.findAllByTestId('cloud-tile');
const red = tiles.find((t) =>
t.getAttribute('aria-label')?.startsWith('Kunde Rot'),
) as HTMLElement;
fireEvent.click(within(red).getByRole('button', { name: 'Diese Cloud jetzt prüfen' }));
await waitFor(() => expect(mockCheckOne).toHaveBeenCalledWith('c'));
await waitFor(() => expect(screen.getAllByTestId('cloud-tile')).toHaveLength(4));
await waitFor(() =>
expect(
screen.getAllByTestId('cloud-tile').find((t) => t.textContent?.includes('Kunde Rot')),
).toHaveAttribute('data-level', 'green'),
);
});
it('„Cloud hinzufügen“ öffnet das Formular', async () => {
mockCanManage = true;
mockListInstances.mockResolvedValue(LIST);
render(<NextcloudStatusPage />);
await screen.findAllByTestId('cloud-tile');
fireEvent.click(screen.getByRole('button', { name: 'Cloud hinzufügen' }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});
}); });
@@ -1,10 +1,26 @@
'use client'; 'use client';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import {
readSortPreference,
SORT_KEYS,
type SortKey,
sortClouds,
writeSortPreference,
} from '@/components/nextcloud-status/sort-clouds';
import { WELL } from '@/components/proxmox/status-styles'; import { WELL } from '@/components/proxmox/status-styles';
import { listInstances, type NextcloudList } from '@/lib/nextcloud-status-api'; import {
checkAll,
checkOne,
listInstances,
type NextcloudInstance,
type NextcloudList,
} from '@/lib/nextcloud-status-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
import { CloudForm } from './components/CloudForm';
import { CloudTile } from './components/CloudTile'; import { CloudTile } from './components/CloudTile';
/** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */ /** Relative Zeitangaben („vor 3 Min.“) bleiben stimmig, ohne neu zu laden. */
@@ -25,16 +41,50 @@ function SkeletonTile() {
); );
} }
function RefreshIcon({ spinning }: { spinning: boolean }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={spinning ? 'animate-spin motion-reduce:animate-none' : ''}
>
<path d="M21 12a9 9 0 1 1-2.64-6.36L21 8" />
<polyline points="21 3 21 8 16 8" />
</svg>
);
}
/** /**
* Modulseite Nextcloud-Status (quick-261002-k67): eine Kachel je Cloud mit * Modulseite Nextcloud-Status (quick-261002-k67): eine Kachel je Cloud mit
* Ampel, installierter Version und Zeitpunkt der letzten Pruefung. Die * Ampel, installierter Version und Zeitpunkt der letzten Pruefung. Die
* Bewertung berechnet die API beim Lesen (D-B) — diese Seite zeigt nur an. * Bewertung berechnet die API beim Lesen (D-B) — diese Seite zeigt nur an.
*
* Sortierung (L-07) steht jedem Benutzer offen und wird je Benutzer im
* Browser gemerkt. Hinzufuegen, Bearbeiten, Pruefen („Jetzt pruefen“, je
* Kachel) nur mit Verwalten (`useCanManageModule`, L-09); die Knoepfe sind
* reine Anzeige — bindend ist `@ModuleManage` an der API.
*/ */
export default function NextcloudStatusPage() { export default function NextcloudStatusPage() {
const t = useTranslations('nextcloudStatus'); const t = useTranslations('nextcloudStatus');
const userId = useAuthStore((s) => s.user?.id ?? null);
const canManage = useCanManageModule('nextcloud-status') === true;
const [data, setData] = useState<NextcloudList | null>(null); const [data, setData] = useState<NextcloudList | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const [sortKey, setSortKey] = useState<SortKey>('name');
const [checkingAll, setCheckingAll] = useState(false);
const [checkingIds, setCheckingIds] = useState<ReadonlySet<string>>(new Set());
// `undefined` = Formular zu, `null` = neue Cloud, sonst bearbeiten.
const [editing, setEditing] = useState<NextcloudInstance | null | undefined>(undefined);
const reload = useCallback(() => { const reload = useCallback(() => {
listInstances() listInstances()
@@ -54,11 +104,82 @@ export default function NextcloudStatusPage() {
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, []); }, []);
// Gemerkte Sortierung dieses Benutzers anwenden, sobald seine Kennung feststeht.
useEffect(() => {
if (userId) setSortKey(readSortPreference(userId));
}, [userId]);
const handleSort = (key: SortKey) => {
setSortKey(key);
if (userId) writeSortPreference(userId, key);
};
const handleCheckAll = async () => {
setCheckingAll(true);
try {
const list = await checkAll();
setData(list);
setNow(Date.now());
} catch {
setError(t('loadError'));
} finally {
setCheckingAll(false);
}
};
const handleCheckOne = async (id: string) => {
setCheckingIds((prev) => new Set(prev).add(id));
try {
const updated = await checkOne(id);
setData((prev) =>
prev
? { ...prev, instances: prev.instances.map((i) => (i.id === id ? updated : i)) }
: prev,
);
setNow(Date.now());
} catch {
/* die Kachel behaelt ihren bisherigen Stand */
} finally {
setCheckingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
};
// Nach Anlegen/Aendern/Loeschen die Liste neu laden: die Reihenfolge und die
// neueste Version kommen so wieder aus einer Quelle.
const sorted = useMemo(() => (data ? sortClouds(data.instances, sortKey) : []), [data, sortKey]);
const newest = data?.reference.newestVersion ?? null; const newest = data?.reference.newestVersion ?? null;
return ( return (
<div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6"> <div className="mx-auto max-w-6xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="nextcloud-status" title={t('title')} description={t('description')} /> <PageHeader
moduleSlug="nextcloud-status"
title={t('title')}
description={t('description')}
actions={
canManage ? (
<>
<button type="button" onClick={() => setEditing(null)} className="btn btn-subtle">
{t('actions.add')}
</button>
{data !== null && data.instances.length > 0 && (
<button
type="button"
onClick={handleCheckAll}
disabled={checkingAll}
className="btn btn-primary"
>
<RefreshIcon spinning={checkingAll} />
{checkingAll ? t('actions.checking') : t('actions.checkAll')}
</button>
)}
</>
) : undefined
}
/>
{error && ( {error && (
<p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground"> <p className="rounded-lg border-l-4 border-status-down bg-status-down/8 px-4 py-3 text-sm text-foreground">
@@ -79,26 +200,62 @@ export default function NextcloudStatusPage() {
)} )}
{!error && data !== null && ( {!error && data !== null && (
<p className="text-sm text-muted-foreground" data-testid="newest-version"> <div className="flex flex-wrap items-center justify-between gap-3">
{newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')} <p className="text-sm text-muted-foreground" data-testid="newest-version">
</p> {newest ? t('newestVersion', { version: newest }) : t('referenceUnavailable')}
</p>
{data.instances.length > 0 && (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
{t('sort.label')}
<select
value={sortKey}
onChange={(e) => handleSort(e.target.value as SortKey)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm text-foreground"
>
{SORT_KEYS.map((key) => (
<option key={key} value={key}>
{t(`sort.${key}`)}
</option>
))}
</select>
</label>
)}
</div>
)} )}
{!error && data !== null && data.instances.length === 0 && ( {!error && data !== null && data.instances.length === 0 && (
<div className={`${WELL} px-6 py-10 text-center`}> <div className={`${WELL} px-6 py-10 text-center`}>
<p className="text-sm text-muted-foreground">{t('empty')}</p> <p className="text-sm text-muted-foreground">{t('empty')}</p>
{canManage && <p className="mt-1 text-sm text-muted-foreground">{t('emptyManage')}</p>}
</div> </div>
)} )}
{!error && data !== null && data.instances.length > 0 && ( {!error && data !== null && sorted.length > 0 && (
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> <ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.instances.map((instance) => ( {sorted.map((instance) => (
<li key={instance.id} className="min-w-0"> <li key={instance.id} className="min-w-0">
<CloudTile instance={instance} now={now} /> <CloudTile
key={`${instance.id}:${instance.logoVersion}`}
instance={instance}
now={now}
canManage={canManage}
checking={checkingAll || checkingIds.has(instance.id)}
onCheck={() => handleCheckOne(instance.id)}
onEdit={() => setEditing(instance)}
/>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{editing !== undefined && (
<CloudForm
instance={editing}
onClose={() => setEditing(undefined)}
onSaved={() => reload()}
onDeleted={() => reload()}
/>
)}
</div> </div>
); );
} }
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
import { readSortPreference, sortClouds, writeSortPreference } from './sort-clouds';
function make(
name: string,
level: RatingLevel = 'green',
version: string | null = '35.0.1',
eolDate: string | null = null,
): NextcloudInstance {
return {
id: name,
customerName: name,
baseUrl: `https://${name}.de`,
logoUrl: null,
hasUploadedLogo: false,
logoVersion: 0,
status: {
checkedAt: null,
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: version,
edition: null,
errorKind: null,
errorDetail: null,
},
rating: { level, reason: 'current', updateTo: null, eolDate, cycle: null },
};
}
const names = (list: NextcloudInstance[]) => list.map((i) => i.customerName);
describe('sortClouds', () => {
it('name: A-Z mit deutscher Sortierung, ohne Beachtung der Gross-/Kleinschreibung', () => {
const list = [make('zebra'), make('Ärger'), make('Anton'), make('berta')];
expect(names(sortClouds(list, 'name'))).toEqual(['Anton', 'Ärger', 'berta', 'zebra']);
});
it('status: rot, gelb, grau, gruen, bei Gleichstand nach Name', () => {
const list = [
make('g2', 'green'),
make('r2', 'red'),
make('u1', 'unknown'),
make('y1', 'yellow'),
make('r1', 'red'),
make('g1', 'green'),
];
expect(names(sortClouds(list, 'status'))).toEqual(['r1', 'r2', 'y1', 'u1', 'g1', 'g2']);
});
it('version: aelteste zuerst, ohne Version zuletzt', () => {
const list = [
make('a', 'green', '34.0.10'),
make('b', 'green', null),
make('c', 'green', '33.0.9'),
make('d', 'green', '34.0.2'),
];
expect(names(sortClouds(list, 'version'))).toEqual(['c', 'd', 'a', 'b']);
});
it('eol: frueheste zuerst, ohne Datum zuletzt', () => {
const list = [
make('a', 'green', '1.0.0', '2027-06-30'),
make('b'),
make('c', 'green', '1.0.0', '2026-09-30'),
];
expect(names(sortClouds(list, 'eol'))).toEqual(['c', 'a', 'b']);
});
it('veraendert die Eingabe nicht', () => {
const list = [make('b'), make('a')];
const copy = [...list];
sortClouds(list, 'name');
expect(list).toEqual(copy);
});
});
describe('Sortier-Vorliebe', () => {
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it('merkt die Auswahl je Benutzer unter eigenem Schluessel', () => {
writeSortPreference('u1', 'status');
expect(window.localStorage.getItem('tessera:nextcloud-status:sort:u1')).toBe('status');
expect(readSortPreference('u1')).toBe('status');
expect(readSortPreference('u2')).toBe('name');
});
it('ein unbekannter gespeicherter Wert ergibt name', () => {
window.localStorage.setItem('tessera:nextcloud-status:sort:u1', 'quatsch');
expect(readSortPreference('u1')).toBe('name');
});
it('ein gesperrter Speicher wirft nie', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('gesperrt');
});
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('gesperrt');
});
expect(readSortPreference('u1')).toBe('name');
expect(() => writeSortPreference('u1', 'eol')).not.toThrow();
});
});
@@ -0,0 +1,83 @@
import type { NextcloudInstance, RatingLevel } from '@/lib/nextcloud-status-api';
/**
* Sortierung der Cloud-Kacheln (quick-261002-k67, L-07, D-G) — reine
* Funktionen, kein React.
*/
export type SortKey = 'name' | 'status' | 'version' | 'eol';
export const SORT_KEYS: readonly SortKey[] = ['name', 'status', 'version', 'eol'];
/** Rot zuerst, dann Gelb, Grau, Gruen. */
const LEVEL_ORDER: Record<RatingLevel, number> = { red: 0, yellow: 1, unknown: 2, green: 3 };
function byName(a: NextcloudInstance, b: NextcloudInstance): number {
return a.customerName.localeCompare(b.customerName, 'de', { sensitivity: 'base' });
}
function versionTriple(raw: string | null): [number, number, number] | null {
if (!raw) return null;
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(raw.trim());
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
function compareVersions(a: NextcloudInstance, b: NextcloudInstance): number {
const va = versionTriple(a.status.versionString);
const vb = versionTriple(b.status.versionString);
if (!va && !vb) return 0;
if (!va) return 1; // unbekannt zuletzt
if (!vb) return -1;
for (let i = 0; i < 3; i++) {
if (va[i] !== vb[i]) return va[i] - vb[i];
}
return 0;
}
function compareEol(a: NextcloudInstance, b: NextcloudInstance): number {
const ea = a.rating.eolDate;
const eb = b.rating.eolDate;
if (!ea && !eb) return 0;
if (!ea) return 1; // unbekannt zuletzt
if (!eb) return -1;
return ea < eb ? -1 : ea > eb ? 1 : 0;
}
/** Liefert eine neue, sortierte Liste; die Eingabe bleibt unveraendert. Gleiche Werte nach Kundenname. */
export function sortClouds(items: NextcloudInstance[], key: SortKey): NextcloudInstance[] {
const compare = (a: NextcloudInstance, b: NextcloudInstance): number => {
switch (key) {
case 'status':
return LEVEL_ORDER[a.rating.level] - LEVEL_ORDER[b.rating.level];
case 'version':
return compareVersions(a, b);
case 'eol':
return compareEol(a, b);
default:
return 0;
}
};
return [...items].sort((a, b) => compare(a, b) || byName(a, b));
}
function storageKey(userId: string): string {
return `tessera:nextcloud-status:sort:${userId}`;
}
/** Gemerkte Sortierung dieses Benutzers; unbekannter Wert oder gesperrter Speicher ergeben 'name'. */
export function readSortPreference(userId: string): SortKey {
try {
const stored = window.localStorage.getItem(storageKey(userId));
return (SORT_KEYS as readonly string[]).includes(stored ?? '') ? (stored as SortKey) : 'name';
} catch {
return 'name';
}
}
/** Merkt die Sortierung; ein gesperrter Speicher wird still uebergangen. */
export function writeSortPreference(userId: string, key: SortKey): void {
try {
window.localStorage.setItem(storageKey(userId), key);
} catch {
/* Speicher gesperrt oder voll — die Auswahl gilt dann nur fuer diese Sitzung */
}
}
+90
View File
@@ -84,3 +84,93 @@ export function logoSrc(
} }
return instance.logoUrl ?? null; return instance.logoUrl ?? null;
} }
/** Liest die NestJS-Fehlermeldung aus dem Antwortkoerper, faellt sonst auf einen Standardtext zurueck. */
async function readErrorMessage(res: Response, fallback: string): Promise<string> {
try {
const body = await res.json();
if (typeof body?.message === 'string') return body.message;
if (Array.isArray(body?.message) && body.message.length > 0) return String(body.message[0]);
} catch {
/* Antwort war kein JSON — Standardtext bleibt */
}
return fallback;
}
export interface NextcloudInstancePayload {
customerName: string;
baseUrl: string;
/** Leere Zeichenkette = Logo-Adresse entfernen (nur beim Aendern). */
logoUrl?: string;
}
const BASE = `${API_URL}/modules/nextcloud-status/instances`;
/** POST /instances — Cloud anlegen und sofort pruefen (nur Verwalten). */
export async function createInstance(
payload: NextcloudInstancePayload,
): Promise<NextcloudInstance> {
const res = await fetch(BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to create nextcloud instance'));
return res.json();
}
/** PUT /instances/:id — Cloud aendern (nur Verwalten). */
export async function updateInstance(
id: string,
payload: Partial<NextcloudInstancePayload>,
): Promise<NextcloudInstance> {
const res = await fetch(`${BASE}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to update nextcloud instance'));
return res.json();
}
/** DELETE /instances/:id — Cloud entfernen (nur Verwalten). */
export async function deleteInstance(id: string): Promise<void> {
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to delete nextcloud instance'));
}
/** POST /instances/check — "Jetzt pruefen" fuer die ganze Liste, liefert die frische Liste. */
export async function checkAll(): Promise<NextcloudList> {
const res = await fetch(`${BASE}/check`, { method: 'POST', credentials: 'include' });
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instances'));
return res.json();
}
/** POST /instances/:id/check — eine Cloud sofort pruefen, liefert die aktualisierte Kachel. */
export async function checkOne(id: string): Promise<NextcloudInstance> {
const res = await fetch(`${BASE}/${id}/check`, { method: 'POST', credentials: 'include' });
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to check nextcloud instance'));
return res.json();
}
/** POST /instances/:id/logo — Logo hochladen (Formularfeld `logo`, nur Verwalten). */
export async function uploadLogo(id: string, file: File): Promise<NextcloudInstance> {
const form = new FormData();
form.append('logo', file);
const res = await fetch(`${BASE}/${id}/logo`, {
method: 'POST',
credentials: 'include',
body: form,
});
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to upload logo'));
return res.json();
}
/** DELETE /instances/:id/logo — hochgeladenes Logo entfernen (nur Verwalten). */
export async function removeLogo(id: string): Promise<NextcloudInstance> {
const res = await fetch(`${BASE}/${id}/logo`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) throw new Error(await readErrorMessage(res, 'Failed to remove logo'));
return res.json();
}
+44
View File
@@ -1851,6 +1851,50 @@
"noReference": "Bewertung nicht möglich", "noReference": "Bewertung nicht möglich",
"versionUnknown": "Version unbekannt", "versionUnknown": "Version unbekannt",
"notChecked": "Noch nicht geprüft" "notChecked": "Noch nicht geprüft"
},
"sort": {
"label": "Sortieren nach",
"name": "Kundenname",
"status": "Status",
"version": "Version",
"eol": "Support-Ende"
},
"actions": {
"add": "Cloud hinzufügen",
"checkAll": "Jetzt prüfen",
"checking": "Wird geprüft …"
},
"cardActions": {
"refresh": "Diese Cloud jetzt prüfen",
"edit": "Cloud bearbeiten"
},
"form": {
"addTitle": "Cloud hinzufügen",
"editTitle": "Cloud bearbeiten",
"customerName": "Kundenname",
"baseUrl": "Adresse der Cloud",
"baseUrlPlaceholder": "https://cloud.example.com",
"baseUrlHint": "Tessera ruft nur die öffentliche Statusseite dieser Adresse ab. Zugangsdaten werden nicht benötigt.",
"logo": "Logo",
"logoNone": "Kein Logo",
"logoUpload": "Bild hochladen",
"logoUrl": "Bildadresse (https)",
"logoUrlPlaceholder": "https://www.example.com/logo.png",
"logoUrlHint": "Die Adresse muss mit https:// beginnen. Das Bild wird direkt von Ihrem Browser geladen.",
"logoFileHint": "PNG, JPEG, GIF oder WebP, höchstens 1 MB.",
"logoFileTooLarge": "Die Datei ist größer als 1 MB.",
"logoCurrent": "Aktuelles Logo",
"logoRemove": "Logo entfernen",
"save": "Speichern",
"saving": "Wird geprüft …",
"cancel": "Abbrechen",
"delete": "Cloud entfernen",
"deleteConfirm": "Möchten Sie die Cloud „{name}“ wirklich entfernen?",
"deleteConfirmYes": "Endgültig entfernen",
"required": "Bitte füllen Sie Kundenname und Adresse aus.",
"saveError": "Die Cloud konnte nicht gespeichert werden.",
"deleteError": "Die Cloud konnte nicht entfernt werden.",
"close": "Dialog schließen"
} }
} }
} }
+44
View File
@@ -1851,6 +1851,50 @@
"noReference": "Rating not possible", "noReference": "Rating not possible",
"versionUnknown": "Version unknown", "versionUnknown": "Version unknown",
"notChecked": "Not checked yet" "notChecked": "Not checked yet"
},
"sort": {
"label": "Sort by",
"name": "Customer name",
"status": "Status",
"version": "Version",
"eol": "End of support"
},
"actions": {
"add": "Add cloud",
"checkAll": "Check now",
"checking": "Checking …"
},
"cardActions": {
"refresh": "Check this cloud now",
"edit": "Edit cloud"
},
"form": {
"addTitle": "Add cloud",
"editTitle": "Edit cloud",
"customerName": "Customer name",
"baseUrl": "Cloud address",
"baseUrlPlaceholder": "https://cloud.example.com",
"baseUrlHint": "Tessera only reads the public status page of this address. No login data is needed.",
"logo": "Logo",
"logoNone": "No logo",
"logoUpload": "Upload image",
"logoUrl": "Image address (https)",
"logoUrlPlaceholder": "https://www.example.com/logo.png",
"logoUrlHint": "The address must start with https://. The image is loaded directly by your browser.",
"logoFileHint": "PNG, JPEG, GIF or WebP, at most 1 MB.",
"logoFileTooLarge": "The file is larger than 1 MB.",
"logoCurrent": "Current logo",
"logoRemove": "Remove logo",
"save": "Save",
"saving": "Checking …",
"cancel": "Cancel",
"delete": "Remove cloud",
"deleteConfirm": "Do you really want to remove the cloud “{name}”?",
"deleteConfirmYes": "Remove permanently",
"required": "Please fill in customer name and address.",
"saveError": "The cloud could not be saved.",
"deleteError": "The cloud could not be removed.",
"close": "Close dialog"
} }
} }
} }
@@ -106,6 +106,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch) // quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
'Neueste', 'Neueste',
'ausstehend', 'ausstehend',
'Statusseite',
'Bildadresse',
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch) // quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
'Aussteller', 'Aussteller',
'Betriebssystemen', 'Betriebssystemen',
File diff suppressed because one or more lines are too long