feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten
- Proxmox-Schreibwege und Handelsware-Einstellungen auf @ModuleManage umgestellt - DKV-Fleet: ganze Klasse Verwalten-Stufe, Benutzen allein bleibt ohne Zugriff - Metadaten-Test belegt umgestellte und bewusst Administratoren vorbehaltene Handler - Webseiten (Proxmox, Handelsware, Widget) folgen canManage, DKV-Zugriffsseite erklärt die Stufe Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -41,12 +41,12 @@ type Message = { kind: 'error' | 'ok'; text: string; code?: string | null };
|
||||
*/
|
||||
export function ImportTab({
|
||||
settings,
|
||||
isAdmin,
|
||||
canManage,
|
||||
onOpenSettings,
|
||||
onAccountsChanged,
|
||||
}: {
|
||||
settings: HandelswareSettings | null;
|
||||
isAdmin: boolean;
|
||||
canManage: boolean;
|
||||
onOpenSettings: () => void;
|
||||
onAccountsChanged: () => void;
|
||||
}) {
|
||||
@@ -154,8 +154,8 @@ export function ImportTab({
|
||||
<div className="space-y-5">
|
||||
{blockedBySettings && (
|
||||
<div className="space-y-2 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground">
|
||||
<p>{isAdmin ? t('notConfigured.admin') : t('notConfigured.user')}</p>
|
||||
{isAdmin && (
|
||||
<p>{canManage ? t('notConfigured.admin') : t('notConfigured.user')}</p>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
|
||||
@@ -116,14 +116,24 @@ async function upload(file = xlsx()) {
|
||||
const downloadButton = () =>
|
||||
screen.getByRole('button', { name: 'Buchungsdatei herunterladen' }) as HTMLButtonElement;
|
||||
|
||||
/** Antwort von GET /modules/active für den Fähigkeits-Hook (261002-icv). */
|
||||
const mockFetch = vi.fn();
|
||||
function stubActiveModules(entries: unknown[]) {
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => entries });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser('USER');
|
||||
api.getHandelswareSettings.mockResolvedValue(CONFIGURED);
|
||||
api.listAccounts.mockResolvedValue(ACCOUNTS);
|
||||
stubActiveModules([{ slug: 'handelsware-datev', canManage: false }]);
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
mockFetch.mockReset();
|
||||
for (const m of [...Object.values(api), mockDownload, mockAuthStore]) m.mockReset();
|
||||
});
|
||||
|
||||
@@ -219,7 +229,7 @@ describe('HandelswareDatevPage — Import', () => {
|
||||
it('normaler Benutzer sieht "Ein Administrator muss zuerst …" und keinen Einstellungen-Reiter', async () => {
|
||||
api.getHandelswareSettings.mockResolvedValue(EMPTY);
|
||||
render(<HandelswareDatevPage />);
|
||||
expect(await screen.findByText(/Ein Administrator muss zuerst/)).toBeTruthy();
|
||||
expect(await screen.findByText(/muss zuerst Standard-Erlöskonto/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -348,6 +358,21 @@ describe('HandelswareDatevPage — Konten', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('HandelswareDatevPage — Freigabestufe Verwalten (261002-icv)', () => {
|
||||
it('Benutzer mit canManage sieht den Einstellungen-Reiter', async () => {
|
||||
stubActiveModules([{ slug: 'handelsware-datev', canManage: true }]);
|
||||
render(<HandelswareDatevPage />);
|
||||
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Benutzer ohne canManage sieht keinen Einstellungen-Reiter', async () => {
|
||||
render(<HandelswareDatevPage />);
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
await screen.findByRole('button', { name: 'Konten' });
|
||||
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HandelswareDatevPage — Einstellungen', () => {
|
||||
it('Reiter nur für Administratoren, Eingaben validiert, Speichern ruft die API', async () => {
|
||||
mockUser('SUPER_ADMIN');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react';
|
||||
import { TabBar } from '@/components/accounting/tab-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { getHandelswareSettings, type HandelswareSettings } from '@/lib/handelsware-datev-api';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||
import { AccountsTab } from './components/AccountsTab';
|
||||
import { ImportTab } from './components/ImportTab';
|
||||
import { SettingsTab } from './components/SettingsTab';
|
||||
@@ -15,13 +15,13 @@ type TabId = 'import' | 'accounts' | 'settings';
|
||||
/**
|
||||
* Handelsware (quick-261002-fm5): Excel-Umsaetze Erloeskonten zuordnen und als
|
||||
* DATEV-Buchungsdatei herunterladen. Reiter Import / Konten / Einstellungen
|
||||
* (letzterer nur fuer Administratoren). Nach einem Export zaehlt `accountsVersion`
|
||||
* (letzterer fuer Administratoren und Benutzer mit der Freigabestufe Verwalten). Nach einem Export zaehlt `accountsVersion`
|
||||
* hoch, damit der Reiter "Konten" die neu gespeicherten Konten nachlaedt.
|
||||
*/
|
||||
export default function HandelswareDatevPage() {
|
||||
const t = useTranslations('handelswareDatev');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
// Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv).
|
||||
const canManage = useCanManageModule('handelsware-datev') === true;
|
||||
|
||||
const [tab, setTab] = useState<TabId>('import');
|
||||
const [settings, setSettings] = useState<HandelswareSettings | null>(null);
|
||||
@@ -45,8 +45,8 @@ export default function HandelswareDatevPage() {
|
||||
{ id: 'import', label: t('tabs.import') },
|
||||
{ id: 'accounts', label: t('tabs.accounts') },
|
||||
];
|
||||
if (isAdmin) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
||||
const activeTab = tab === 'settings' && !isAdmin ? 'import' : tab;
|
||||
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
|
||||
const activeTab = tab === 'settings' && !canManage ? 'import' : tab;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
|
||||
@@ -60,7 +60,7 @@ export default function HandelswareDatevPage() {
|
||||
{activeTab === 'import' && (
|
||||
<ImportTab
|
||||
settings={settings}
|
||||
isAdmin={isAdmin}
|
||||
canManage={canManage}
|
||||
onOpenSettings={() => setTab('settings')}
|
||||
onAccountsChanged={() => setAccountsVersion((v) => v + 1)}
|
||||
/>
|
||||
|
||||
@@ -125,7 +125,7 @@ describe('KantineDatevPage — nicht eingerichtet', () => {
|
||||
it('normaler Benutzer sieht den Administrator-Hinweis und keinen Einstellungen-Reiter', async () => {
|
||||
mockGetSettings.mockResolvedValue(EMPTY);
|
||||
render(<KantineDatevPage />);
|
||||
expect(await screen.findByText(/Ein Administrator muss zuerst/)).toBeTruthy();
|
||||
expect(await screen.findByText(/muss zuerst Beraternummer/)).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,9 +74,9 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
|
||||
|
||||
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
|
||||
|
||||
async function card(server: ProxmoxServer, isAdmin?: boolean) {
|
||||
async function card(server: ProxmoxServer, canManage?: boolean) {
|
||||
const { ServerCard } = await import('./ServerCard');
|
||||
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />);
|
||||
renderDe(<ServerCard server={server} canManage={canManage} now={NOW} />);
|
||||
return screen.getByTestId('server-card');
|
||||
}
|
||||
|
||||
@@ -392,7 +392,7 @@ describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
|
||||
expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => {
|
||||
it('canManage={false}: automatischer Hinweis ohne Knopfverweis', async () => {
|
||||
const el = await card(makeServer({ status: UNPOLLED }), false);
|
||||
|
||||
expect(
|
||||
@@ -403,7 +403,7 @@ describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
|
||||
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('isAdmin weggelassen: verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => {
|
||||
it('canManage weggelassen: verhaelt sich wie canManage={false} (sichere Vorgabe)', async () => {
|
||||
const el = await card(makeServer({ status: UNPOLLED }));
|
||||
|
||||
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
|
||||
|
||||
@@ -351,7 +351,7 @@ function errorMessage(t: Translator, kind: ProxmoxErrorKind | null): string {
|
||||
|
||||
interface ServerCardProps {
|
||||
server: ProxmoxServer;
|
||||
isAdmin?: boolean;
|
||||
canManage?: boolean;
|
||||
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
|
||||
now?: number;
|
||||
}
|
||||
@@ -363,7 +363,7 @@ interface ServerCardProps {
|
||||
* („offline & verwaist“) zeigt KEINE alten Messwerte mehr — auch wenn das
|
||||
* Zwischenlager noch welche hat.
|
||||
*/
|
||||
export function ServerCard({ server, isAdmin = false, now = Date.now() }: ServerCardProps) {
|
||||
export function ServerCard({ server, canManage = false, now = Date.now() }: ServerCardProps) {
|
||||
const t = useTranslations('proxmox');
|
||||
const locale = useLocale();
|
||||
const health = serverHealth(server, now);
|
||||
@@ -377,7 +377,7 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
|
||||
body = (
|
||||
<div className="space-y-1 text-sm" data-testid="orphan-notice">
|
||||
<p className="text-foreground">{t('card.orphanText')}</p>
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<p className="text-muted-foreground">
|
||||
{t('card.orphanAdminHint')}{' '}
|
||||
<Link
|
||||
@@ -391,12 +391,12 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
|
||||
</div>
|
||||
);
|
||||
} else if (health === 'idle') {
|
||||
// Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt
|
||||
// ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis
|
||||
// (260923-le6).
|
||||
// Wer nicht verwalten darf, sieht den Knopf nicht (der Poll-Endpunkt
|
||||
// verlangt Verwalten fuer das Proxmox-Modul, 261002-icv) und bekommt
|
||||
// deshalb den Text ohne Knopfverweis (260923-le6).
|
||||
body = (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isAdmin
|
||||
{canManage
|
||||
? t('card.notPolledYet', { refreshLabel: t('card.refresh') })
|
||||
: t('card.notPolledYetAutomatic')}
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import Link from 'next/link';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import { HealthBar } from '@/components/proxmox/HealthBar';
|
||||
import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status';
|
||||
@@ -60,13 +60,14 @@ function SkeletonCard() {
|
||||
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
|
||||
* (down, warn, ok, idle, orphan, darin `position`). „Jetzt aktualisieren“
|
||||
* loest je Server eine Abfrage aus und laedt danach neu; der Knopf erscheint
|
||||
* nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)`
|
||||
* verlangt (260923-le6).
|
||||
* nur fuer Administratoren und Benutzer mit der Freigabestufe Verwalten, weil
|
||||
* `POST servers/:id/poll` `@ModuleManage('proxmox')` verlangt (260923-le6,
|
||||
* 261002-icv).
|
||||
*/
|
||||
export default function ProxmoxPage() {
|
||||
const t = useTranslations('proxmox');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
// Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv).
|
||||
const canManage = useCanManageModule('proxmox') === true;
|
||||
|
||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -112,7 +113,7 @@ export default function ProxmoxPage() {
|
||||
title={t('title')}
|
||||
description={t('description')}
|
||||
actions={
|
||||
isAdmin ? (
|
||||
canManage ? (
|
||||
<>
|
||||
<Link href="/modules/proxmox/settings" className="btn btn-subtle">
|
||||
{t('card.settingsNav')}
|
||||
@@ -171,7 +172,7 @@ export default function ProxmoxPage() {
|
||||
<line x1="7" y1="16.5" x2="7.01" y2="16.5" />
|
||||
</svg>
|
||||
<p className="max-w-md text-sm text-muted-foreground">{t('emptyState')}</p>
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<Link
|
||||
href="/modules/proxmox/settings"
|
||||
className="text-sm font-medium text-foreground hover:underline"
|
||||
@@ -191,7 +192,7 @@ export default function ProxmoxPage() {
|
||||
<ul className="gap-4 lg:columns-2">
|
||||
{sorted.map((server) => (
|
||||
<li key={server.id} className="mb-4 min-w-0 break-inside-avoid">
|
||||
<ServerCard server={server} isAdmin={isAdmin} now={now} />
|
||||
<ServerCard server={server} canManage={canManage} now={now} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import type { ReactElement } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
@@ -79,8 +79,22 @@ function makeUnpolledServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServ
|
||||
} as ProxmoxServer;
|
||||
}
|
||||
|
||||
// Antwort von GET /modules/active für den Fähigkeits-Hook (261002-icv):
|
||||
// Standard ist "keine Verwalten-Freigabe".
|
||||
const mockFetch = vi.fn();
|
||||
function stubActiveModules(entries: unknown[]) {
|
||||
mockFetch.mockResolvedValue({ ok: true, json: async () => entries });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubActiveModules([]);
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
mockFetch.mockReset();
|
||||
mockListServers.mockReset();
|
||||
mockPollServer.mockReset();
|
||||
mockAuthStore.mockReset();
|
||||
@@ -104,6 +118,38 @@ describe('ProxmoxPage role gating (260923-le6)', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Rolle USER mit canManage (Verwalten): Knopf, Einstellungen-Verweis und Admin-Text sichtbar (261002-icv)', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
stubActiveModules([{ slug: 'proxmox', canManage: true }]);
|
||||
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
|
||||
await screen.findByText('pve-1');
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Einstellungen' })).toHaveAttribute(
|
||||
'href',
|
||||
'/modules/proxmox/settings',
|
||||
);
|
||||
});
|
||||
|
||||
it('Rolle USER mit canManage false: bleibt schreibgeschützt', async () => {
|
||||
mockUser({ role: 'USER' });
|
||||
stubActiveModules([{ slug: 'proxmox', canManage: false }]);
|
||||
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
||||
|
||||
const { default: ProxmoxPage } = await import('./page');
|
||||
render(<ProxmoxPage />);
|
||||
|
||||
await screen.findByText('pve-1');
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Einstellungen' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('kein Benutzer geladen (user: null): kein Knopf', async () => {
|
||||
mockUser(null);
|
||||
mockListServers.mockResolvedValue([makeUnpolledServer()]);
|
||||
|
||||
@@ -88,7 +88,7 @@ const EXISTING_SERVER = {
|
||||
describe('ServerForm', () => {
|
||||
it('bei Typ pmg erscheint die Auswahl "API-Token" gar nicht', async () => {
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
const authSelect = screen.getByLabelText('Zugangsart') as HTMLSelectElement;
|
||||
const options = [...authSelect.options].map((o) => o.value);
|
||||
@@ -98,7 +98,7 @@ describe('ServerForm', () => {
|
||||
it('bei pve/pbs mit Token erscheinen Token-Kennung und -Geheimnis; bei Passwort Benutzer und Passwort', async () => {
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
|
||||
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={pveServer} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
expect(screen.getByLabelText('Token-Kennung')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Token-Geheimnis')).toBeInTheDocument();
|
||||
@@ -113,7 +113,7 @@ describe('ServerForm', () => {
|
||||
|
||||
it('ein gespeichertes Geheimnis wird nie im Klartext angezeigt — das Feld ist leer', async () => {
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
const passwordInput = screen.getByLabelText('Passwort') as HTMLInputElement;
|
||||
expect(passwordInput.value).toBe('');
|
||||
@@ -123,7 +123,7 @@ describe('ServerForm', () => {
|
||||
mockUpdateServer.mockResolvedValue(EXISTING_SERVER);
|
||||
const onSaved = vi.fn();
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={onSaved} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={onSaved} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Speichern'));
|
||||
|
||||
@@ -134,7 +134,7 @@ describe('ServerForm', () => {
|
||||
|
||||
it('der Schalter fuer die Zertifikatspruefung steht beim Anlegen auf "pruefen" mit Hinweistext', async () => {
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={null} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
const checkbox = screen.getByLabelText('Zertifikat prüfen') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
@@ -152,7 +152,7 @@ describe('ServerForm', () => {
|
||||
rawSample: null,
|
||||
});
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Verbindung testen'));
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('ServerForm', () => {
|
||||
rawSample: null,
|
||||
});
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Verbindung testen'));
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('ServerForm', () => {
|
||||
rawSample: null,
|
||||
});
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={null} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Adresse'), {
|
||||
target: { value: 'https://pve.neu:8006' },
|
||||
@@ -211,7 +211,7 @@ describe('ServerForm', () => {
|
||||
rawSample: null,
|
||||
});
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
// EXISTING_SERVER wurde MIT Zertifikatspruefung gespeichert — im Formular jetzt abschalten.
|
||||
fireEvent.click(screen.getByLabelText('Zertifikat prüfen'));
|
||||
@@ -233,7 +233,7 @@ describe('ServerForm', () => {
|
||||
});
|
||||
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
|
||||
const { ServerForm } = await import('./ServerForm');
|
||||
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
render(<ServerForm server={pveServer} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Verbindung testen'));
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ function serverToForm(server: ProxmoxServer | null): FormState {
|
||||
|
||||
interface ServerFormProps {
|
||||
server: ProxmoxServer | null;
|
||||
isAdmin: boolean;
|
||||
canManage: boolean;
|
||||
onSaved: (server: ProxmoxServer) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -72,7 +72,7 @@ interface ServerFormProps {
|
||||
* Geheimnis wird nie im Klartext angezeigt: das Feld ist leer, ein leer
|
||||
* gelassenes Feld laesst den gespeicherten Wert unveraendert.
|
||||
*/
|
||||
export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormProps) {
|
||||
export function ServerForm({ server, canManage, onSaved, onCancel }: ServerFormProps) {
|
||||
const t = useTranslations('proxmox');
|
||||
const [form, setForm] = useState<FormState>(() => serverToForm(server));
|
||||
const [savedServer, setSavedServer] = useState<ProxmoxServer | null>(server);
|
||||
@@ -189,7 +189,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
type="text"
|
||||
className={inputCls}
|
||||
value={form.name}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -202,7 +202,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
id="proxmox-product-type"
|
||||
className={inputCls}
|
||||
value={form.productType}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => handleProductTypeChange(e.target.value as ProxmoxProductType)}
|
||||
>
|
||||
<option value="pve">{t('settings.productTypePve')}</option>
|
||||
@@ -221,7 +221,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
placeholder="https://pve.intern:8006"
|
||||
className={inputCls}
|
||||
value={form.baseUrl}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('baseUrl', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -234,7 +234,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
id="proxmox-auth-method"
|
||||
className={inputCls}
|
||||
value={form.authMethod}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('authMethod', e.target.value as ProxmoxAuthMethod)}
|
||||
>
|
||||
{/* D-03: PMG kennt keinen API-Token — die Auswahl bietet ihn bei diesem Typ gar nicht erst an. */}
|
||||
@@ -255,7 +255,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
placeholder="root@pam!tessera"
|
||||
className={inputCls}
|
||||
value={form.tokenId}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('tokenId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -269,7 +269,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
|
||||
className={inputCls}
|
||||
value={form.tokenSecret}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('tokenSecret', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -286,7 +286,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
placeholder="admin@pam"
|
||||
className={inputCls}
|
||||
value={form.username}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('username', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
|
||||
className={inputCls}
|
||||
value={form.password}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -318,7 +318,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
max={1440}
|
||||
className={inputCls}
|
||||
value={form.pollIntervalMin}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('pollIntervalMin', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -329,7 +329,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
id="proxmox-tls-reject"
|
||||
type="checkbox"
|
||||
checked={form.tlsRejectUnauthorized}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('tlsRejectUnauthorized', e.target.checked)}
|
||||
/>
|
||||
{t('settings.tlsRejectLabel')}
|
||||
@@ -343,7 +343,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
id="proxmox-active"
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
disabled={!isAdmin}
|
||||
disabled={!canManage}
|
||||
onChange={(e) => update('isActive', e.target.checked)}
|
||||
/>
|
||||
{t('settings.activeLabel')}
|
||||
@@ -362,7 +362,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useCanManageModule } from '@/lib/use-module-capability';
|
||||
import { deleteServer, listServers, type ProxmoxServer } from '@/lib/proxmox-api';
|
||||
import { ServerForm } from './components/ServerForm';
|
||||
|
||||
@@ -48,17 +48,18 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
|
||||
}
|
||||
|
||||
/**
|
||||
* Moduleinstellungen (Aufgabe 5) — ADMINISTRATION ONLY. Die Rollenpruefung
|
||||
* hier ist reine Anzeige (Ladezustand solange die Rolle unbekannt ist,
|
||||
* damit die Verwaltungsteile fuer einen normalen Benutzer nie kurz
|
||||
* aufblitzen) — der verbindliche Riegel liegt serverseitig
|
||||
* (`@Roles(ADMIN, SUPER_ADMIN)` auf jedem Schreibweg, Vorbild
|
||||
* `tender-radar/settings/page.tsx`).
|
||||
* Moduleinstellungen (Aufgabe 5) — fuer Administratoren und Benutzer mit der
|
||||
* Freigabestufe Verwalten (261002-icv). Die Pruefung hier ist reine Anzeige
|
||||
* (Ladezustand solange die Faehigkeit unbekannt ist, damit die
|
||||
* Verwaltungsteile fuer einen normalen Benutzer nie kurz aufblitzen) — der
|
||||
* verbindliche Riegel liegt serverseitig (`@ModuleManage('proxmox')` auf
|
||||
* jedem Schreibweg).
|
||||
*/
|
||||
export default function ProxmoxSettingsPage() {
|
||||
const t = useTranslations('proxmox');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
// null solange unklar: Verwaltungsteile blitzen nie kurz auf (261002-icv).
|
||||
const canManageOrNull = useCanManageModule('proxmox');
|
||||
const canManage = canManageOrNull === true;
|
||||
|
||||
const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | 'new' | null>(null);
|
||||
@@ -93,7 +94,7 @@ export default function ProxmoxSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (user === null) {
|
||||
if (canManageOrNull === null) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<div className="mb-6 h-8 w-64 animate-pulse rounded bg-muted" />
|
||||
@@ -102,7 +103,7 @@ export default function ProxmoxSettingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
if (!canManage) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<h1 className="mb-4 text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
|
||||
@@ -127,7 +128,7 @@ export default function ProxmoxSettingsPage() {
|
||||
{editingId === 'new' && (
|
||||
<ServerForm
|
||||
server={null}
|
||||
isAdmin={isAdmin}
|
||||
canManage={canManage}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
@@ -143,7 +144,7 @@ export default function ProxmoxSettingsPage() {
|
||||
<li key={server.id}>
|
||||
<ServerForm
|
||||
server={server}
|
||||
isAdmin={isAdmin}
|
||||
canManage={canManage}
|
||||
onSaved={handleSaved}
|
||||
onCancel={() => setEditingId(null)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user