M3 Mandant-Einstellungen und Rollencheck ergänzen

- zentrale Owner/Admin-Rollenpruefung in SaaS-Auth ergaenzen
- Mandant-Einstellungen fuer Preise, PayPal und Basisdaten bauen
- Settings-Flow-Test, Navigation und M3-Dokumentation aktualisieren
This commit is contained in:
2026-07-12 22:32:51 +02:00
parent 5baf6542ce
commit 19ff11c21a
9 changed files with 492 additions and 8 deletions
+210
View File
@@ -24,6 +24,40 @@ function saas_html(mixed $value): string
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function saas_parse_money_cents(mixed $value): ?int
{
$normalized = trim((string)$value);
$normalized = str_replace(["\xc2\xa0", ' '], '', $normalized);
if ($normalized === '') {
return null;
}
if (str_contains($normalized, ',') && str_contains($normalized, '.')) {
$normalized = str_replace('.', '', $normalized);
}
$normalized = str_replace(',', '.', $normalized);
if (!preg_match('/^-?[0-9]+(?:\.[0-9]{1,2})?$/', $normalized)) {
return null;
}
$negative = str_starts_with($normalized, '-');
if ($negative) {
$normalized = substr($normalized, 1);
}
[$euros, $cents] = array_pad(explode('.', $normalized, 2), 2, '0');
$result = ((int)$euros * 100) + (int)str_pad(substr($cents, 0, 2), 2, '0');
return $negative ? -$result : $result;
}
function saas_format_money_cents(mixed $cents): string
{
return number_format(((int)$cents) / 100, 2, ',', '');
}
function saas_current_user(?PDO $pdo = null): ?array
{
static $cached = false;
@@ -111,6 +145,182 @@ function saas_require_login(): array
exit;
}
function saas_user_has_role(array|string $roles, ?array $user = null): bool
{
$user = $user ?? saas_current_user();
if ($user === null) {
return false;
}
$allowed = is_array($roles) ? $roles : [$roles];
return in_array((string)($user['role'] ?? ''), $allowed, true);
}
function saas_require_role(array|string $roles): array
{
$user = saas_require_login();
if (saas_user_has_role($roles, $user)) {
return $user;
}
http_response_code(403);
die('Keine Berechtigung.');
}
function saas_can_manage_tenant_settings(?array $user = null): bool
{
return saas_user_has_role(['owner', 'admin'], $user);
}
function saas_fetch_tenant_settings(PDO $pdo, int $tenantId): ?array
{
$stmt = $pdo->prepare(
'SELECT
t.id AS tenant_id,
t.slug,
t.name,
t.status,
t.timezone,
t.locale,
t.currency_code,
ts.mark_price_cents,
ts.self_entry_enabled,
ts.paypal_enabled,
ts.paypal_url_template,
ts.sheet_window_days,
ts.negative_warning_cents
FROM tenants t
LEFT JOIN tenant_settings ts ON ts.tenant_id = t.id
WHERE t.id = ?
LIMIT 1'
);
$stmt->execute([$tenantId]);
$settings = $stmt->fetch();
if ($settings === false) {
return null;
}
if ($settings['mark_price_cents'] === null) {
$pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$tenantId]);
return saas_fetch_tenant_settings($pdo, $tenantId);
}
return [
'tenant_id' => (int)$settings['tenant_id'],
'slug' => (string)$settings['slug'],
'name' => (string)$settings['name'],
'status' => (string)$settings['status'],
'timezone' => (string)$settings['timezone'],
'locale' => (string)$settings['locale'],
'currency_code' => (string)$settings['currency_code'],
'mark_price_cents' => (int)$settings['mark_price_cents'],
'self_entry_enabled' => (int)$settings['self_entry_enabled'],
'paypal_enabled' => (int)$settings['paypal_enabled'],
'paypal_url_template' => (string)$settings['paypal_url_template'],
'sheet_window_days' => (int)$settings['sheet_window_days'],
'negative_warning_cents' => (int)$settings['negative_warning_cents'],
];
}
function saas_update_tenant_settings(PDO $pdo, int $tenantId, array $input): array
{
$tenantName = trim((string)($input['tenant_name'] ?? ''));
$timezone = trim((string)($input['timezone'] ?? 'Europe/Berlin'));
$locale = trim((string)($input['locale'] ?? 'de-DE'));
$currencyCode = strtoupper(trim((string)($input['currency_code'] ?? 'EUR')));
$markPriceCents = saas_parse_money_cents($input['mark_price'] ?? '');
$sheetWindowDays = filter_var($input['sheet_window_days'] ?? null, FILTER_VALIDATE_INT);
$negativeWarningCents = saas_parse_money_cents($input['negative_warning'] ?? '');
$paypalUrlTemplate = trim((string)($input['paypal_url_template'] ?? ''));
$errors = [];
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
}
if (!in_array($timezone, DateTimeZone::listIdentifiers(), true)) {
$errors[] = 'Die Zeitzone ist ungueltig.';
}
if (!preg_match('/^[a-z]{2}-[A-Z]{2}$/', $locale)) {
$errors[] = 'Die Locale muss dem Muster de-DE entsprechen.';
}
if (!preg_match('/^[A-Z]{3}$/', $currencyCode)) {
$errors[] = 'Der Waehrungscode muss aus drei Grossbuchstaben bestehen.';
}
if ($markPriceCents === null || $markPriceCents < 1 || $markPriceCents > 10000) {
$errors[] = 'Der Preis pro Strich muss zwischen 0,01 und 100,00 liegen.';
}
if ($sheetWindowDays === false || $sheetWindowDays < 1 || $sheetWindowDays > 365) {
$errors[] = 'Das Listenfenster muss zwischen 1 und 365 Tagen liegen.';
}
if ($negativeWarningCents === null || $negativeWarningCents < 0 || $negativeWarningCents > 100000) {
$errors[] = 'Die Schulden-Warnschwelle muss zwischen 0,00 und 1000,00 liegen.';
}
if (strlen($paypalUrlTemplate) > 1000) {
$errors[] = 'Der PayPal-Link ist zu lang.';
}
if ($errors !== []) {
return ['ok' => false, 'errors' => $errors];
}
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'UPDATE tenants
SET name = ?, timezone = ?, locale = ?, currency_code = ?
WHERE id = ?'
);
$stmt->execute([$tenantName, $timezone, $locale, $currencyCode, $tenantId]);
$stmt = $pdo->prepare(
'INSERT INTO tenant_settings
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled,
paypal_url_template, sheet_window_days, negative_warning_cents)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
mark_price_cents = VALUES(mark_price_cents),
self_entry_enabled = VALUES(self_entry_enabled),
paypal_enabled = VALUES(paypal_enabled),
paypal_url_template = VALUES(paypal_url_template),
sheet_window_days = VALUES(sheet_window_days),
negative_warning_cents = VALUES(negative_warning_cents)'
);
$stmt->execute([
$tenantId,
$markPriceCents,
$selfEntryEnabled,
$paypalEnabled,
$paypalUrlTemplate,
(int)$sheetWindowDays,
-abs($negativeWarningCents),
]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
return [
'ok' => false,
'errors' => ['Die Einstellungen konnten nicht gespeichert werden.'],
];
}
return [
'ok' => true,
'errors' => [],
'settings' => saas_fetch_tenant_settings($pdo, $tenantId),
];
}
function saas_authenticate(PDO $pdo, string $email, string $password, string $tenantSlug = ''): array
{
$emailNorm = saas_email_norm($email);
+2 -2
View File
@@ -140,8 +140,8 @@ Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`,
eine PayPal-Alias-Dublette und hinterlaesst keine Datei in `var/uploads`.
- CSV-Upload negative Tests: Nicht-CSV-Dateien werden abgewiesen.
- Golden Master weiterhin gruen mit 104 Assertions.
- HTTP-Smoke weiterhin gruen mit 16 sicheren Seiten inklusive Login und
Registrierung.
- HTTP-Smoke weiterhin gruen mit 17 sicheren Seiten inklusive Login,
Registrierung und geschuetzter Mandant-Einstellungen.
## Bewusste Grenzen
+27 -4
View File
@@ -47,6 +47,7 @@ Ergaenzende Skripte:
scripts/backfill-default-tenant.php
scripts/check-m3-saas-basis.php
scripts/check-m3-auth-flow.php
scripts/check-m3-settings-flow.php
```
Wichtige Regeln:
@@ -136,8 +137,27 @@ Noch offen im M3-Auth-Scope:
- Passwort-Reset.
- E-Mail-Verifikation.
- Tenant-Aufloesung ueber Subdomain oder Custom Domain.
- Zentrale Rollenpruefung fuer neue SaaS-Seiten.
- Owner-/Admin-Seite fuer Tenant-Grundeinstellungen.
- Weitergehende Rollenmatrix fuer spaetere SaaS-Seiten.
## Rollen und Grundeinstellungen
Umgesetzte Dateien:
```text
mandant-einstellungen.php
```
Umgesetzter Umfang:
- `saas_user_has_role()`, `saas_require_role()` und
`saas_can_manage_tenant_settings()` zentralisieren die erste Rollenpruefung.
- Owner und Admin duerfen Tenant-Grundeinstellungen bearbeiten.
- Die Seite bearbeitet Kundenname, Zeitzone, Locale, Waehrung, Preis pro
Strich, Web-Striche, PayPal-Link, Listenfenster und Schulden-Warnschwelle.
- Geldwerte werden in der UI als Euro-Werte erfasst und weiterhin als Cent-
Integer gespeichert.
- `konto.php` und die Sidebar verlinken die Einstellungen nur fuer passende
Rollen.
## Erste Akzeptanzkriterien
@@ -150,6 +170,7 @@ Noch offen im M3-Auth-Scope:
- Admins werden als tenant-scoped Rolle abgebildet: erfuellt.
- Registrierung und Login funktionieren fuer einen neuen Test-Tenant:
erfuellt.
- Owner-/Admin-Grundeinstellungen koennen aktualisiert werden: erfuellt.
- Golden-Master und HTTP-Smoke bleiben gruen: erfuellt.
## Risiken
@@ -171,6 +192,8 @@ Noch offen im M3-Auth-Scope:
5. Kontrollskript fuer Tenant/Participant/Role-Counts schreiben: erledigt.
6. Login-/Registrierungsrouten bauen: erledigt.
7. Auth-Flow-Kontrollskript schreiben: erledigt.
8. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
9. Passwort-Reset, E-Mail-Verifikation und zentrale Rollenpruefung planen:
8. Zentrale Rollenpruefung und Mandant-Einstellungen bauen: erledigt.
9. Settings-Flow-Kontrollskript schreiben: erledigt.
10. Golden-Master und HTTP-Smoke ausfuehren: erledigt.
11. Passwort-Reset, E-Mail-Verifikation und Tenant-Aufloesung planen:
naechster Schritt.
+2 -2
View File
@@ -422,8 +422,8 @@ Schritte:
- Login und Logout bauen: erster Flow erledigt.
- Passwort-Reset und E-Mail-Verifikation bauen.
- Tenant-Aufloesung definieren.
- Rollenpruefung zentralisieren.
- Erste Admin-/Owner-Seite fuer Grundeinstellungen.
- Rollenpruefung zentralisieren: erster Owner/Admin-Check erledigt.
- Erste Admin-/Owner-Seite fuer Grundeinstellungen: erledigt.
Ergebnis:
+7
View File
@@ -38,6 +38,13 @@ $saasNavUser = function_exists('saas_current_user') ? saas_current_user() : null
if ($saasNavUser !== null) {
?>
<li><a href="konto.php">Kundenkonto</a></li>
<?php
if (function_exists('saas_can_manage_tenant_settings') && saas_can_manage_tenant_settings($saasNavUser)) {
?>
<li><a href="mandant-einstellungen.php">Mandant-Einstellungen</a></li>
<?php
}
?>
<li><a href="logout.php">Logout</a></li>
<?php
} else {
+6
View File
@@ -40,6 +40,12 @@ include 'nav.php';
</tr>
</table>
<?php if (saas_can_manage_tenant_settings($user)): ?>
<ul class="actions">
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
</ul>
<?php endif; ?>
<form method="post" action="logout.php">
<?php echo app_csrf_field(); ?>
<button type="submit">Abmelden</button>
+119
View File
@@ -0,0 +1,119 @@
<?php
require_once __DIR__ . '/functions.php';
$pdo = app_db_pdo();
$user = saas_require_login();
$canManageSettings = saas_can_manage_tenant_settings($user);
$errors = [];
$saved = false;
$settings = null;
if ($canManageSettings) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
app_require_csrf();
$result = saas_update_tenant_settings($pdo, (int)$user['tenant_id'], $_POST);
if ($result['ok']) {
$saved = true;
$settings = $result['settings'];
} else {
$errors = $result['errors'];
$settings = [
'name' => $_POST['tenant_name'] ?? '',
'timezone' => $_POST['timezone'] ?? 'Europe/Berlin',
'locale' => $_POST['locale'] ?? 'de-DE',
'currency_code' => $_POST['currency_code'] ?? 'EUR',
'mark_price_cents' => saas_parse_money_cents($_POST['mark_price'] ?? '') ?? 0,
'self_entry_enabled' => !empty($_POST['self_entry_enabled']) ? 1 : 0,
'paypal_enabled' => !empty($_POST['paypal_enabled']) ? 1 : 0,
'paypal_url_template' => $_POST['paypal_url_template'] ?? '',
'sheet_window_days' => $_POST['sheet_window_days'] ?? 100,
'negative_warning_cents' => -abs(saas_parse_money_cents($_POST['negative_warning'] ?? '') ?? 0),
];
}
} else {
$settings = saas_fetch_tenant_settings($pdo, (int)$user['tenant_id']);
}
}
include 'header.php';
include 'headerline.php';
include 'nav.php';
?>
<section id="banner">
<div class="content">
<h2>Mandant-Einstellungen</h2>
<?php if (!$canManageSettings): ?>
<?php http_response_code(403); ?>
<div class="hint-box error"><p>Du hast keine Berechtigung fuer diese Einstellungen.</p></div>
<?php elseif ($settings === null): ?>
<div class="hint-box error"><p>Die Einstellungen konnten nicht geladen werden.</p></div>
<?php else: ?>
<?php if ($saved): ?>
<div class="hint-box success"><p>Die Einstellungen wurden gespeichert.</p></div>
<?php endif; ?>
<?php if ($errors !== []): ?>
<div class="hint-box error">
<?php foreach ($errors as $error): ?>
<p><?php echo saas_html($error); ?></p>
<?php endforeach; ?>
</div>
<?php endif; ?>
<form method="post" action="mandant-einstellungen.php">
<?php echo app_csrf_field(); ?>
<div class="row">
<div class="col-6 col-12-small">
<label for="tenant_name">Kundenname</label>
<input type="text" name="tenant_name" id="tenant_name" value="<?php echo saas_html($settings['name']); ?>" required>
</div>
<div class="col-6 col-12-small">
<label for="timezone">Zeitzone</label>
<input type="text" name="timezone" id="timezone" value="<?php echo saas_html($settings['timezone']); ?>" required>
</div>
<div class="col-6 col-12-small">
<label for="locale">Locale</label>
<input type="text" name="locale" id="locale" value="<?php echo saas_html($settings['locale']); ?>" required>
</div>
<div class="col-6 col-12-small">
<label for="currency_code">Waehrung</label>
<input type="text" name="currency_code" id="currency_code" value="<?php echo saas_html($settings['currency_code']); ?>" maxlength="3" required>
</div>
<div class="col-6 col-12-small">
<label for="mark_price">Preis pro Strich</label>
<input type="text" name="mark_price" id="mark_price" value="<?php echo saas_html(saas_format_money_cents($settings['mark_price_cents'])); ?>" required>
</div>
<div class="col-6 col-12-small">
<label for="sheet_window_days">Listenfenster in Tagen</label>
<input type="number" name="sheet_window_days" id="sheet_window_days" min="1" max="365" value="<?php echo saas_html($settings['sheet_window_days']); ?>" required>
</div>
<div class="col-6 col-12-small">
<label for="negative_warning">Schulden-Warnschwelle</label>
<input type="text" name="negative_warning" id="negative_warning" value="<?php echo saas_html(saas_format_money_cents(abs((int)$settings['negative_warning_cents']))); ?>" required>
</div>
<div class="col-12">
<label for="paypal_url_template">PayPal-Link</label>
<input type="text" name="paypal_url_template" id="paypal_url_template" value="<?php echo saas_html($settings['paypal_url_template']); ?>">
</div>
<div class="col-6 col-12-small">
<input type="checkbox" id="self_entry_enabled" name="self_entry_enabled" value="1" <?php echo (int)$settings['self_entry_enabled'] === 1 ? 'checked' : ''; ?>>
<label for="self_entry_enabled">Web-Striche erlauben</label>
</div>
<div class="col-6 col-12-small">
<input type="checkbox" id="paypal_enabled" name="paypal_enabled" value="1" <?php echo (int)$settings['paypal_enabled'] === 1 ? 'checked' : ''; ?>>
<label for="paypal_enabled">PayPal anzeigen</label>
</div>
</div>
<ul class="actions">
<li><button type="submit">Speichern</button></li>
<li><a href="konto.php" class="button alt">Zurueck</a></li>
</ul>
</form>
<?php endif; ?>
</div>
</section>
<?php include 'footer.php'; ?>
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
require __DIR__ . '/dev-db.php';
require __DIR__ . '/../app/saas-auth.php';
function settings_check_assert(string $label, bool $condition, array &$failures, int &$passes): void
{
if ($condition) {
$passes++;
echo "PASS {$label}\n";
return;
}
$failures[] = $label;
echo "FAIL {$label}\n";
}
function settings_check_cleanup(PDO $pdo, string $slug, string $emailNorm): void
{
$stmt = $pdo->prepare('SELECT id FROM tenants WHERE slug = ?');
$stmt->execute([$slug]);
$tenantId = $stmt->fetchColumn();
if ($tenantId !== false) {
$tenantId = (int)$tenantId;
$pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]);
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]);
}
$pdo->prepare('DELETE FROM users WHERE email_norm = ?')->execute([$emailNorm]);
}
$pdo = dev_pdo();
dev_apply_schema($pdo);
$slug = 'm3-settings-flow-check';
$email = 'm3-settings-flow-check@test.local';
$emailNorm = saas_email_norm($email);
$password = 'M3-settings-flow-123';
$failures = [];
$passes = 0;
settings_check_cleanup($pdo, $slug, $emailNorm);
$registration = saas_register_tenant_owner($pdo, [
'tenant_name' => 'M3 Settings Flow Check',
'tenant_slug' => $slug,
'display_name' => 'M3 Settings Check',
'email' => $email,
'password' => $password,
'password_confirm' => $password,
]);
settings_check_assert('registration succeeds', $registration['ok'] === true, $failures, $passes);
if ($registration['ok']) {
$identity = $registration['identity'];
$tenantId = (int)$identity['tenant_id'];
settings_check_assert('owner can manage settings', saas_can_manage_tenant_settings($identity), $failures, $passes);
settings_check_assert(
'member cannot manage settings',
!saas_can_manage_tenant_settings(['role' => 'member']),
$failures,
$passes
);
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
settings_check_assert('settings can be fetched', $settings !== null, $failures, $passes);
if ($settings !== null) {
settings_check_assert('default mark price is 20 cents', $settings['mark_price_cents'] === 20, $failures, $passes);
}
$update = saas_update_tenant_settings($pdo, $tenantId, [
'tenant_name' => 'M3 Settings Flow Updated',
'timezone' => 'Europe/Berlin',
'locale' => 'de-DE',
'currency_code' => 'EUR',
'mark_price' => '0,35',
'sheet_window_days' => '90',
'negative_warning' => '12,50',
'paypal_url_template' => 'https://paypal.example/m3/',
'self_entry_enabled' => '1',
'paypal_enabled' => '1',
]);
settings_check_assert('settings update succeeds', $update['ok'] === true, $failures, $passes);
$updated = saas_fetch_tenant_settings($pdo, $tenantId);
settings_check_assert('updated settings can be fetched', $updated !== null, $failures, $passes);
if ($updated !== null) {
settings_check_assert('tenant name is updated', $updated['name'] === 'M3 Settings Flow Updated', $failures, $passes);
settings_check_assert('mark price is updated', $updated['mark_price_cents'] === 35, $failures, $passes);
settings_check_assert('sheet window is updated', $updated['sheet_window_days'] === 90, $failures, $passes);
settings_check_assert('negative warning is stored negative', $updated['negative_warning_cents'] === -1250, $failures, $passes);
settings_check_assert('paypal is enabled', $updated['paypal_enabled'] === 1, $failures, $passes);
settings_check_assert('self entry is enabled', $updated['self_entry_enabled'] === 1, $failures, $passes);
}
}
settings_check_cleanup($pdo, $slug, $emailNorm);
if ($failures !== []) {
echo "\nM3 settings flow check failed with " . count($failures) . " failure(s):\n";
foreach ($failures as $failure) {
echo "- {$failure}\n";
}
exit(1);
}
echo "\nM3 settings flow check passed with {$passes} assertions.\n";
+5
View File
@@ -61,6 +61,11 @@ $checks = [
'path' => 'register.php',
'contains' => ['Registrierung', 'Kundenkonto anlegen'],
],
[
'label' => 'Mandant-Einstellungen Login-Schutz',
'path' => 'mandant-einstellungen.php',
'contains' => ['Login'],
],
[
'label' => 'Namensanpassung',
'path' => 'namenanpassen.php',