diff --git a/app/saas-auth.php b/app/saas-auth.php index f73fb24..4926dba 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -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); diff --git a/docs/m2-technical-foundation.md b/docs/m2-technical-foundation.md index 9c825ac..ae25824 100644 --- a/docs/m2-technical-foundation.md +++ b/docs/m2-technical-foundation.md @@ -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 diff --git a/docs/m3-saas-basis-vorbereitung.md b/docs/m3-saas-basis-vorbereitung.md index 2c8c0fe..b4eefb7 100644 --- a/docs/m3-saas-basis-vorbereitung.md +++ b/docs/m3-saas-basis-vorbereitung.md @@ -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. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 7871a42..1171e4c 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -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: diff --git a/footer.php b/footer.php index f3adca5..d2e153a 100644 --- a/footer.php +++ b/footer.php @@ -38,6 +38,13 @@ $saasNavUser = function_exists('saas_current_user') ? saas_current_user() : null if ($saasNavUser !== null) { ?>
  • Kundenkonto
  • + +
  • Mandant-Einstellungen
  • +
  • Logout
  • + + + +
    diff --git a/mandant-einstellungen.php b/mandant-einstellungen.php new file mode 100644 index 0000000..6985d9d --- /dev/null +++ b/mandant-einstellungen.php @@ -0,0 +1,119 @@ + $_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'; +?> + +
    + + diff --git a/scripts/check-m3-settings-flow.php b/scripts/check-m3-settings-flow.php new file mode 100644 index 0000000..1b0f8bd --- /dev/null +++ b/scripts/check-m3-settings-flow.php @@ -0,0 +1,114 @@ +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"; diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php index e3451b0..bff92b8 100644 --- a/scripts/http-smoke.php +++ b/scripts/http-smoke.php @@ -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',