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
@@ -11,6 +11,7 @@ import {
saveNextcloudFilesSettings,
testNextcloudFilesSettings,
} from '@/lib/nextcloud-files-api';
import { useAuthStore } from '@/lib/stores/auth-store';
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';
@@ -41,11 +42,16 @@ function MessageLine({ message }: { message: Message | null }) {
* Einstellungen des Moduls Dateien (quick-261008-mzu): die EINE Nextcloud-Adresse
* der Organisation, die Verbindungspruefung und Hinweise fuer die
* 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 }) {
const t = useTranslations('nextcloudFiles.settings');
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 [address, setAddress] = useState('');
@@ -81,7 +87,9 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
setTesting(true);
setMessage(null);
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) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
@@ -128,7 +136,7 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
};
const busy = testing || saving;
const empty = address.trim() === '';
const empty = isAdmin ? address.trim() === '' : !settings?.baseUrl;
return (
<div className="space-y-6">
@@ -148,14 +156,16 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
>
{testing ? t('address.testing') : t('address.test')}
</button>
<button
type="button"
className={PRIMARY_BUTTON}
disabled={busy || empty || loadFailed}
onClick={onSaveClick}
>
{saving ? t('saving') : t('save')}
</button>
{isAdmin && (
<button
type="button"
className={PRIMARY_BUTTON}
disabled={busy || empty || loadFailed}
onClick={onSaveClick}
>
{saving ? t('saving') : t('save')}
</button>
)}
</>
) : (
<>
@@ -200,15 +210,22 @@ export function SettingsTab({ onChanged }: { onChanged: () => void }) {
inputMode="url"
autoComplete="off"
spellCheck={false}
className={INPUT_CLASS}
className={`${INPUT_CLASS} read-only:cursor-default read-only:bg-well read-only:text-muted-foreground`}
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) => {
setAddress(event.target.value);
setMessage(null);
setConfirmCount(null);
}}
/>
{!isAdmin && (
<p id="nextcloud-files-address-admin-only" className="text-xs text-muted-foreground">
{t('address.adminOnly')}
</p>
)}
{settings?.baseUrl && (
<p className="text-xs text-muted-foreground">
{t('address.connectedUsers', { count: settings.connectedAccounts })}
@@ -17,6 +17,7 @@ import {
type NextcloudFilesStatus,
type NextcloudServerInfo,
} from '@/lib/nextcloud-files-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import de from '@/messages/de.json';
import NextcloudFilesPage from './page';
@@ -90,6 +91,7 @@ function settings(over: Partial<NextcloudFilesSettings> = {}): NextcloudFilesSet
beforeEach(() => {
mockCanManage = true;
useAuthStore.setState({ user: { id: 'a1', role: 'ADMIN' } as never });
mockGetStatus.mockReset().mockResolvedValue(status());
mockGetSettings.mockReset().mockResolvedValue(settings());
mockSaveSettings.mockReset();
@@ -135,6 +137,7 @@ describe('NextcloudFilesPage', () => {
it('nicht eingerichtet, Benutzen: Hinweis auf einen Administrator, kein Reiter, kein Einstellungsknopf', async () => {
mockCanManage = false;
useAuthStore.setState({ user: { id: 'u1', role: 'USER' } as never });
mockGetStatus.mockResolvedValue(status({ configured: false, serverUrl: null, host: null }));
render(<NextcloudFilesPage />);
expect(await screen.findByText(/Bitte wenden Sie sich an einen Administrator/)).toBeTruthy();
@@ -144,6 +147,14 @@ describe('NextcloudFilesPage', () => {
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 () => {
render(<NextcloudFilesPage />);
expect(await screen.findByRole('button', { name: 'Dateien' })).toBeTruthy();
@@ -256,6 +267,26 @@ describe('SettingsTab', () => {
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 () => {
await openSettings();
expect(screen.getByText(/occ config:app:set bruteForce whitelist_0/)).toBeTruthy();
@@ -11,6 +11,7 @@ import {
type NextcloudFilesStatus,
type NextcloudServerInfo,
} from '@/lib/nextcloud-files-api';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCanManageModule } from '@/lib/use-module-capability';
import { AccountBar } from './components/AccountBar';
import { ConnectPanel } from './components/ConnectPanel';
@@ -21,9 +22,10 @@ type TabId = 'files' | 'settings';
/**
* Dateien (quick-261008-mzu): die Nextcloud-Dateien jedes Benutzers in Tessera.
* Die Adresse der Nextcloud stellen Administratoren und Benutzer mit der
* Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur
* den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Im
* Den Reiter "Einstellungen" sehen Administratoren und Benutzer mit der
* Freigabestufe Verwalten; die Adresse der Nextcloud AENDERN darf nur ein
* 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
* (ConnectPanel); ist es verbunden, steht dort die Dateiansicht
* (`nextcloud-files-browser`, FileBrowser). Laeuft die Verbindung ab, laedt die
@@ -32,6 +34,8 @@ type TabId = 'files' | 'settings';
export default function NextcloudFilesPage() {
const t = useTranslations('nextcloudFiles');
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 [statusError, setStatusError] = useState(false);
@@ -107,9 +111,9 @@ export default function NextcloudFilesPage() {
{activeTab === 'files' && status && !status.configured && (
<SettingsSection title={t('notConfigured.title')}>
<p className="text-sm text-muted-foreground">
{canManage ? t('notConfigured.managerHint') : t('notConfigured.userHint')}
{isAdmin ? t('notConfigured.managerHint') : t('notConfigured.userHint')}
</p>
{canManage && (
{isAdmin && canManage && (
<button
type="button"
onClick={() => setTab('settings')}
+3 -2
View File
@@ -2331,7 +2331,7 @@
"notConfigured": {
"title": "Die Nextcloud ist noch nicht eingerichtet.",
"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"
},
"connect": {
@@ -2390,7 +2390,8 @@
"placeholder": "https://cloud.ihre-firma.de",
"test": "Verbindung prüfen",
"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": {
"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": {
"title": "Nextcloud has not been set up yet.",
"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"
},
"connect": {
@@ -2390,7 +2390,8 @@
"placeholder": "https://cloud.your-company.com",
"test": "Check connection",
"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": {
"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.}}",