fix(nextcloud-files): CR-02 Nextcloud-Adresse aendern nur noch Administratoren

- PUT settings traegt @Roles(ADMIN, SUPER_ADMIN) statt @ModuleManage (Muster wie
  TendersController.getSourceConfig); alle Benutzer schicken ihr Passwort an diese Adresse
- POST settings/test: Verwalter ohne Administratorrolle pruefen nur die gespeicherte Adresse,
  jede andere Adresse ist 403
- Einstellungen fuer Verwalter nur lesbar mit kurzem Hinweis, ohne Speichern-Knopf; Hinweis
  "nicht eingerichtet" verweist Nicht-Administratoren an einen Administrator
- Metadaten-Spec (module-manage-handlers), Controller-Spec, Seitentest, e2e-settings
  (Verwalter: 403 auf PUT und fremde Pruefung), Anleitungen und Changelog angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 22:31:06 +02:00
parent 5c2d327bef
commit e78e059477
12 changed files with 187 additions and 37 deletions
@@ -66,4 +66,29 @@ e2e_second_user e2euser "$UJAR"
code=$(e2e_status "$UJAR" GET "$S/settings") code=$(e2e_status "$UJAR" GET "$S/settings")
case "$code" in 403|404) ;; *) e2e_fail "Benutzer ohne Freigabe: GET settings erwartet 403/404, war $code" ;; esac case "$code" in 403|404) ;; *) e2e_fail "Benutzer ohne Freigabe: GET settings erwartet 403/404, war $code" ;; esac
# CR-02: die Adresse legt nur ein Administrator fest. Ein Benutzer mit der Freigabestufe Verwalten
# (ohne Administratorrolle) liest die Einstellungen und prueft die GESPEICHERTE Adresse, darf
# aber nichts aendern und keine andere Adresse pruefen.
MJAR="$E2E_TMP/manager.jar"
e2e_second_user e2emanager "$MJAR"
e2e_status "$JAR" GET "$API/users" "" "$E2E_TMP/users.out" >/dev/null
MID=$(python3 -I -c 'import json,sys
print([u["id"] for u in json.load(open(sys.argv[1])) if u["username"]=="e2emanager"][0])' "$E2E_TMP/users.out")
e2e_status "$JAR" GET "$API/modules/catalog" "" "$E2E_TMP/catalog.out" >/dev/null
MODID=$(python3 -I -c 'import json,sys
print([m["id"] for m in json.load(open(sys.argv[1])) if m["slug"]=="nextcloud-files"][0])' "$E2E_TMP/catalog.out")
code=$(e2e_status "$JAR" POST "$API/module-grants" "{\"moduleId\":\"$MODID\",\"userId\":\"$MID\",\"level\":\"MANAGE\"}")
case "$code" in 200|201|409) ;; *) e2e_fail "Freigabe Verwalten fuer e2emanager -> $code" ;; esac
code=$(e2e_status "$MJAR" GET "$S/settings")
e2e_expect 200 "$code" "Verwalter: GET settings"
code=$(e2e_status "$MJAR" PUT "$S/settings" '{"baseUrl":"http://172.17.0.1:18081","confirmReconnect":true}')
e2e_expect 403 "$code" "Verwalter: PUT settings (nur Administratoren)"
code=$(e2e_status "$MJAR" POST "$S/settings/test" '{"baseUrl":"http://172.17.0.1:1"}')
e2e_expect 403 "$code" "Verwalter: andere Adresse pruefen"
code=$(e2e_status "$MJAR" POST "$S/settings/test" "{\"baseUrl\":\"$NC_BASE\"}")
e2e_expect 200 "$code" "Verwalter: gespeicherte Adresse pruefen"
e2e_contains "$E2E_TMP/body.out" '"ok":true' "Verwalter: Pruefung ok"
code=$(e2e_status "$JAR" GET "$S/settings")
e2e_contains "$E2E_TMP/body.out" '"baseUrl":"http://172.17.0.1:18080"' "Adresse unveraendert"
echo "e2e settings ok" echo "e2e settings ok"
+1 -1
View File
@@ -11,7 +11,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Domains, Kontakte und Kunden: Alle Kontakte, die bei AutoDNS vorhanden sind, erscheinen automatisch in Tessera („Aus AutoDNS neu einlesen“ holt sie auf Wunsch sofort neu; das dürfen Benutzer mit der Freigabe „Verwalten“). Neue Kontakte (Person oder Organisation) legen Sie über ein Formular an. Jeden Kontakt – einzeln oder viele auf einmal – ordnen Sie einem Kunden zu; Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie. Die Listen lassen sich nach Kunde filtern und gruppieren. - Domains, Kontakte und Kunden: Alle Kontakte, die bei AutoDNS vorhanden sind, erscheinen automatisch in Tessera („Aus AutoDNS neu einlesen“ holt sie auf Wunsch sofort neu; das dürfen Benutzer mit der Freigabe „Verwalten“). Neue Kontakte (Person oder Organisation) legen Sie über ein Formular an. Jeden Kontakt – einzeln oder viele auf einmal – ordnen Sie einem Kunden zu; Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie. Die Listen lassen sich nach Kunde filtern und gruppieren.
- Domains, Domainliste: Alle Domains aus AutoDNS mit Kunde (über den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status, ebenfalls filterbar und nach Kunde gruppierbar. - Domains, Domainliste: Alle Domains aus AutoDNS mit Kunde (über den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status, ebenfalls filterbar und nach Kunde gruppierbar.
- Domains, Registrieren: Verfügbarkeit prüfen, Kontakte wählen (die Nameserver übernimmt Tessera unverändert und in derselben Reihenfolge aus den Standardwerten in AutoDNS; sind dort keine hinterlegt, ist die Registrierung gesperrt), Zusammenfassung mit System und Preis ansehen und mit „Jetzt verbindlich registrieren“ bestätigen. Tessera schützt vor Doppelbestellungen: Jede Registrierung wird höchstens ein einziges Mal an AutoDNS geschickt, auch bei Doppelklick oder zwei geöffneten Fenstern, und nie automatisch wiederholt. Ist unklar, ob AutoDNS die Bestellung angenommen hat, steht der Auftrag auf „Ergebnis ungeklärt“, bis Tessera ihn mit AutoDNS abgeglichen hat. Den Stand jeder Registrierung sehen Sie im Reiter „Aufträge“; er wird beim Öffnen und danach alle 30 Sekunden mit AutoDNS abgeglichen. - Domains, Registrieren: Verfügbarkeit prüfen, Kontakte wählen (die Nameserver übernimmt Tessera unverändert und in derselben Reihenfolge aus den Standardwerten in AutoDNS; sind dort keine hinterlegt, ist die Registrierung gesperrt), Zusammenfassung mit System und Preis ansehen und mit „Jetzt verbindlich registrieren“ bestätigen. Tessera schützt vor Doppelbestellungen: Jede Registrierung wird höchstens ein einziges Mal an AutoDNS geschickt, auch bei Doppelklick oder zwei geöffneten Fenstern, und nie automatisch wiederholt. Ist unklar, ob AutoDNS die Bestellung angenommen hat, steht der Auftrag auf „Ergebnis ungeklärt“, bis Tessera ihn mit AutoDNS abgeglichen hat. Den Stand jeder Registrierung sehen Sie im Reiter „Aufträge“; er wird beim Öffnen und danach alle 30 Sekunden mit AutoDNS abgeglichen.
- Neues Modul „Dateien“ in der Gruppe „Infrastruktur“: Ihre Dateien aus der Firmen-Nextcloud direkt in Tessera ansehen, hochladen, herunterladen und ordnen. Ein Administrator aktiviert das Modul im Marktplatz; wer es nutzen soll, bekommt zusätzlich die Freigabe. Administratoren und Benutzer mit der Freigabe „Verwalten“ tragen im Reiter „Einstellungen“ die Adresse der Nextcloud ein und können sie mit „Verbindung prüfen“ testen; wird die Adresse später geändert, müssen sich alle Benutzer neu anmelden. - Neues Modul „Dateien“ in der Gruppe „Infrastruktur“: Ihre Dateien aus der Firmen-Nextcloud direkt in Tessera ansehen, hochladen, herunterladen und ordnen. Ein Administrator aktiviert das Modul im Marktplatz; wer es nutzen soll, bekommt zusätzlich die Freigabe. Die Adresse der Nextcloud trägt nur ein Administrator im Reiter „Einstellungen“ ein, denn alle Benutzer schicken ihr Nextcloud-Passwort dorthin; „Verbindung prüfen“ testet sie. Benutzer mit der Freigabe „Verwalten“ sehen die Adresse dort und können die Verbindung prüfen, sie aber nicht ändern. Wird die Adresse später geändert, müssen sich alle Benutzer neu anmelden.
- Dateien, Anmeldung: Jeder meldet sich mit seinem eigenen Nextcloud-Konto an. Tessera speichert dabei nicht Ihr Passwort, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit in Nextcloud widerrufen können. Konten mit Zwei-Faktor-Anmeldung melden sich über „Im Browser anmelden“: Sie bestätigen den Zugriff auf der Nextcloud-Seite, Tessera wartet so lange. Der Anmeldebildschirm zeigt Name, Logo und Farbe Ihrer Nextcloud, damit Sie sehen, wo Sie sich anmelden. „Abmelden“ entfernt den Zugang auch in Nextcloud; Ihre Dateien bleiben unverändert. Damit nicht ein paar Fehlversuche die Nextcloud für alle sperren, begrenzt Tessera Fehlversuche selbst und hält alle Anfragen an, sobald die Nextcloud „zu viele Anfragen“ meldet. - Dateien, Anmeldung: Jeder meldet sich mit seinem eigenen Nextcloud-Konto an. Tessera speichert dabei nicht Ihr Passwort, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit in Nextcloud widerrufen können. Konten mit Zwei-Faktor-Anmeldung melden sich über „Im Browser anmelden“: Sie bestätigen den Zugriff auf der Nextcloud-Seite, Tessera wartet so lange. Der Anmeldebildschirm zeigt Name, Logo und Farbe Ihrer Nextcloud, damit Sie sehen, wo Sie sich anmelden. „Abmelden“ entfernt den Zugang auch in Nextcloud; Ihre Dateien bleiben unverändert. Damit nicht ein paar Fehlversuche die Nextcloud für alle sperren, begrenzt Tessera Fehlversuche selbst und hält alle Anfragen an, sobald die Nextcloud „zu viele Anfragen“ meldet.
- Dateien, Arbeiten mit Dateien: Ordner durchsuchen mit Pfadleiste, Liste oder Raster (Tessera merkt sich Ihre Wahl), Farbkacheln je Dateityp, echte Vorschaubilder und Anzeige des belegten Speicherplatzes. Neuer Ordner, Umbenennen, Verschieben (mit Ordnerauswahl) und Löschen – gelöscht wird in den Papierkorb Ihrer Nextcloud, nach einer Rückfrage; alles auch für mehrere Einträge gleichzeitig, per Maus, Zeilenmenü, Rechtsklick oder Tastatur (Enter, Rücktaste, F2, Entf, Strg+A). - Dateien, Arbeiten mit Dateien: Ordner durchsuchen mit Pfadleiste, Liste oder Raster (Tessera merkt sich Ihre Wahl), Farbkacheln je Dateityp, echte Vorschaubilder und Anzeige des belegten Speicherplatzes. Neuer Ordner, Umbenennen, Verschieben (mit Ordnerauswahl) und Löschen – gelöscht wird in den Papierkorb Ihrer Nextcloud, nach einer Rückfrage; alles auch für mehrere Einträge gleichzeitig, per Maus, Zeilenmenü, Rechtsklick oder Tastatur (Enter, Rücktaste, F2, Entf, Strg+A).
- Dateien, Hochladen und Herunterladen: Dateien und ganze Ordner per Ziehen und Ablegen (auch direkt auf einen Ordner) oder über „Hochladen“ hochladen; große Dateien gehen in Stücken hoch. Die Übertragungsleiste am unteren Rand zeigt den Fortschritt jeder Datei, lässt Abbrechen und Wiederholen zu und nennt Fehler in einfachen Worten. Tessera überschreibt nie stillschweigend: Gibt es den Namen schon, wählen Sie „Ersetzen“, „Beide behalten“ oder „Überspringen“. Dateien werden direkt heruntergeladen, Ordner und Mehrfachauswahl als ZIP-Datei. - Dateien, Hochladen und Herunterladen: Dateien und ganze Ordner per Ziehen und Ablegen (auch direkt auf einen Ordner) oder über „Hochladen“ hochladen; große Dateien gehen in Stücken hoch. Die Übertragungsleiste am unteren Rand zeigt den Fortschritt jeder Datei, lässt Abbrechen und Wiederholen zu und nennt Fehler in einfachen Worten. Tessera überschreibt nie stillschweigend: Gibt es den Namen schon, wählen Sie „Ersetzen“, „Beide behalten“ oder „Überspringen“. Dateien werden direkt heruntergeladen, Ordner und Mehrfachauswahl als ZIP-Datei.
@@ -136,7 +136,6 @@ describe('Umgestellte Handler (Verwalten)', () => {
it.each([ it.each([
'getSettings', 'getSettings',
'saveSettings',
'testSettings', 'testSettings',
])('NextcloudFilesController.%s verlangt Verwalten für nextcloud-files (quick-261008-mzu)', (name) => { ])('NextcloudFilesController.%s verlangt Verwalten für nextcloud-files (quick-261008-mzu)', (name) => {
expectManage(NextcloudFilesController, name, 'nextcloud-files'); expectManage(NextcloudFilesController, name, 'nextcloud-files');
@@ -201,4 +200,8 @@ describe('Bewusst nur für Administratoren (T-icv-01, T-icv-07)', () => {
])('ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => { ])('ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)', (name) => {
expectAdminOnly(ModuleRegistryController, name); expectAdminOnly(ModuleRegistryController, name);
}); });
it('NextcloudFilesController.saveSettings: die Nextcloud-Adresse legt nur ein Administrator fest (quick-261008-mzu, CR-02)', () => {
expectAdminOnly(NextcloudFilesController, 'saveSettings');
});
}); });
@@ -9,7 +9,9 @@ const proto = NextcloudFilesController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) as any; const req = (tenantId?: string) => ({ tenantId }) as any;
/** Verwalten (Administratoren und Freigabestufe Verwalten). Spaetere Aufgaben ergaenzen nichts hier. */ /** Verwalten (Administratoren und Freigabestufe Verwalten). Spaetere Aufgaben ergaenzen nichts hier. */
const MANAGE_HANDLERS = ['getSettings', 'saveSettings', 'testSettings']; const MANAGE_HANDLERS = ['getSettings', 'testSettings'];
/** Nur Administratoren (CR-02): die Adresse aendern. */
const ADMIN_HANDLERS = ['saveSettings'];
/** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */ /** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */
const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle']; const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle'];
@@ -52,15 +54,24 @@ describe('NextcloudFilesController — Metadaten', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudFilesController)).toBe('nextcloud-files'); expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudFilesController)).toBe('nextcloud-files');
}); });
it('Einstellungen und Pruefung verlangen Verwalten, ohne Rollen-Decorator', () => { it('Einstellungen lesen und Pruefung verlangen Verwalten, ohne Rollen-Decorator', () => {
for (const name of MANAGE_HANDLERS) { for (const name of MANAGE_HANDLERS) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBe(true);
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined(); expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
} }
}); });
it('die Adresse aendern duerfen nur Administratoren (CR-02)', () => {
for (const name of ADMIN_HANDLERS) {
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toEqual(['ADMIN', 'SUPER_ADMIN']);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
}
});
it('alle anderen Handler stehen auf Benutzen-Ebene', () => { it('alle anderen Handler stehen auf Benutzen-Ebene', () => {
const others = routeHandlers().filter((n) => !MANAGE_HANDLERS.includes(n)); const others = routeHandlers().filter(
(n) => !MANAGE_HANDLERS.includes(n) && !ADMIN_HANDLERS.includes(n),
);
expect(others).toContain('getStatus'); expect(others).toContain('getStatus');
for (const name of others) { for (const name of others) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined(); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
@@ -195,6 +206,9 @@ describe('NextcloudFilesController — Delegation', () => {
getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })), getSettings: vi.fn(async (..._a: unknown[]) => ({ baseUrl: null, connectedAccounts: 0 })),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})), saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })), testAddress: vi.fn(async (..._a: unknown[]) => ({ ok: true })),
getBaseUrl: vi.fn(
async (..._a: unknown[]): Promise<string | null> => 'https://cloud.example',
),
}; };
} }
@@ -224,7 +238,10 @@ describe('NextcloudFilesController — Delegation', () => {
function makeServerInfo() { function makeServerInfo() {
return { return {
getServerInfo: vi.fn(async (..._a: unknown[]) => ({ host: 'cloud.example', name: 'Nextcloud' })), getServerInfo: vi.fn(async (..._a: unknown[]) => ({
host: 'cloud.example',
name: 'Nextcloud',
})),
sendLogo: vi.fn(async (..._a: unknown[]) => undefined), sendLogo: vi.fn(async (..._a: unknown[]) => undefined),
}; };
} }
@@ -368,9 +385,10 @@ describe('NextcloudFilesController — Delegation', () => {
makeServerInfo() as any, makeServerInfo() as any,
); );
await controller.getStatus(userReq('t1', 'u1')); await controller.getStatus(userReq('t1', 'u1'));
const admin = { tenantId: 't1', user: { id: 'a1', role: 'ADMIN' } } as any;
await controller.getSettings(req('t1')); await controller.getSettings(req('t1'));
await controller.saveSettings(req('t1'), { baseUrl: 'https://x.example' } as any); await controller.saveSettings(admin, { baseUrl: 'https://x.example' } as any);
await controller.testSettings(req('t1'), { baseUrl: 'https://x.example' } as any); await controller.testSettings(admin, { baseUrl: 'https://x.example' } as any);
await controller.connectPassword(userReq('t1', 'u1'), { await controller.connectPassword(userReq('t1', 'u1'), {
loginName: ' anna ', loginName: ' anna ',
password: 'geheim', password: 'geheim',
@@ -391,6 +409,31 @@ describe('NextcloudFilesController — Delegation', () => {
expect(account.disconnect).toHaveBeenCalledWith('t1', 'u1'); expect(account.disconnect).toHaveBeenCalledWith('t1', 'u1');
}); });
it('Verwalter ohne Administratorrolle pruefen nur die gespeicherte Adresse (CR-02)', async () => {
const settings = makeSettings();
const controller = new NextcloudFilesController(
settings as any,
makeAccount() as any,
makeFiles() as any,
makeTransfer() as any,
makeServerInfo() as any,
);
const manager = { tenantId: 't1', user: { id: 'm1', role: 'USER' } } as any;
await controller.testSettings(manager, { baseUrl: 'https://CLOUD.example/' } as any);
expect(settings.testAddress).toHaveBeenCalledTimes(1);
await expect(
controller.testSettings(manager, { baseUrl: 'http://10.0.0.5:8080' } as any),
).rejects.toBeInstanceOf(ForbiddenException);
settings.getBaseUrl.mockResolvedValueOnce(null);
await expect(
controller.testSettings(manager, { baseUrl: 'https://cloud.example' } as any),
).rejects.toBeInstanceOf(ForbiddenException);
expect(settings.testAddress).toHaveBeenCalledTimes(1);
const superAdmin = { tenantId: 't1', user: { id: 's1', role: 'SUPER_ADMIN' } } as any;
await controller.testSettings(superAdmin, { baseUrl: 'http://10.0.0.5:8080' } as any);
expect(settings.testAddress).toHaveBeenCalledTimes(2);
});
it('ohne Mandantenkontext: ForbiddenException', async () => { it('ohne Mandantenkontext: ForbiddenException', async () => {
const controller = new NextcloudFilesController( const controller = new NextcloudFilesController(
makeSettings() as any, makeSettings() as any,
@@ -13,9 +13,12 @@ import {
Req, Req,
Res, Res,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client';
import type { Response } from 'express'; import type { Response } from 'express';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard'; import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { normalizeCloudUrl } from '../nextcloud-status/nextcloud-status-fetch';
import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto'; import { ConnectPasswordDto } from './dto/nextcloud-files-connect.dto';
import { import {
CreateFolderDto, CreateFolderDto,
@@ -49,9 +52,14 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
* die Benutzerkennung (spaetere Aufgaben) ausschliesslich aus dem Token. * die Benutzerkennung (spaetere Aufgaben) ausschliesslich aus dem Token.
* *
* Rechte je Route (quick-261008-mzu, D-N): * Rechte je Route (quick-261008-mzu, D-N):
* Nur Administratoren (`@Roles(ADMIN, SUPER_ADMIN)`, CR-02): PUT settings. Die
* Adresse bestimmt, wohin ALLE Benutzer ihr Nextcloud-Passwort
* schicken (oft das Verzeichnis-Passwort); das darf keine nur
* delegierte Freigabe.
* Verwalten (`@ModuleManage('nextcloud-files')`, Administratoren und Benutzer * Verwalten (`@ModuleManage('nextcloud-files')`, Administratoren und Benutzer
* mit der Freigabestufe Verwalten): GET settings, PUT settings, * mit der Freigabestufe Verwalten): GET settings, POST settings/test —
* POST settings/test. * wer nicht Administrator ist, darf dabei nur die GESPEICHERTE
* Adresse pruefen (keine neue, kein Abtasten anderer Adressen).
* Benutzen (nur Klassen-`@UseModule`): alles andere — GET status, GET server, * Benutzen (nur Klassen-`@UseModule`): alles andere — GET status, GET server,
* GET server/logo, POST connect/password, POST connect/flow, * GET server/logo, POST connect/password, POST connect/flow,
* DELETE connect, GET files, DELETE files, POST folders, POST move, * DELETE connect, GET files, DELETE files, POST folders, POST move,
@@ -61,7 +69,8 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE * uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
* uploads/:uploadId. Jeder Benutzer arbeitet nur im eigenen Konto. * uploads/:uploadId. Jeder Benutzer arbeitet nur im eigenen Konto.
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale * Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
* RolesGuard wuerde Verwalter sonst aussperren. * RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler
* traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`).
* *
* REIHENFOLGE: alle statischen Routen stehen VOR jeder Route mit `:param`, sonst * REIHENFOLGE: alle statischen Routen stehen VOR jeder Route mit `:param`, sonst
* faengt die Parameterroute sie ab (404-Shadowing, Unit-Tests ohne die * faengt die Parameterroute sie ab (404-Shadowing, Unit-Tests ohne die
@@ -78,6 +87,14 @@ function headerOf(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value; return Array.isArray(value) ? value[0] : value;
} }
const ADDRESS_ADMIN_ONLY =
'Nur Administratoren dürfen die Nextcloud-Adresse ändern oder eine andere Adresse prüfen.';
function isAdmin(req: AuthenticatedRequest): boolean {
const role = req.user?.role;
return role === Role.ADMIN || role === Role.SUPER_ADMIN;
}
@Controller('modules/nextcloud-files') @Controller('modules/nextcloud-files')
@UseModule('nextcloud-files') @UseModule('nextcloud-files')
export class NextcloudFilesController { export class NextcloudFilesController {
@@ -129,8 +146,9 @@ export class NextcloudFilesController {
return this.settings.getSettings(this.requireTenantId(req)); return this.settings.getSettings(this.requireTenantId(req));
} }
/** Die Adresse legt nur ein Administrator fest (CR-02). */
@Put('settings') @Put('settings')
@ModuleManage('nextcloud-files') @Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: SaveNextcloudFilesSettingsDto) { async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: SaveNextcloudFilesSettingsDto) {
return this.settings.saveSettings(this.requireTenantId(req), dto); return this.settings.saveSettings(this.requireTenantId(req), dto);
} }
@@ -139,7 +157,14 @@ export class NextcloudFilesController {
@HttpCode(200) @HttpCode(200)
@ModuleManage('nextcloud-files') @ModuleManage('nextcloud-files')
async testSettings(@Req() req: AuthenticatedRequest, @Body() dto: TestNextcloudFilesSettingsDto) { async testSettings(@Req() req: AuthenticatedRequest, @Body() dto: TestNextcloudFilesSettingsDto) {
this.requireTenantId(req); const tenantId = this.requireTenantId(req);
if (!isAdmin(req)) {
// Verwalter ohne Administratorrolle pruefen nur die gespeicherte Adresse (CR-02, WR-01).
const saved = await this.settings.getBaseUrl(tenantId);
if (saved === null || normalizeCloudUrl(dto.baseUrl) !== saved) {
throw new ForbiddenException(ADDRESS_ADMIN_ONLY);
}
}
return this.settings.testAddress(dto.baseUrl); return this.settings.testAddress(dto.baseUrl);
} }
@@ -11,6 +11,7 @@ import {
saveNextcloudFilesSettings, saveNextcloudFilesSettings,
testNextcloudFilesSettings, testNextcloudFilesSettings,
} from '@/lib/nextcloud-files-api'; } from '@/lib/nextcloud-files-api';
import { useAuthStore } from '@/lib/stores/auth-store';
const INPUT_CLASS = const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring'; 'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
@@ -41,11 +42,16 @@ function MessageLine({ message }: { message: Message | null }) {
* Einstellungen des Moduls Dateien (quick-261008-mzu): die EINE Nextcloud-Adresse * Einstellungen des Moduls Dateien (quick-261008-mzu): die EINE Nextcloud-Adresse
* der Organisation, die Verbindungspruefung und Hinweise fuer die * der Organisation, die Verbindungspruefung und Hinweise fuer die
* Nextcloud-Administration. Nur fuer Verwalter sichtbar (die Seite blendet den * Nextcloud-Administration. Nur fuer Verwalter sichtbar (die Seite blendet den
* Reiter sonst aus; bindend ist die API). * Reiter sonst aus; bindend ist die API). Die Adresse AENDERN duerfen nur
* Administratoren (CR-02): alle Benutzer schicken ihr Nextcloud-Passwort an diese
* Adresse. Verwalter ohne Administratorrolle sehen sie nur und koennen die
* gespeicherte Adresse pruefen.
*/ */
export function SettingsTab({ onChanged }: { onChanged: () => void }) { export function SettingsTab({ onChanged }: { onChanged: () => void }) {
const t = useTranslations('nextcloudFiles.settings'); const t = useTranslations('nextcloudFiles.settings');
const tErrors = useTranslations('nextcloudFiles.errors'); const tErrors = useTranslations('nextcloudFiles.errors');
const role = useAuthStore((s) => s.user?.role);
const isAdmin = role === 'ADMIN' || role === 'SUPER_ADMIN';
const [settings, setSettings] = useState<NextcloudFilesSettings | null>(null); const [settings, setSettings] = useState<NextcloudFilesSettings | null>(null);
const [address, setAddress] = useState(''); const [address, setAddress] = useState('');
@@ -81,7 +87,9 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
setTesting(true); setTesting(true);
setMessage(null); setMessage(null);
try { try {
showCheck(await testNextcloudFilesSettings(address.trim())); // Ohne Administratorrolle pruefen nur die gespeicherte Adresse (die API lehnt andere ab).
const target = isAdmin ? address.trim() : (settings?.baseUrl ?? '');
showCheck(await testNextcloudFilesSettings(target));
} catch (error) { } catch (error) {
setMessage({ kind: 'error', text: errorText(error) }); setMessage({ kind: 'error', text: errorText(error) });
} finally { } finally {
@@ -128,7 +136,7 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
}; };
const busy = testing || saving; const busy = testing || saving;
const empty = address.trim() === ''; const empty = isAdmin ? address.trim() === '' : !settings?.baseUrl;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -148,14 +156,16 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
> >
{testing ? t('address.testing') : t('address.test')} {testing ? t('address.testing') : t('address.test')}
</button> </button>
<button {isAdmin && (
type="button" <button
className={PRIMARY_BUTTON} type="button"
disabled={busy || empty || loadFailed} className={PRIMARY_BUTTON}
onClick={onSaveClick} disabled={busy || empty || loadFailed}
> onClick={onSaveClick}
{saving ? t('saving') : t('save')} >
</button> {saving ? t('saving') : t('save')}
</button>
)}
</> </>
) : ( ) : (
<> <>
@@ -200,15 +210,22 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
inputMode="url" inputMode="url"
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
className={INPUT_CLASS} className={`${INPUT_CLASS} read-only:cursor-default read-only:bg-well read-only:text-muted-foreground`}
value={address} value={address}
placeholder={t('address.placeholder')} readOnly={!isAdmin}
aria-describedby={isAdmin ? undefined : 'nextcloud-files-address-admin-only'}
placeholder={isAdmin ? t('address.placeholder') : undefined}
onChange={(event) => { onChange={(event) => {
setAddress(event.target.value); setAddress(event.target.value);
setMessage(null); setMessage(null);
setConfirmCount(null); setConfirmCount(null);
}} }}
/> />
{!isAdmin && (
<p id="nextcloud-files-address-admin-only" className="text-xs text-muted-foreground">
{t('address.adminOnly')}
</p>
)}
{settings?.baseUrl && ( {settings?.baseUrl && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t('address.connectedUsers', { count: settings.connectedAccounts })} {t('address.connectedUsers', { count: settings.connectedAccounts })}
@@ -17,6 +17,7 @@ import {
type NextcloudFilesStatus, type NextcloudFilesStatus,
type NextcloudServerInfo, type NextcloudServerInfo,
} from '@/lib/nextcloud-files-api'; } from '@/lib/nextcloud-files-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import de from '@/messages/de.json'; import de from '@/messages/de.json';
import NextcloudFilesPage from './page'; import NextcloudFilesPage from './page';
@@ -90,6 +91,7 @@ function settings(over: Partial<NextcloudFilesSettings> = {}): NextcloudFilesSet
beforeEach(() => { beforeEach(() => {
mockCanManage = true; mockCanManage = true;
useAuthStore.setState({ user: { id: 'a1', role: 'ADMIN' } as never });
mockGetStatus.mockReset().mockResolvedValue(status()); mockGetStatus.mockReset().mockResolvedValue(status());
mockGetSettings.mockReset().mockResolvedValue(settings()); mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset(); mockSaveSettings.mockReset();
@@ -135,6 +137,7 @@ describe('NextcloudFilesPage', () => {
it('nicht eingerichtet, Benutzen: Hinweis auf einen Administrator, kein Reiter, kein Einstellungsknopf', async () => { it('nicht eingerichtet, Benutzen: Hinweis auf einen Administrator, kein Reiter, kein Einstellungsknopf', async () => {
mockCanManage = false; mockCanManage = false;
useAuthStore.setState({ user: { id: 'u1', role: 'USER' } as never });
mockGetStatus.mockResolvedValue(status({ configured: false, serverUrl: null, host: null })); mockGetStatus.mockResolvedValue(status({ configured: false, serverUrl: null, host: null }));
render(<NextcloudFilesPage />); render(<NextcloudFilesPage />);
expect(await screen.findByText(/Bitte wenden Sie sich an einen Administrator/)).toBeTruthy(); expect(await screen.findByText(/Bitte wenden Sie sich an einen Administrator/)).toBeTruthy();
@@ -144,6 +147,14 @@ describe('NextcloudFilesPage', () => {
expect(mockGetSettings).not.toHaveBeenCalled(); expect(mockGetSettings).not.toHaveBeenCalled();
}); });
it('nicht eingerichtet, Verwalten ohne Administratorrolle: Hinweis auf einen Administrator, kein Knopf (CR-02)', async () => {
useAuthStore.setState({ user: { id: 'm1', role: 'USER' } as never });
mockGetStatus.mockResolvedValue(status({ configured: false, serverUrl: null, host: null }));
render(<NextcloudFilesPage />);
expect(await screen.findByText(/Nur er kann die Adresse der Nextcloud eintragen/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Zu den Einstellungen' })).toBeNull();
});
it('Verwalter sehen die Reiter Dateien und Einstellungen', async () => { it('Verwalter sehen die Reiter Dateien und Einstellungen', async () => {
render(<NextcloudFilesPage />); render(<NextcloudFilesPage />);
expect(await screen.findByRole('button', { name: 'Dateien' })).toBeTruthy(); expect(await screen.findByRole('button', { name: 'Dateien' })).toBeTruthy();
@@ -256,6 +267,26 @@ describe('SettingsTab', () => {
expect(await screen.findByText('Verbindung erfolgreich.')).toBeTruthy(); expect(await screen.findByText('Verbindung erfolgreich.')).toBeTruthy();
}); });
it('Verwalten ohne Administratorrolle: Adresse nur lesbar, kein Speichern, Pruefung der gespeicherten Adresse (CR-02)', async () => {
useAuthStore.setState({ user: { id: 'm1', role: 'USER' } as never });
mockTestSettings.mockResolvedValue({
ok: true,
kind: 'ok',
message: 'Verbindung erfolgreich.',
version: null,
productName: null,
});
const input = (await openSettings()) as HTMLInputElement;
await waitFor(() => expect(input.value).toBe('https://cloud.example'));
expect(input.readOnly).toBe(true);
expect(screen.getByText(/Die Adresse legt ein Administrator fest/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Speichern' })).toBeNull();
fireEvent.change(input, { target: { value: 'http://10.0.0.5' } });
fireEvent.click(screen.getByRole('button', { name: 'Verbindung prüfen' }));
await waitFor(() => expect(mockTestSettings).toHaveBeenCalledWith('https://cloud.example'));
expect(mockSaveSettings).not.toHaveBeenCalled();
});
it('die Hinweiskarte nennt den Befehl fuer die Ausnahmeliste', async () => { it('die Hinweiskarte nennt den Befehl fuer die Ausnahmeliste', async () => {
await openSettings(); await openSettings();
expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy(); expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy();
@@ -11,6 +11,7 @@ import {
type NextcloudFilesStatus, type NextcloudFilesStatus,
type NextcloudServerInfo, type NextcloudServerInfo,
} from '@/lib/nextcloud-files-api'; } from '@/lib/nextcloud-files-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability'; import { useCanManageModule } from '@/lib/use-module-capability';
import { AccountBar } from './components/AccountBar'; import { AccountBar } from './components/AccountBar';
import { ConnectPanel } from './components/ConnectPanel'; import { ConnectPanel } from './components/ConnectPanel';
@@ -21,9 +22,10 @@ type TabId = 'files' | 'settings';
/** /**
* Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera. * Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera.
* Die Adresse der Nextcloud stellen Administratoren und Benutzer mit der * Den Reiter "Einstellungen" sehen Administratoren und Benutzer mit der
* Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur * Freigabestufe Verwalten; die Adresse der Nextcloud AENDERN darf nur ein
* den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Im * Administrator (CR-02). Alle anderen sehen nur den Reiter "Dateien" ohne
* Reiterleiste — bindend ist allein die API. Im
* Abschnitt `nextcloud-files-main` verbindet jeder Benutzer sein eigenes Konto * Abschnitt `nextcloud-files-main` verbindet jeder Benutzer sein eigenes Konto
* (ConnectPanel); ist es verbunden, steht dort die Dateiansicht * (ConnectPanel); ist es verbunden, steht dort die Dateiansicht
* (`nextcloud-files-browser`, FileBrowser). Laeuft die Verbindung ab, laedt die * (`nextcloud-files-browser`, FileBrowser). Laeuft die Verbindung ab, laedt die
@@ -32,6 +34,8 @@ type TabId = 'files' | 'settings';
export default function NextcloudFilesPage() { export default function NextcloudFilesPage() {
const t = useTranslations('nextcloudFiles'); const t = useTranslations('nextcloudFiles');
const canManage = useCanManageModule('nextcloud-files') === true; const canManage = useCanManageModule('nextcloud-files') === true;
const role = useAuthStore((s) => s.user?.role);
const isAdmin = role === 'ADMIN' || role === 'SUPER_ADMIN';
const [status, setStatus] = useState<NextcloudFilesStatus | null>(null); const [status, setStatus] = useState<NextcloudFilesStatus | null>(null);
const [statusError, setStatusError] = useState(false); const [statusError, setStatusError] = useState(false);
@@ -107,9 +111,9 @@ export default function NextcloudFilesPage() {
{activeTab === 'files' && status && !status.configured && ( {activeTab === 'files' && status && !status.configured && (
<SettingsSection title={t('notConfigured.title')}> <SettingsSection title={t('notConfigured.title')}>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')} {isAdmin ? t('notConfigured.managerHint') : t('notConfigured.userHint')}
</p> </p>
{canManage && ( {isAdmin && canManage && (
<button <button
type="button" type="button"
onClick={() => setTab('settings')} onClick={() => setTab('settings')}
+3 -2
View File
@@ -2331,7 +2331,7 @@
"notConfigured": { "notConfigured": {
"title": "Die Nextcloud ist noch nicht eingerichtet.", "title": "Die Nextcloud ist noch nicht eingerichtet.",
"managerHint": "Tragen Sie in den Einstellungen die Adresse Ihrer Nextcloud ein.", "managerHint": "Tragen Sie in den Einstellungen die Adresse Ihrer Nextcloud ein.",
"userHint": "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten.", "userHint": "Bitte wenden Sie sich an einen Administrator. Nur er kann die Adresse der Nextcloud eintragen.",
"goToSettings": "Zu den Einstellungen" "goToSettings": "Zu den Einstellungen"
}, },
"connect": { "connect": {
@@ -2390,7 +2390,8 @@
"placeholder": "https://cloud.ihre-firma.de", "placeholder": "https://cloud.ihre-firma.de",
"test": "Verbindung prüfen", "test": "Verbindung prüfen",
"testing": "Prüfe …", "testing": "Prüfe …",
"connectedUsers": "{count, plural, =0 {Zurzeit ist niemand verbunden.} one {Ein Benutzer ist verbunden.} other {# Benutzer sind verbunden.}}" "connectedUsers": "{count, plural, =0 {Zurzeit ist niemand verbunden.} one {Ein Benutzer ist verbunden.} other {# Benutzer sind verbunden.}}",
"adminOnly": "Die Adresse legt ein Administrator fest. Sie können sie hier ansehen und die Verbindung prüfen."
}, },
"reconnect": { "reconnect": {
"message": "{count, plural, one {Ein Benutzer ist verbunden. Nach dem Wechsel muss er sich neu anmelden.} other {# Benutzer sind verbunden. Nach dem Wechsel müssen sich alle neu anmelden.}}", "message": "{count, plural, one {Ein Benutzer ist verbunden. Nach dem Wechsel muss er sich neu anmelden.} other {# Benutzer sind verbunden. Nach dem Wechsel müssen sich alle neu anmelden.}}",
+3 -2
View File
@@ -2331,7 +2331,7 @@
"notConfigured": { "notConfigured": {
"title": "Nextcloud has not been set up yet.", "title": "Nextcloud has not been set up yet.",
"managerHint": "Enter the address of your Nextcloud in the settings.", "managerHint": "Enter the address of your Nextcloud in the settings.",
"userHint": "Please contact an administrator or someone with the Manage permission level.", "userHint": "Please contact an administrator. Only an administrator can enter the Nextcloud address.",
"goToSettings": "Go to settings" "goToSettings": "Go to settings"
}, },
"connect": { "connect": {
@@ -2390,7 +2390,8 @@
"placeholder": "https://cloud.your-company.com", "placeholder": "https://cloud.your-company.com",
"test": "Check connection", "test": "Check connection",
"testing": "Checking …", "testing": "Checking …",
"connectedUsers": "{count, plural, =0 {Nobody is connected right now.} one {One user is connected.} other {# users are connected.}}" "connectedUsers": "{count, plural, =0 {Nobody is connected right now.} one {One user is connected.} other {# users are connected.}}",
"adminOnly": "The address is set by an administrator. You can view it here and check the connection."
}, },
"reconnect": { "reconnect": {
"message": "{count, plural, one {One user is connected. After the change they have to sign in again.} other {# users are connected. After the change everyone has to sign in again.}}", "message": "{count, plural, one {One user is connected. After the change they have to sign in again.} other {# users are connected. After the change everyone has to sign in again.}}",
+1 -1
View File
@@ -358,7 +358,7 @@ Das Modul „Nextcloud-Status“ (Gruppe Infrastruktur) aktivieren Sie wie jedes
### Dateien: Nextcloud anbinden ### Dateien: Nextcloud anbinden
Das Modul „Dateien“ (Gruppe Infrastruktur) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Adresse der Nextcloud eintragen und prüfen** dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“ (Reiter „Einstellungen“ im Modul); vergeben Sie diese Stufe nur an Personen, denen Sie das zutrauen, denn die Adresse ruft der Tessera-Server selbst auf. Wer nur „Benutzen“ hat, sieht den Reiter nicht. Das Modul „Dateien“ (Gruppe Infrastruktur) aktivieren Sie wie jedes Modul im Marktplatz; wer es nutzen soll, bekommt die Freigabe „Benutzen“. **Die Adresse der Nextcloud legt ausschließlich ein Administrator fest** (Reiter „Einstellungen“ im Modul). Der Grund: Alle Benutzer schicken ihr Nextcloud-Passwort an diese Adresse, und das ist oft zugleich ihr Windows-Passwort. Benutzer mit der Freigabestufe „Verwalten“ sehen den Reiter ebenfalls, können die eingetragene Adresse aber nur ansehen und mit „Verbindung prüfen“ testen, nicht ändern. Wer nur „Benutzen“ hat, sieht den Reiter nicht.
- **Adresse:** Tragen Sie die Adresse so ein, wie Ihre Benutzer die Nextcloud im Browser aufrufen, möglichst mit `https://` (zum Beispiel `https://cloud.ihre-firma.de`). Es gibt genau eine Nextcloud für die ganze Installation. Interne Adressen sind erlaubt. „Verbindung prüfen“ ruft nur die öffentliche Statusseite ab und nennt Version oder den Grund, warum es nicht klappt. Tessera folgt nie einer Weiterleitung: Leitet die Adresse um (zum Beispiel von `http` auf `https`), tragen Sie die endgültige Adresse ein. - **Adresse:** Tragen Sie die Adresse so ein, wie Ihre Benutzer die Nextcloud im Browser aufrufen, möglichst mit `https://` (zum Beispiel `https://cloud.ihre-firma.de`). Es gibt genau eine Nextcloud für die ganze Installation. Interne Adressen sind erlaubt. „Verbindung prüfen“ ruft nur die öffentliche Statusseite ab und nennt Version oder den Grund, warum es nicht klappt. Tessera folgt nie einer Weiterleitung: Leitet die Adresse um (zum Beispiel von `http` auf `https`), tragen Sie die endgültige Adresse ein.
- **Vertrauenswürdige Domains:** Meldet die Prüfung „Nextcloud lehnt diese Adresse ab“, muss der Rechnername der Adresse in der Nextcloud unter `trusted_domains` stehen (`config.php`, oder `occ config:system:set trusted_domains 1 --value=<Rechnername>`). - **Vertrauenswürdige Domains:** Meldet die Prüfung „Nextcloud lehnt diese Adresse ab“, muss der Rechnername der Adresse in der Nextcloud unter `trusted_domains` stehen (`config.php`, oder `occ config:system:set trusted_domains 1 --value=<Rechnername>`).
+1 -1
View File
@@ -244,7 +244,7 @@ Pro Änderung kommt genau eine Nachricht; solange die Störung anhält, wiederho
### Dateien (Nextcloud) ### Dateien (Nextcloud)
Das Modul zeigt Ihre Dateien aus der Firmen-Nextcloud direkt in Tessera: ansehen, hochladen, herunterladen, ordnen. Es steht in der Seitenleiste in der Gruppe **Infrastruktur**. Jeder arbeitet nur in seinem eigenen Nextcloud-Konto; niemand sieht die Dateien eines Kollegen. Die Adresse der Nextcloud stellt ein Administrator (oder jemand mit der Freigabe „Verwalten“) einmal im Reiter **Einstellungen** ein. Steht dort noch nichts, sagt Ihnen die Seite das. Das Modul zeigt Ihre Dateien aus der Firmen-Nextcloud direkt in Tessera: ansehen, hochladen, herunterladen, ordnen. Es steht in der Seitenleiste in der Gruppe **Infrastruktur**. Jeder arbeitet nur in seinem eigenen Nextcloud-Konto; niemand sieht die Dateien eines Kollegen. Die Adresse der Nextcloud stellt ein Administrator einmal im Reiter **Einstellungen** ein. Steht dort noch nichts, sagt Ihnen die Seite das.
**Anmelden:** Beim ersten Öffnen sehen Sie den Anmeldebildschirm mit Name, Logo und Farbe Ihrer Nextcloud, damit Sie wissen, wo Sie sich anmelden. Es gibt zwei Wege: **Anmelden:** Beim ersten Öffnen sehen Sie den Anmeldebildschirm mit Name, Logo und Farbe Ihrer Nextcloud, damit Sie wissen, wo Sie sich anmelden. Es gibt zwei Wege: