feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog

- Matrix und Benutzerdetails liefern und zeigen die Stufe, Auswahlfeld Benutzen/Verwalten
- Gruppen mit Verwalten sind in den Benutzerdetails markiert
- Administrationshandbuch, Anwenderanleitung und Changelog beschreiben die Stufen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 13:35:00 +02:00
parent c2ebc8daa0
commit eaf2c4574a
11 changed files with 463 additions and 31 deletions
+1
View File
@@ -9,6 +9,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
- Handelsware: Eine Excel-Liste mit Handelswaren-Umsätzen hochladen. Tessera ordnet jedem Produkt sein Konto zu, markiert neue Produkte mit „neu“ und vergibt ihnen das nächste freie Gegenkonto. Das Buchungsdatum wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und lässt sich ändern. Neue Konten werden erst beim Herunterladen der Buchungsdatei gespeichert. Im Reiter „Konten“ pflegen Sie die Kontenliste, lesen sie aus einer CSV-Datei ein (ersetzt alle vorhandenen Konten, nach Rückfrage) und exportieren sie als CSV. Standard-Erlöskonto und Startwert für das Gegenkonto trägt ein Administrator einmalig ein.
- Modul-Freigaben haben jetzt zwei Stufen: „Benutzen“ (wie bisher) und „Verwalten“. Wer ein Modul verwalten darf, ändert dessen Einstellungen selbst, ohne Administrator zu sein, zum Beispiel in der Kantinenabrechnung, bei Handelsware und bei den Proxmox-Servern. Ein Administrator wählt die Stufe je Gruppe in der Freigaben-Matrix oder je Benutzer in den Benutzerdetails; bestehende Freigaben bleiben „Benutzen“. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Das Modul DKV-Rechnung steht Administratoren und Benutzern mit „Verwalten“ zur Verfügung.
## 1.9.2 – 2026-10-02
@@ -495,7 +495,18 @@ describe('ModuleGrantsService.getMatrix', () => {
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']);
expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']);
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1' }]);
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1', level: 'USE' }]);
});
it('261002-icv: jedes Grant-Element trägt die Stufe', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
const matrix = await service.getMatrix('t1');
expect(matrix.grants).toEqual([{ moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }]);
});
it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => {
@@ -552,11 +563,32 @@ describe('ModuleGrantsService.getUserAccess', () => {
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
viaGroups: ['Gruppe A'],
direct: false,
directLevel: null,
manageViaGroups: [],
},
]);
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
});
it('261002-icv: directLevel zeigt die Stufe der Direkt-Freigabe, manageViaGroups nennt Gruppen mit Verwalten', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Gruppe B' });
prisma.__seedMembership('g1', 'u1');
prisma.__seedMembership('g2', 'u1');
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g2', level: 'MANAGE' });
await service.grant('t1', { moduleId: 'mod-1', userId: 'u1', level: 'MANAGE' });
const row = (await service.getUserAccess('t1', 'u1')).modules[0];
expect(row.direct).toBe(true);
expect(row.directLevel).toBe('MANAGE');
expect([...row.viaGroups].sort()).toEqual(['Gruppe A', 'Gruppe B']);
expect(row.manageViaGroups).toEqual(['Gruppe B']);
});
it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
+22 -6
View File
@@ -226,7 +226,7 @@ export class ModuleGrantsService {
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, groupId: { not: null } },
select: { moduleId: true, groupId: true },
select: { moduleId: true, groupId: true, level: true },
}),
]);
@@ -242,6 +242,7 @@ export class ModuleGrantsService {
grants: groupGrants.map((g) => ({
moduleId: g.moduleId,
groupId: g.groupId,
level: g.level ?? ModuleGrantLevel.USE,
})),
};
}
@@ -255,7 +256,8 @@ export class ModuleGrantsService {
* dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere
* Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein
* Direkt-Grant) und behält dafür je Eintrag exakt die Form
* { module, viaGroups, direct }.
* { module, viaGroups, direct }; seit 261002-icv kommen `directLevel` und
* `manageViaGroups` hinzu (Anzeige der Freigabestufe).
*
* Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide
* Projektionsstellen (viaGroups-Namen, groups[].name) liefern
@@ -282,7 +284,7 @@ export class ModuleGrantsService {
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId },
select: { moduleId: true },
select: { moduleId: true, level: true },
}),
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
// über denselben tenantPrisma wie die drei Abfragen oben gesetzt —
@@ -299,13 +301,23 @@ export class ModuleGrantsService {
}),
]);
const directModuleIds = new Set(directGrants.map((g) => g.moduleId));
const directLevelByModule = new Map<string, ModuleGrantLevel>();
for (const g of directGrants) {
directLevelByModule.set(g.moduleId, g.level ?? ModuleGrantLevel.USE);
}
const groupNamesByModule = new Map<string, string[]>();
const manageGroupNamesByModule = new Map<string, string[]>();
for (const g of groupGrants) {
if (!g.group) continue;
const displayName = g.group.internalName ?? g.group.name;
const names = groupNamesByModule.get(g.moduleId) ?? [];
names.push(g.group.internalName ?? g.group.name);
names.push(displayName);
groupNamesByModule.set(g.moduleId, names);
if (g.level === ModuleGrantLevel.MANAGE) {
const manageNames = manageGroupNamesByModule.get(g.moduleId) ?? [];
manageNames.push(displayName);
manageGroupNamesByModule.set(g.moduleId, manageNames);
}
}
const modules = activations
@@ -328,7 +340,11 @@ export class ModuleGrantsService {
modules: modules.map((module) => ({
module,
viaGroups: groupNamesByModule.get(module.id) ?? [],
direct: directModuleIds.has(module.id),
direct: directLevelByModule.has(module.id),
// 261002-icv: Stufe der Direkt-Freigabe (null ohne Direkt-Grant) und
// die Gruppen, die Verwalten gewähren (Teilmenge von viaGroups).
directLevel: directLevelByModule.get(module.id) ?? null,
manageViaGroups: manageGroupNamesByModule.get(module.id) ?? [],
})),
};
}
@@ -17,11 +17,15 @@ const messages: Record<string, Record<string, unknown>> = {
emptyModulesLink: 'Zu Module',
adminNote:
'ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.',
levelExplanation: '„Benutzen“: Das Modul öffnen. „Verwalten“: zusätzlich die Einstellungen ändern.',
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
noSearchResults: 'Kein Treffer für "{search}" — weder bei den Modulen noch bei den Gruppen.',
},
'admin.groups.grants': {
matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}',
levelUse: 'Benutzen',
levelManage: 'Verwalten',
levelSelectLabel: 'Stufe für {module} in Gruppe {group}',
},
'adminModules.activationDialog': {
title: 'Modul aktivieren: {module}',
@@ -321,6 +325,121 @@ describe('AdminModuleGrantsPage (Permission-Matrix)', () => {
});
});
describe('AdminModuleGrantsPage — Freigabestufe (261002-icv)', () => {
beforeEach(() => {
stubAdmin();
});
const levelMatrix = {
...mockMatrix,
grants: [
{ moduleId: 'm1', groupId: 'g1', level: 'MANAGE' },
{ moduleId: 'm2', groupId: 'g1', level: 'USE' },
],
};
it('zeigt bei freigegebenen Zellen ein Stufenfeld mit der gelieferten Stufe, bei leeren Zellen keins', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) })),
);
render(<AdminModuleGrantsPage />);
const manage = (await screen.findByLabelText(
'Stufe für Ausschreibungs-Radar in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
expect(manage.value).toBe('MANAGE');
const use = screen.getByLabelText('Stufe für DKV Flotte in Gruppe Alle Benutzer') as HTMLSelectElement;
expect(use.value).toBe('USE');
expect(screen.queryByLabelText('Stufe für Ausschreibungs-Radar in Gruppe Buchhaltung')).toBeNull();
});
it('Wechsel auf Verwalten sendet POST mit level MANAGE', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const select = (await screen.findByLabelText(
'Stufe für DKV Flotte in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({
moduleId: 'm2',
groupId: 'g1',
level: 'MANAGE',
});
});
expect(select.value).toBe('MANAGE');
});
it('ein fehlgeschlagener Stufenwechsel setzt das Auswahlfeld zurück', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const select = (await screen.findByLabelText(
'Stufe für DKV Flotte in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => expect(screen.getByText(/500: boom/)).toBeInTheDocument());
expect(select.value).toBe('USE');
});
it('Ankreuzen einer leeren Zelle sendet POST ohne level und zeigt Benutzen', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const box = await screen.findByLabelText('Ausschreibungs-Radar für Gruppe Buchhaltung freigeben');
await userEvent.click(box);
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ moduleId: 'm1', groupId: 'g2' });
});
const select = (await screen.findByLabelText(
'Stufe für Ausschreibungs-Radar in Gruppe Buchhaltung',
)) as HTMLSelectElement;
expect(select.value).toBe('USE');
});
it('zeigt die Erklärung der Stufen unter der Matrix', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) })),
);
render(<AdminModuleGrantsPage />);
expect(await screen.findByText(/„Verwalten“: zusätzlich die Einstellungen ändern/)).toBeInTheDocument();
});
});
describe('ActivateModuleDialog', () => {
it('renders all three actions (cancel, configure later, grant now)', async () => {
vi.stubGlobal(
@@ -19,9 +19,13 @@ interface Group {
internalName?: string | null;
}
type GrantLevel = 'USE' | 'MANAGE';
interface GrantPair {
moduleId: string;
groupId: string;
/** Freigabestufe (261002-icv); fehlt sie, gilt Benutzen. */
level?: GrantLevel;
}
function cellKey(moduleId: string, groupId: string): string {
@@ -35,7 +39,9 @@ function cellKey(moduleId: string, groupId: string): string {
* einmal GET /module-grants/matrix (Module, Gruppen, bestehende
* Gruppen-Grants in einer Antwort) und schaltet jede Zelle sofort um
* (optimistisches UI, Rücksprung bei Fehler -- identisches Verhalten zu
* AdminModulesPage.toggleModule).
* AdminModulesPage.toggleModule). Seit 261002-icv trägt jede freigegebene
* Zelle eine Stufe (Benutzen / Verwalten), die per Auswahlfeld gewechselt
* wird.
*/
export default function AdminModuleGrantsPage() {
const t = useTranslations('adminModules.grants');
@@ -47,7 +53,7 @@ export default function AdminModuleGrantsPage() {
const [modules, setModules] = useState<Module[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [grants, setGrants] = useState<Set<string>>(new Set());
const [grants, setGrants] = useState<Map<string, GrantLevel>>(new Map());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState('');
@@ -63,7 +69,11 @@ export default function AdminModuleGrantsPage() {
await res.json();
setModules(data.modules);
setGroups(data.groups);
setGrants(new Set(data.grants.map((g) => cellKey(g.moduleId, g.groupId))));
setGrants(
new Map(
data.grants.map((g) => [cellKey(g.moduleId, g.groupId), g.level ?? 'USE'] as const),
),
);
}
} catch {
// silently fail -- same precedent as the other admin list pages
@@ -87,21 +97,22 @@ export default function AdminModuleGrantsPage() {
// Optimistic toggle -- rolled back below on any failure so the UI never
// shows a grant the database does not have (T-15-25).
const previousLevel = grants.get(key);
setGrants((prev) => {
const next = new Set(prev);
const next = new Map(prev);
if (currentlyGranted) {
next.delete(key);
} else {
next.add(key);
next.set(key, 'USE');
}
return next;
});
const rollback = () => {
setGrants((prev) => {
const next = new Set(prev);
const next = new Map(prev);
if (currentlyGranted) {
next.add(key);
next.set(key, previousLevel ?? 'USE');
} else {
next.delete(key);
}
@@ -130,6 +141,37 @@ export default function AdminModuleGrantsPage() {
}
};
// Stufe einer bereits freigegebenen Zelle wechseln (261002-icv): POST mit
// `level` — der Server hebt/senkt die Stufe der bestehenden Freigabe.
// Optimistisch, bei Fehler Rücksprung auf die vorherige Stufe.
const changeLevel = async (moduleId: string, groupId: string, level: GrantLevel) => {
const key = cellKey(moduleId, groupId);
const previous = grants.get(key) ?? 'USE';
if (previous === level) return;
setTogglingKey(key);
setError(null);
setGrants((prev) => new Map(prev).set(key, level));
try {
const res = await fetch(`${API_URL}/module-grants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ moduleId, groupId, level }),
});
if (!res.ok) {
setGrants((prev) => new Map(prev).set(key, previous));
const body = await res.text().catch(() => '');
setError(`${res.status}: ${body || t('saveError')}`);
}
} catch (err) {
setGrants((prev) => new Map(prev).set(key, previous));
setError(String(err));
} finally {
setTogglingKey(null);
}
};
const searchLower = search.trim().toLowerCase();
// Both axes are searched independently, then combined with the following
@@ -263,10 +305,11 @@ export default function AdminModuleGrantsPage() {
{filteredGroups.map((g) => {
const key = cellKey(mod.id, g.id);
const isGranted = grants.has(key);
const level = grants.get(key) ?? 'USE';
const isToggling = togglingKey === key;
return (
<td key={g.id} className="px-4 py-3 text-center">
<span className="relative inline-flex items-center justify-center">
<span className="relative inline-flex items-center justify-center gap-2">
<input
type="checkbox"
checked={isGranted}
@@ -283,6 +326,23 @@ export default function AdminModuleGrantsPage() {
})}
className="h-4 w-4 rounded border-input"
/>
{isGranted && (
<select
value={level}
disabled={isToggling}
onChange={(e) =>
changeLevel(mod.id, g.id, e.target.value as GrantLevel)
}
aria-label={tGrantLabel('levelSelectLabel', {
module: mod.name,
group: g.name,
})}
className="h-7 rounded-md border border-input bg-background px-1 text-xs"
>
<option value="USE">{tGrantLabel('levelUse')}</option>
<option value="MANAGE">{tGrantLabel('levelManage')}</option>
</select>
)}
{isToggling && (
<span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
)}
@@ -299,6 +359,7 @@ export default function AdminModuleGrantsPage() {
</div>
)}
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</>
)}
@@ -11,10 +11,16 @@ interface ModuleSummary {
category: string;
}
type GrantLevel = 'USE' | 'MANAGE';
interface ModuleAccessRow {
module: ModuleSummary;
viaGroups: string[];
direct: boolean;
/** Stufe der Direkt-Freigabe (261002-icv); null ohne Direkt-Freigabe. */
directLevel?: GrantLevel | null;
/** Namen der Gruppen, die Verwalten gewähren (Teilmenge von viaGroups). */
manageViaGroups?: string[];
}
interface MembershipChip {
@@ -37,7 +43,9 @@ interface UserAccessModalProps {
/**
* Benutzer-Detail-Zugriff (D-16, Surface Contract 3): Gruppenmitgliedschaften
* (read-only -- bearbeitet wird ausschliesslich unter /admin/groups) und
* Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle.
* Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle. Seit 261002-icv
* trägt die Direkt-Freigabe eine Stufe (Benutzen / Verwalten, Auswahlfeld),
* und Gruppen, die Verwalten gewähren, sind in der Liste markiert.
*
* Lädt einmal GET /module-grants/users/:userId -- eine Antwort, zwei
* Abschnitte: die Gruppenmitgliedschafts-Chips kommen direkt aus den
@@ -49,6 +57,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
const t = useTranslations('admin.users.grants');
const tCommon = useTranslations('common');
const tMembers = useTranslations('admin.groups.members');
const tLevel = useTranslations('admin.groups.grants');
const [rows, setRows] = useState<ModuleAccessRow[] | null>(null);
const [groups, setGroups] = useState<MembershipChip[] | null>(null);
@@ -84,19 +93,28 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
const toggleDirect = async (moduleId: string, currentlyDirect: boolean) => {
setTogglingModuleId(moduleId);
setSaveError(null);
const previousLevel = rows?.find((r) => r.module.id === moduleId)?.directLevel ?? null;
// Optimistic toggle -- rolled back below on failure (T-15-25): the UI
// must never show a grant the database does not have.
setRows((prev) =>
prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: !currentlyDirect } : r))
? prev.map((r) =>
r.module.id === moduleId
? { ...r, direct: !currentlyDirect, directLevel: currentlyDirect ? null : 'USE' }
: r,
)
: prev,
);
const rollback = () => {
setRows((prev) =>
prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: currentlyDirect } : r))
? prev.map((r) =>
r.module.id === moduleId
? { ...r, direct: currentlyDirect, directLevel: previousLevel }
: r,
)
: prev,
);
};
@@ -122,6 +140,41 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
}
};
// Stufe der Direkt-Freigabe wechseln (261002-icv): POST mit `level`,
// optimistisch, bei Fehler Rücksprung auf die vorherige Stufe.
const changeDirectLevel = async (moduleId: string, level: GrantLevel) => {
const previous = rows?.find((r) => r.module.id === moduleId)?.directLevel ?? 'USE';
if (previous === level) return;
setTogglingModuleId(moduleId);
setSaveError(null);
const setLevel = (value: GrantLevel) =>
setRows((prev) =>
prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, directLevel: value } : r))
: prev,
);
setLevel(level);
try {
const res = await fetch(`${API_URL}/module-grants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ moduleId, userId, level }),
});
if (!res.ok) {
setLevel(previous);
const body = await res.text().catch(() => '');
setSaveError(`${res.status}: ${body || t('saveError')}`);
}
} catch (err) {
setLevel(previous);
setSaveError(String(err));
} finally {
setTogglingModuleId(null);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@@ -217,13 +270,18 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
{name}
{row.manageViaGroups?.includes(name) && (
<span className="ml-1 font-medium text-foreground">
· {t('manageMarker')}
</span>
)}
</span>
))}
</span>
)}
</td>
<td className="px-4 py-3 text-center">
<span className="relative inline-flex items-center justify-center">
<span className="relative inline-flex items-center justify-center gap-2">
<input
type="checkbox"
checked={row.direct}
@@ -239,6 +297,23 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
})}
className="h-4 w-4 rounded border-input"
/>
{row.direct && (
<select
value={row.directLevel ?? 'USE'}
disabled={isToggling}
onChange={(e) =>
changeDirectLevel(row.module.id, e.target.value as GrantLevel)
}
aria-label={t('directLevelLabel', {
module: row.module.name,
user: username,
})}
className="h-7 rounded-md border border-input bg-background px-1 text-xs"
>
<option value="USE">{tLevel('levelUse')}</option>
<option value="MANAGE">{tLevel('levelManage')}</option>
</select>
)}
{isToggling && (
<span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
)}
@@ -22,6 +22,12 @@ const messages: Record<string, Record<string, unknown>> = {
directCheckboxLabel:
'{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}',
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
directLevelLabel: 'Stufe der direkten Freigabe von {module} für {user}',
manageMarker: 'Verwalten',
},
'admin.groups.grants': {
levelUse: 'Benutzen',
levelManage: 'Verwalten',
},
'admin.groups.members': {
sourceManual: 'Manuell',
@@ -285,3 +291,90 @@ describe('UserAccessModal', () => {
expect(screen.getByText('LDAP')).toBeInTheDocument();
});
});
describe('UserAccessModal — Freigabestufe (261002-icv)', () => {
const levelAccess = {
groups: [{ id: 'g1', name: 'Alle Benutzer', source: 'MANUAL' as const }],
modules: [
{
module: { id: 'm1', name: 'Proxmox', category: 'infrastructure' },
viaGroups: ['Alle Benutzer'],
direct: false,
directLevel: null,
manageViaGroups: ['Alle Benutzer'],
},
{
module: { id: 'm2', name: 'DKV Flotte', category: 'fleet' },
viaGroups: [],
direct: true,
directLevel: 'USE' as const,
manageViaGroups: [],
},
],
};
it('zeigt bei der Direkt-Freigabe ein Stufenfeld und wechselt die Stufe per POST mit level', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) });
});
vi.stubGlobal('fetch', fetchMock);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
const select = (await screen.findByLabelText(
'Stufe der direkten Freigabe von DKV Flotte für Maxi Musterfrau',
)) as HTMLSelectElement;
expect(select.value).toBe('USE');
// Ohne Direkt-Freigabe kein Stufenfeld.
expect(
screen.queryByLabelText('Stufe der direkten Freigabe von Proxmox für Maxi Musterfrau'),
).toBeNull();
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({
moduleId: 'm2',
userId: 'u1',
level: 'MANAGE',
});
});
expect(select.value).toBe('MANAGE');
});
it('setzt die Stufe bei einem Fehler zurück und zeigt die Meldung', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) });
});
vi.stubGlobal('fetch', fetchMock);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
const select = (await screen.findByLabelText(
'Stufe der direkten Freigabe von DKV Flotte für Maxi Musterfrau',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => expect(screen.getByText(/500: boom/)).toBeInTheDocument());
expect(select.value).toBe('USE');
});
it('markiert eine Gruppe, die Verwalten gewährt, mit „Verwalten“', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) })),
);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
expect(await screen.findByText(/· Verwalten/)).toBeInTheDocument();
});
});
+8 -2
View File
@@ -553,6 +553,8 @@
"noInheritance": "–",
"noActiveModules": "Für diesen Mandanten sind keine Module aktiviert.",
"directCheckboxLabel": "{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}",
"directLevelLabel": "Stufe der direkten Freigabe von {module} für {user}",
"manageMarker": "Verwalten",
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen."
},
"errors": {
@@ -730,7 +732,10 @@
"body": "Diese Gruppe hat {memberCount, plural, one {# Mitglied} other {# Mitglieder}} und {grantCount, plural, one {# Modul-Freigabe} other {# Modul-Freigaben}}. Betroffene Benutzer verlieren den Zugriff, sofern sie ihn nicht anderweitig haben. Diese Aktion kann nicht rückgängig gemacht werden."
},
"grants": {
"matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}"
"matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}",
"levelUse": "Benutzen",
"levelManage": "Verwalten",
"levelSelectLabel": "Stufe für {module} in Gruppe {group}"
}
},
"customModules": {
@@ -860,7 +865,8 @@
"searchPlaceholder": "Module oder Gruppen durchsuchen...",
"emptyModules": "Es sind noch keine Module für diesen Mandanten aktiviert. Aktiviere zuerst ein Modul unter Module.",
"emptyModulesLink": "Zu Module",
"adminNote": "ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.",
"levelExplanation": "„Benutzen“: Das Modul öffnen und damit arbeiten. „Verwalten“: zusätzlich die Einstellungen dieses Moduls ändern. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Hat jemand über mehrere Wege Zugriff, gilt die höhere Stufe.",
"adminNote": "Administratoren haben immer Zugriff auf alle aktiven Module und dürfen deren Einstellungen ändern – diese Matrix betrifft nur Benutzer ohne Administratorrechte.",
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.",
"noSearchResults": "Kein Treffer für „{search}\" — weder bei den Modulen noch bei den Gruppen."
},
+8 -2
View File
@@ -553,6 +553,8 @@
"noInheritance": "–",
"noActiveModules": "No modules are activated for this tenant.",
"directCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} directly for {user}",
"directLevelLabel": "Level of the direct grant of {module} for {user}",
"manageMarker": "Manage",
"saveError": "Could not save grant. Please try again."
},
"errors": {
@@ -730,7 +732,10 @@
"body": "This group has {memberCount, plural, one {# member} other {# members}} and {grantCount, plural, one {# module grant} other {# module grants}}. Affected users will lose access unless they have it another way. This action cannot be undone."
},
"grants": {
"matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}"
"matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}",
"levelUse": "Use",
"levelManage": "Manage",
"levelSelectLabel": "Level for {module} in group {group}"
}
},
"customModules": {
@@ -860,7 +865,8 @@
"searchPlaceholder": "Search modules or groups...",
"emptyModules": "No modules are activated for this tenant yet. Activate a module under Modules first.",
"emptyModulesLink": "Go to Modules",
"adminNote": "ADMIN and SUPER_ADMIN always have access to all active modules — this matrix only applies to the USER role.",
"levelExplanation": "“Use”: open the module and work with it. “Manage”: additionally change the settings of this module. Only administrators may grant access and activate modules. If someone has access through several routes, the higher level applies.",
"adminNote": "Administrators always have access to all active modules and may change their settings — this matrix only affects users without administrator rights.",
"saveError": "Could not save grant. Please try again.",
"noSearchResults": "No match for \"{search}\" — neither in the modules nor in the groups."
},
+25 -4
View File
@@ -30,7 +30,7 @@ Wichtige Details, die im Code tatsächlich so umgesetzt sind:
- Der Menüpunkt „Administrator“ im Header erscheint nur für ADMIN und SUPER_ADMIN. Ein Benutzer mit der Rolle USER hat keinen sichtbaren Einstiegspunkt in den Administrationsbereich.
- Innerhalb des Administrationsbereichs ist „Mandanten“ der einzige Navigationspunkt, der ausschließlich für SUPER_ADMIN sichtbar ist; alle anderen Punkte (Benutzer, Module, LDAP, SMTP, Gruppen) sind für ADMIN und SUPER_ADMIN gleichermaßen erreichbar.
- **ADMIN und SUPER_ADMIN haben immer Zugriff auf alle für den Mandanten aktivierten Module** – unabhängig von der Freigaben-Matrix (siehe Kapitel 5). Die Matrix regelt ausschließlich, welche Module ein Benutzer mit der Rolle USER sehen darf.
- **ADMIN und SUPER_ADMIN haben immer Zugriff auf alle für den Mandanten aktivierten Module** – unabhängig von der Freigaben-Matrix (siehe Kapitel 5). Die Matrix regelt ausschließlich, welche Module ein Benutzer mit der Rolle USER sehen darf und in welcher Stufe („Benutzen“ oder „Verwalten“, siehe Kapitel 5). Administratoren haben auf jedem aktiven Modul automatisch die Stufe „Verwalten“.
- Ein ADMIN kann einem Benutzer nicht die Rolle SUPER_ADMIN zuweisen – weder beim Anlegen noch beim Bearbeiten. Nur ein SUPER_ADMIN darf das.
## 2. Benutzerverwaltung
@@ -104,7 +104,7 @@ Der eigene Account lässt sich nicht löschen; der Löschen-Button ist für die
Über den Button „Details“ neben einem Benutzer öffnet sich ein Dialog mit zwei Abschnitten:
- **Gruppenmitgliedschaften** – rein informativ (Chips mit Quelle „LDAP“ oder „Manuell“); Änderungen daran erfolgen ausschließlich unter „Gruppen“.
- **Modul-Zugriff** – eine Tabelle je aktiviertem Modul mit der Spalte „Über Gruppe(n)“ (zeigt, über welche Gruppenmitgliedschaft(en) der Zugriff vererbt wird) und einer Checkbox „Direkt“, mit der zusätzlich zur Gruppenvererbung ein direkter Zugriff für genau diesen Benutzer gesetzt oder entzogen werden kann.
- **Modul-Zugriff** – eine Tabelle je aktiviertem Modul mit der Spalte „Über Gruppe(n)“ (zeigt, über welche Gruppenmitgliedschaft(en) der Zugriff vererbt wird) und einer Checkbox „Direkt“, mit der zusätzlich zur Gruppenvererbung ein direkter Zugriff für genau diesen Benutzer gesetzt oder entzogen werden kann. Bei einer gesetzten Direkt-Freigabe erscheint daneben ein Auswahlfeld für die **Stufe** („Benutzen“ oder „Verwalten“). Gibt eine Gruppe die Stufe „Verwalten“ weiter, ist ihr Chip in der Spalte „Über Gruppe(n)“ mit „Verwalten“ gekennzeichnet.
## 3. Gruppen
@@ -232,9 +232,29 @@ Ein bereits aktiviertes Modul lässt sich direkt über den Schalter wieder deakt
### Freigaben-Matrix
Unter „Module → Freigaben-Matrix“ erscheint eine Tabelle: Zeilen sind die aktivierten Module (nach Kategorie gruppiert), Spalten sind die Gruppen des Mandanten (angezeigt mit ihrem internen Namen, falls gesetzt, sonst mit dem regulären Namen). Jede Zelle ist eine Checkbox, die die Freigabe für genau diese Kombination aus Modul und Gruppe an- oder abschaltet – Änderungen werden sofort gespeichert. Über das Suchfeld lässt sich sowohl nach Modul- als auch nach Gruppennamen filtern; ein Treffer auf nur einer Achse leert die andere Achse nicht.
Unter „Module → Freigaben-Matrix“ erscheint eine Tabelle: Zeilen sind die aktivierten Module (nach Kategorie gruppiert), Spalten sind die Gruppen des Mandanten (angezeigt mit ihrem internen Namen, falls gesetzt, sonst mit dem regulären Namen). Jede Zelle ist eine Checkbox, die die Freigabe für genau diese Kombination aus Modul und Gruppe an- oder abschaltet – Änderungen werden sofort gespeichert. Bei einer freigegebenen Zelle erscheint neben der Checkbox ein Auswahlfeld für die **Stufe**: „Benutzen“ (Standard) oder „Verwalten“. Ein erneutes Ankreuzen ändert die Stufe nie; sie wechseln Sie nur über das Auswahlfeld. Über das Suchfeld lässt sich sowohl nach Modul- als auch nach Gruppennamen filtern; ein Treffer auf nur einer Achse leert die andere Achse nicht.
Ein Hinweistext unterhalb der Matrix erinnert daran, dass ADMIN und SUPER_ADMIN immer Zugriff auf alle aktiven Module haben und diese Matrix nur die Rolle USER betrifft.
Ein Hinweistext unterhalb der Matrix erklärt die beiden Stufen und erinnert daran, dass Administratoren immer Zugriff auf alle aktiven Module haben und diese Matrix nur Benutzer ohne Administratorrechte betrifft.
### Freigabestufen: Benutzen und Verwalten
Jede Freigabe – ob für eine Gruppe oder für einen einzelnen Benutzer – hat eine von zwei Stufen:
- **Benutzen** (Standard): Das Modul öffnen und damit arbeiten. Alle Freigaben, die vor Einführung der Stufen bestanden, sind „Benutzen“; niemand hat durch die Umstellung zusätzliche Rechte bekommen.
- **Verwalten**: zusätzlich die Einstellungen **dieses einen Moduls** ändern. Die Stufe gilt nur für das Modul, für das sie vergeben wurde, und nur, solange das Modul aktiviert ist.
Was „Verwalten“ je Modul freischaltet:
| Modul | Mit „Verwalten“ möglich |
|---|---|
| Kantinenabrechnung | Reiter „Einstellungen“ (Beraternummer, Mandantennummer, Lohnart) |
| Handelsware | Reiter „Einstellungen“ (Standard-Erlöskonto, Startwert Gegenkonto) |
| Proxmox | Server anlegen, ändern und löschen, Verbindung prüfen, „Jetzt aktualisieren“ |
| DKV-Rechnung | das gesamte Modul – „Benutzen“ allein reicht dort nicht; Benutzer mit nur „Benutzen“ sehen eine Seite, die das erklärt |
Hat jemand über mehrere Wege Zugriff (zum Beispiel direkt „Benutzen“ und über eine Gruppe „Verwalten“), gilt die **höhere** Stufe.
Weiterhin **ausschließlich Administratoren** vorbehalten bleiben: Freigaben erteilen und deren Stufe wählen, Module aktivieren und deaktivieren, Benutzer, Gruppen, LDAP, SMTP, die Willkommensmail, gemeinsame eigene Module sowie die plattformweiten Einstellungen des Ausschreibungs-Radars (Abrufintervall, „Jetzt abrufen“, plattformweite RSS-Feeds). Wer „Verwalten“ für Proxmox erhält, darf Serveradressen eintragen; vergeben Sie die Stufe deshalb nur an Personen, denen Sie das zutrauen.
### Eigene Module (Seitenleisten-Einträge)
@@ -347,6 +367,7 @@ Ein Mandant lässt sich **nicht löschen, solange er noch aktive Benutzer hat**
| Ein AD-Benutzer wird zwar synchronisiert, aber ohne E-Mail-Adresse angelegt oder aktualisiert; Passwort-Reset-Mails erreichen ihn nicht. | Normales, dokumentiertes Verhalten seit E-Mail optional ist: Die im AD hinterlegte Adresse gehört bereits zu einem anderen Konto und wird deshalb nicht übernommen. Der Ergebnisbericht des Sync-Laufs listet solche Fälle unter „Konten ohne E-Mail-Adresse“ auf. Die Kollision im betroffenen Konto (dem Halter der Adresse) prüfen und die Adresse dort ggf. korrigieren. |
| Eine AD-Gruppe erscheint im Sync-Bericht als gelöscht, obwohl sie im Verzeichnis noch existiert. | Die Gruppe wurde vermutlich außerhalb der konfigurierten Basis-DN(s) verschoben. Tessera prüft vor einer Löschung zwar zusätzlich die Domänenwurzel, kann eine Verschiebung aber nur innerhalb der erreichbaren Suchbereiche erkennen. Basis-DN(s) und Import-Filter prüfen. |
| Ein Benutzer mit Rolle USER sieht ein Modul nicht, obwohl es für den Mandanten aktiviert ist. | Für dieses Modul fehlt eine Freigabe – weder direkt (Benutzer-Details) noch über eine Gruppe (Freigaben-Matrix). Prüfen, ob der Benutzer Mitglied der vorgesehenen Gruppe ist und ob die Matrix-Zelle für Modul/Gruppe gesetzt ist. |
| Ein Benutzer sieht den Reiter „Einstellungen“ eines Moduls nicht (oder Proxmox zeigt keine Verwaltungsknöpfe), obwohl er das Modul öffnen kann. | Seine Freigabe hat nur die Stufe „Benutzen“. In der Freigaben-Matrix (Gruppe) oder im Benutzer-Detaildialog (Direkt-Freigabe) die Stufe auf „Verwalten“ stellen. Hat er mehrere Freigaben, zählt die höchste. |
| Ein neu importierter/erstellter Benutzer hat unerwartet Zugriff auf ein Modul, das eigentlich niemandem freigegeben sein sollte. | Der Benutzer wurde automatisch Mitglied der Standardgruppe (siehe Kapitel 3), und dieser Gruppe wurde beim Aktivieren eines Moduls über „Sofort freigeben“ Zugriff erteilt. Freigaben-Matrix prüfen und ggf. die Standardgruppen-Freigabe für das betreffende Modul entfernen. |
| Der Name einer AD-gebundenen Gruppe „springt“ nach jedem Sync-Lauf auf den AD-Namen zurück, obwohl ein anderer Name gewünscht ist. | Erwartetes Verhalten: Der Anzeigename einer AD-gebundenen Gruppe wird bei jedem Sync mit dem AD-Wert überschrieben. Für einen dauerhaft abweichenden Anzeigenamen den „Internen Namen“ im Bearbeiten-Dialog der Gruppe setzen – dieses Feld wird von der Synchronisation nie berührt. |
| Ein AD-Gruppen-Import schlägt für eine bestimmte Gruppe mit einem Namenskonflikt fehl. | Es existiert bereits eine lokale (manuelle) Gruppe mit demselben Namen. Diese lokale Gruppe umbenennen oder – falls es sich tatsächlich um dieselbe Gruppe handeln soll – vor dem Import einen internen Namen dafür vergeben, dann erneut importieren. |
+6 -4
View File
@@ -109,6 +109,8 @@ Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen, XFrame und Pro
**Aktivieren und Freigeben sind zwei unterschiedliche Dinge:** Nur Administratoren können ein Modul für das gesamte Unternehmen **aktivieren**. Ob Sie persönlich das Modul danach auch sehen und öffnen können, hängt zusätzlich davon ab, ob Ihnen oder Ihrer Gruppe der Zugriff **freigegeben** wurde. Ein Modul kann also für das Unternehmen aktiv sein, ohne dass Sie selbst Zugriff darauf haben. Versuchen Sie in diesem Fall, das Modul zu öffnen, erscheint statt des Moduls die Meldung „Kein Zugriff auf dieses Modul" mit dem Hinweis „Sie haben für dieses Modul keine Freigabe. Wenden Sie sich an Ihren Administrator." In diesem Fall wenden Sie sich an Ihren Administrator, damit er Ihnen (direkt oder über eine Gruppe) die Freigabe für das Modul erteilt.
Eine Freigabe hat zwei Stufen: **Benutzen** (das Modul öffnen und damit arbeiten) und **Verwalten** (zusätzlich die Einstellungen dieses Moduls ändern, zum Beispiel in der Kantinenabrechnung, bei Handelsware oder die Server in Proxmox). Welche Stufe Sie haben, legt Ihr Administrator fest; Freigaben vergeben und Module aktivieren darf auch mit „Verwalten“ nur er.
## Die Module
Aktuell stehen in Tessera sieben Module zur Verfügung. Je nachdem, welche für Sie freigegeben sind, sehen Sie sie in der Seitenleiste unter ihrer jeweiligen Kategorie.
@@ -136,7 +138,7 @@ Administratoren sehen zusätzlich einen Link zu den plattformweiten Einstellunge
### DKV-Rechnung
Das Modul prüft automatisch ein Postfach auf eingehende DKV-Tankkarten-Rechnungen und verarbeitet sie.
Das Modul prüft automatisch ein Postfach auf eingehende DKV-Tankkarten-Rechnungen und verarbeitet sie. Es steht Administratoren und Benutzern mit der Freigabestufe „Verwalten“ zur Verfügung; wer das Modul nur benutzen darf, sieht stattdessen eine Seite mit diesem Hinweis.
- **Posteingang:** Über **„Jetzt prüfen"** lässt sich der Posteingang sofort abfragen, statt auf den nächsten automatischen Lauf zu warten. Die **Verarbeitungshistorie** zeigt Datum/Zeit, Rechnungsnummer, betroffene Fahrzeuge, Anzahl Transaktionen und Status jeder verarbeiteten Rechnung. Darunter listet **„Letzte Exportdateien"** die erzeugten Exporte zum Download.
- **Fahrzeuge:** Im Tab „Fahrzeuge" pflegen Sie die Fahrzeug-Stammdaten (Kennzeichen, Marke, Modell, Fahrer) — einzeln oder per CSV-Import (Format: `Kennzeichen;Marke;Modell;Fahrer`). Beim Import wählen Sie zwischen „Zusammenführen" (neue Fahrzeuge ergänzen, vorhandene bleiben) und „Ersetzen" (alle vorhandenen Fahrzeuge werden ersetzt — vor dieser Aktion erscheint eine Sicherheitsabfrage, da sie nicht rückgängig gemacht werden kann).
@@ -163,7 +165,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
**Die Modulseite** zeigt eine Karte je eingetragenem Server: Name, Typ und Adresse, dazu die zuletzt gemessenen Werte — bei PVE Anzahl Knoten sowie laufende/gestoppte virtuelle Maschinen und Container samt Auslastung je Knoten, bei PBS die Belegung je Datenspeicher mit dem Zeitpunkt der letzten Sicherung und deren Prüfergebnis, bei PMG die Tageszahlen eingehender und ausgehender E-Mails sowie Spam- und Virenfunde. Ein Wert, der „unbekannt" anzeigt, bedeutet nicht, dass etwas kaputt ist — er bedeutet, dass dieser eine Messwert beim letzten Abruf nicht in der erwarteten Form geliefert wurde. Der Knopf **„Jetzt aktualisieren"** fragt alle eingetragenen Server neu ab; regulär geschieht das automatisch im Hintergrund, in dem Abstand, den Sie je Server eingestellt haben.
**Einen Server anlegen** (nur für Administratoren, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen.
**Einen Server anlegen** (für Administratoren und Benutzer mit der Freigabestufe „Verwalten“, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen.
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**. Wie Sie diesen Zugang Schritt für Schritt anlegen (bei PVE und PBS als API-Token, bei PMG als eigener Benutzer), steht im Administrationshandbuch unter [Proxmox-Server anbinden](anleitung-administration.md#proxmox-server-anbinden-zugang-nur-zum-lesen-einrichten).
@@ -177,7 +179,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
Das Modul bereitet die Kantinenabrechnung für die Gehaltsabrechnung vor. Es steht in der Seitenleiste in der Gruppe **Finanzbuchhaltung**. Sie laden die CSV-Datei der Kantine hoch, Tessera prüft sie und erstellt daraus die Lohndatei für DATEV.
**Einmalig einrichten (Administrator):** Im Reiter **Einstellungen** trägt ein Administrator Beraternummer, Mandantennummer und Lohnart ein (nur Ziffern). Diese Angaben gelten für alle Benutzer Ihres Unternehmens. Solange sie fehlen, zeigt das Modul einen Hinweis und der Download bleibt gesperrt; andere Benutzer sehen den Hinweis „Ein Administrator muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen.“
**Einmalig einrichten (Administrator oder wer das Modul verwalten darf):** Im Reiter **Einstellungen** trägt ein Administrator Beraternummer, Mandantennummer und Lohnart ein (nur Ziffern). Diese Angaben gelten für alle Benutzer Ihres Unternehmens. Solange sie fehlen, zeigt das Modul einen Hinweis und der Download bleibt gesperrt; andere Benutzer sehen den Hinweis „Ein Administrator oder jemand, der dieses Modul verwalten darf, muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen.“
**Abrechnung:** Ziehen Sie die CSV-Datei auf die Fläche oder klicken Sie darauf. Die Datei muss Semikolon-getrennt sein und elf Spalten haben; ob sie als UTF-8 oder Windows-1252 gespeichert ist, erkennt Tessera selbst. Nach dem Hochladen sehen Sie:
@@ -191,7 +193,7 @@ Ist alles in Ordnung, erzeugt **„DATEV-Datei herunterladen“** die Lohndatei
Das Modul ordnet Handelswaren-Umsätze aus einer Excel-Liste den Erlöskonten zu und erstellt die Buchungsdatei für DATEV. Es steht in der Gruppe **Finanzbuchhaltung**. Es hat drei Reiter.
**Einmalig einrichten (Administrator):** Im Reiter **Einstellungen** legt ein Administrator das **Standard-Erlöskonto** (wird neuen Konten zugeordnet) und den **Startwert Gegenkonto** (die erste Nummer, die vergeben wird, wenn die Kontenliste leer ist) fest. Solange das fehlt, ist die Verarbeitung gesperrt; andere Benutzer sehen den Hinweis, dass ein Administrator das zuerst hinterlegen muss.
**Einmalig einrichten (Administrator oder wer das Modul verwalten darf):** Im Reiter **Einstellungen** legt ein Administrator das **Standard-Erlöskonto** (wird neuen Konten zugeordnet) und den **Startwert Gegenkonto** (die erste Nummer, die vergeben wird, wenn die Kontenliste leer ist) fest. Solange das fehlt, ist die Verarbeitung gesperrt; andere Benutzer sehen den Hinweis, dass ein Administrator oder jemand, der das Modul verwalten darf, das zuerst hinterlegen muss.
**Import:** Laden Sie die Excel-Datei (.xlsx) hoch. Der Buchungstext steht in Spalte A, der Umsatz in Spalte B (als Zahl oder als Text wie „1.234,56“), der Kopftext in Zelle B1. Die Vorschau zeigt je Zeile Buchungstext, Umsatz, S/H (Soll oder Haben), Gegenkonto, Datum und Erlöskonto. Produkte, die noch kein Konto haben, tragen die Markierung **neu** und bekommen das nächste freie Gegenkonto. Das **Buchungsdatum** wird aus dem Dateinamen abgeleitet („HWA 0326 Test.xlsx“ ergibt den letzten Tag im März 2026, also 3103); Sie können es als vierstelliges TTMM ändern, eine ungültige Eingabe sperrt den Download. **„Buchungsdatei herunterladen“** erstellt die TXT-Datei und speichert erst jetzt die neuen Konten. Hat sich die Kontenliste zwischenzeitlich geändert, weist Tessera darauf hin und bietet an, die Vorschau neu zu laden.