Vier Luecken geschlossen, die im Alltag sofort aufgefallen waeren: - Passwort aendern war im eingeloggten Zustand gar nicht moeglich; es gab nur den Reset per Mail-Link. saas_change_password() prueft das aktuelle Passwort, verlangt ein tatsaechlich anderes und erneuert danach die Session-ID. Formular in konto.php. - mandant-auswahl.php war nur direkt nach dem Login erreichbar. Wer bei mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die Seite bedient jetzt beide Wege, die Mandantenpruefung bleibt unveraendert ueber saas_identity_for_user_tenant(). Menuepunkt ab zwei Mitgliedschaften. - email_verified_at wurde nirgends geprueft, nur angezeigt - bei offener Selbstregistrierung konnte sich jemand mit fremder Adresse anmelden und alles nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion nach aussen wirkt: Einladung, Info-Mail, Jahresabschluss. Der Login selbst bleibt bewusst frei, sonst waeren alle migrierten Bestandsnutzer mit NULL-Verifikation ausgesperrt. Zusaetzlich setzt der Passwort-Reset die Verifikation mit, weil der Mail-Link den Postfachzugriff nachweist - sonst blieben eingeladene Mitglieder dauerhaft unbestaetigt. - Login landete auf konto.php statt auf dem Dashboard. Landingpage: die gruene Vertrauenszeile auf "DSGVO-konform" gekuerzt, die Eintraege zu Paragraf 19 UStG und "Bestehende Ablaeufe bleiben" entfernt. Geprueft: neues scripts/check-konto-und-mandantenwechsel.php mit 18 Assertions gruen, Passwortformular zusaetzlich manuell inkl. CSRF (419). Bestehende Suiten unveraendert gruen: HTTP-Smoke 34 Seiten, Rollenmatrix 55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1318 lines
43 KiB
PHP
1318 lines
43 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/bootstrap.php';
|
|
require_once __DIR__ . '/database.php';
|
|
require_once __DIR__ . '/faq.php';
|
|
require_once __DIR__ . '/billing.php';
|
|
|
|
function saas_email_norm(string $email): string
|
|
{
|
|
return strtolower(trim($email));
|
|
}
|
|
|
|
function saas_slugify(string $value): string
|
|
{
|
|
$slug = strtolower(trim($value));
|
|
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? '';
|
|
$slug = trim($slug, '-');
|
|
|
|
return $slug;
|
|
}
|
|
|
|
function saas_html(mixed $value): string
|
|
{
|
|
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
|
}
|
|
|
|
function saas_should_show_auth_links(): bool
|
|
{
|
|
return app_is_dev() || app_env('APP_SHOW_AUTH_LINKS', '0') === '1';
|
|
}
|
|
|
|
function saas_token_hash(string $token): string
|
|
{
|
|
return hash('sha256', $token);
|
|
}
|
|
|
|
function saas_normalize_host(?string $host): string
|
|
{
|
|
$host = strtolower(trim((string)$host));
|
|
if ($host === '') {
|
|
return '';
|
|
}
|
|
|
|
if (str_contains($host, '://')) {
|
|
$parsedHost = parse_url($host, PHP_URL_HOST);
|
|
$host = is_string($parsedHost) ? $parsedHost : $host;
|
|
}
|
|
|
|
$host = preg_replace('/:\d+$/', '', $host) ?? $host;
|
|
|
|
return trim($host, " \t\n\r\0\x0B.");
|
|
}
|
|
|
|
function saas_primary_app_host(): string
|
|
{
|
|
return saas_normalize_host(app_env('APP_PRIMARY_HOST', ''));
|
|
}
|
|
|
|
function saas_is_primary_app_host(?string $host = null): bool
|
|
{
|
|
$primaryHost = saas_primary_app_host();
|
|
if ($primaryHost === '') {
|
|
return false;
|
|
}
|
|
|
|
$host = $host ?? ($_SERVER['HTTP_HOST'] ?? '');
|
|
|
|
return saas_normalize_host($host) === $primaryHost;
|
|
}
|
|
|
|
function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array
|
|
{
|
|
$host = saas_normalize_host($host ?? ($_SERVER['HTTP_HOST'] ?? ''));
|
|
if ($host === '' || saas_is_primary_app_host($host)) {
|
|
return null;
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'SELECT
|
|
td.id AS tenant_domain_id,
|
|
td.domain,
|
|
td.domain_norm,
|
|
td.domain_type,
|
|
t.id AS tenant_id,
|
|
t.slug AS tenant_slug,
|
|
t.name AS tenant_name,
|
|
t.status AS tenant_status
|
|
FROM tenant_domains td
|
|
JOIN tenants t ON t.id = td.tenant_id
|
|
WHERE td.domain_norm = ?
|
|
AND td.status = ?
|
|
AND t.status = ?
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([$host, 'active', 'active']);
|
|
$row = $stmt->fetch();
|
|
|
|
return $row !== false ? $row : null;
|
|
}
|
|
|
|
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;
|
|
static $user = null;
|
|
|
|
app_start_session();
|
|
|
|
if ($cached) {
|
|
return $user;
|
|
}
|
|
|
|
$cached = true;
|
|
|
|
$userId = isset($_SESSION['saas_user_id']) ? (int)$_SESSION['saas_user_id'] : 0;
|
|
$tenantId = isset($_SESSION['saas_tenant_id']) ? (int)$_SESSION['saas_tenant_id'] : 0;
|
|
if ($userId <= 0 || $tenantId <= 0) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$pdo = $pdo ?? app_db_pdo();
|
|
$stmt = $pdo->prepare(
|
|
'SELECT
|
|
u.id AS user_id,
|
|
u.email,
|
|
u.email_norm,
|
|
u.display_name,
|
|
u.email_verified_at,
|
|
u.status AS user_status,
|
|
t.id AS tenant_id,
|
|
t.slug AS tenant_slug,
|
|
t.name AS tenant_name,
|
|
t.status AS tenant_status,
|
|
tm.role,
|
|
tm.status AS membership_status
|
|
FROM users u
|
|
JOIN tenant_memberships tm ON tm.user_id = u.id
|
|
JOIN tenants t ON t.id = tm.tenant_id
|
|
WHERE u.id = ?
|
|
AND t.id = ?
|
|
AND u.status = ?
|
|
AND tm.status = ?
|
|
AND t.status = ?
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
|
$row = $stmt->fetch();
|
|
} catch (Throwable $e) {
|
|
return null;
|
|
}
|
|
|
|
if ($row === false) {
|
|
unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']);
|
|
return null;
|
|
}
|
|
|
|
$user = $row;
|
|
|
|
return $user;
|
|
}
|
|
|
|
function saas_session_login(array $identity): void
|
|
{
|
|
app_start_session();
|
|
session_regenerate_id(true);
|
|
|
|
$_SESSION['saas_user_id'] = (int)$identity['user_id'];
|
|
$_SESSION['saas_tenant_id'] = (int)$identity['tenant_id'];
|
|
$_SESSION['saas_role'] = (string)$identity['role'];
|
|
saas_clear_pending_tenant_selection();
|
|
}
|
|
|
|
function saas_logout(): void
|
|
{
|
|
app_start_session();
|
|
unset(
|
|
$_SESSION['saas_user_id'],
|
|
$_SESSION['saas_tenant_id'],
|
|
$_SESSION['saas_role'],
|
|
$_SESSION['saas_pending_user_id'],
|
|
$_SESSION['saas_pending_started_at']
|
|
);
|
|
}
|
|
|
|
function saas_require_login(): array
|
|
{
|
|
$user = saas_current_user();
|
|
if ($user !== null) {
|
|
return $user;
|
|
}
|
|
|
|
header('Location: login.php');
|
|
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,
|
|
ts.pdf_show_empty_rows,
|
|
ts.pdf_split_mode,
|
|
ts.pdf_row_height_px,
|
|
ts.pdf_watermark_text,
|
|
ts.pdf_footer_text,
|
|
ts.payment_reminder_enabled,
|
|
ts.payment_reminder_interval_days,
|
|
ts.pdf_watermark_logo,
|
|
ts.cash_enabled,
|
|
ts.cash_contact,
|
|
ts.bank_transfer_enabled,
|
|
ts.bank_account_holder,
|
|
ts.bank_iban
|
|
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'],
|
|
'pdf_show_empty_rows' => (int)$settings['pdf_show_empty_rows'],
|
|
'pdf_split_mode' => (string)$settings['pdf_split_mode'],
|
|
'pdf_row_height_px' => (int)$settings['pdf_row_height_px'],
|
|
'pdf_watermark_text' => (string)$settings['pdf_watermark_text'],
|
|
'pdf_footer_text' => (string)$settings['pdf_footer_text'],
|
|
'payment_reminder_enabled' => (int)$settings['payment_reminder_enabled'],
|
|
'payment_reminder_interval_days' => (int)$settings['payment_reminder_interval_days'],
|
|
'pdf_watermark_logo' => (string)$settings['pdf_watermark_logo'],
|
|
'cash_enabled' => (int)$settings['cash_enabled'],
|
|
'cash_contact' => (string)$settings['cash_contact'],
|
|
'bank_transfer_enabled' => (int)$settings['bank_transfer_enabled'],
|
|
'bank_account_holder' => (string)$settings['bank_account_holder'],
|
|
'bank_iban' => (string)$settings['bank_iban'],
|
|
];
|
|
}
|
|
|
|
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'] ?? ''));
|
|
$cashContact = trim((string)($input['cash_contact'] ?? ''));
|
|
$bankAccountHolder = trim((string)($input['bank_account_holder'] ?? ''));
|
|
$bankIban = strtoupper(preg_replace('/\s+/', '', (string)($input['bank_iban'] ?? '')));
|
|
$pdfSplitMode = trim((string)($input['pdf_split_mode'] ?? 'drinking_behavior'));
|
|
$pdfRowHeightPx = filter_var($input['pdf_row_height_px'] ?? null, FILTER_VALIDATE_INT);
|
|
$pdfWatermarkText = trim((string)($input['pdf_watermark_text'] ?? ''));
|
|
$pdfFooterText = trim((string)($input['pdf_footer_text'] ?? ''));
|
|
$paymentReminderIntervalDays = filter_var($input['payment_reminder_interval_days'] ?? null, FILTER_VALIDATE_INT);
|
|
|
|
$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 ungültig.';
|
|
}
|
|
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 Währungscode muss aus drei Großbuchstaben 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 (!in_array($pdfSplitMode, ['drinking_behavior', 'alphabetical'], true)) {
|
|
$errors[] = 'Die Sortierung für den Ausdruck ist ungültig.';
|
|
}
|
|
if (strlen($pdfWatermarkText) > 500) {
|
|
$errors[] = 'Der Wasserzeichen-Text im Ausdruck ist zu lang (max. 500 Zeichen).';
|
|
}
|
|
if (strlen($pdfFooterText) > 500) {
|
|
$errors[] = 'Der Fußzeilen-Hinweis im Ausdruck ist zu lang (max. 500 Zeichen).';
|
|
}
|
|
if ($pdfRowHeightPx === false || $pdfRowHeightPx < 10 || $pdfRowHeightPx > 60) {
|
|
$errors[] = 'Die Zeilenhöhe im Ausdruck muss zwischen 10 und 60 Pixel liegen.';
|
|
}
|
|
if ($paymentReminderIntervalDays === false || $paymentReminderIntervalDays < 1 || $paymentReminderIntervalDays > 90) {
|
|
$errors[] = 'Das Erinnerungs-Intervall muss zwischen 1 und 90 Tagen liegen.';
|
|
}
|
|
if (strlen($cashContact) > 255) {
|
|
$errors[] = 'Der Bar-Ansprechpartner ist zu lang (max. 255 Zeichen).';
|
|
}
|
|
if (strlen($bankAccountHolder) > 255) {
|
|
$errors[] = 'Der Kontoinhaber ist zu lang (max. 255 Zeichen).';
|
|
}
|
|
if ($bankIban !== '' && !preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/', $bankIban)) {
|
|
$errors[] = 'Die IBAN hat kein gültiges Format.';
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return ['ok' => false, 'errors' => $errors];
|
|
}
|
|
|
|
$selfEntryEnabled = !empty($input['self_entry_enabled']) ? 1 : 0;
|
|
$paypalEnabled = !empty($input['paypal_enabled']) ? 1 : 0;
|
|
$cashEnabled = !empty($input['cash_enabled']) ? 1 : 0;
|
|
$bankTransferEnabled = !empty($input['bank_transfer_enabled']) ? 1 : 0;
|
|
$pdfShowEmptyRows = !empty($input['pdf_show_empty_rows']) ? 1 : 0;
|
|
$paymentReminderEnabled = !empty($input['payment_reminder_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,
|
|
pdf_show_empty_rows, pdf_split_mode, pdf_row_height_px,
|
|
pdf_watermark_text, pdf_footer_text,
|
|
payment_reminder_enabled, payment_reminder_interval_days,
|
|
cash_enabled, cash_contact, bank_transfer_enabled,
|
|
bank_account_holder, bank_iban)
|
|
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),
|
|
pdf_show_empty_rows = VALUES(pdf_show_empty_rows),
|
|
pdf_split_mode = VALUES(pdf_split_mode),
|
|
pdf_row_height_px = VALUES(pdf_row_height_px),
|
|
pdf_watermark_text = VALUES(pdf_watermark_text),
|
|
pdf_footer_text = VALUES(pdf_footer_text),
|
|
payment_reminder_enabled = VALUES(payment_reminder_enabled),
|
|
payment_reminder_interval_days = VALUES(payment_reminder_interval_days),
|
|
cash_enabled = VALUES(cash_enabled),
|
|
cash_contact = VALUES(cash_contact),
|
|
bank_transfer_enabled = VALUES(bank_transfer_enabled),
|
|
bank_account_holder = VALUES(bank_account_holder),
|
|
bank_iban = VALUES(bank_iban)'
|
|
);
|
|
$stmt->execute([
|
|
$tenantId,
|
|
$markPriceCents,
|
|
$selfEntryEnabled,
|
|
$paypalEnabled,
|
|
$paypalUrlTemplate,
|
|
(int)$sheetWindowDays,
|
|
-abs($negativeWarningCents),
|
|
$pdfShowEmptyRows,
|
|
$pdfSplitMode,
|
|
(int)$pdfRowHeightPx,
|
|
$pdfWatermarkText,
|
|
$pdfFooterText,
|
|
$paymentReminderEnabled,
|
|
(int)$paymentReminderIntervalDays,
|
|
$cashEnabled,
|
|
$cashContact,
|
|
$bankTransferEnabled,
|
|
$bankAccountHolder,
|
|
$bankIban,
|
|
]);
|
|
|
|
$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_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = ''): ?array
|
|
{
|
|
$emailNorm = saas_email_norm($email);
|
|
if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
|
|
return null;
|
|
}
|
|
|
|
$params = [$emailNorm, 'active', 'active', 'active'];
|
|
$tenantFilter = '';
|
|
if (trim($tenantSlug) !== '') {
|
|
$tenantFilter = ' AND t.slug = ?';
|
|
$params[] = saas_slugify($tenantSlug);
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'SELECT
|
|
u.id AS user_id,
|
|
u.email,
|
|
u.email_norm,
|
|
u.display_name,
|
|
u.status AS user_status,
|
|
u.email_verified_at,
|
|
t.id AS tenant_id,
|
|
t.slug AS tenant_slug,
|
|
t.name AS tenant_name,
|
|
tm.role
|
|
FROM users u
|
|
JOIN tenant_memberships tm ON tm.user_id = u.id
|
|
JOIN tenants t ON t.id = tm.tenant_id
|
|
WHERE u.email_norm = ?
|
|
AND u.status = ?
|
|
AND tm.status = ?
|
|
AND t.status = ?' . $tenantFilter . '
|
|
ORDER BY
|
|
CASE tm.role
|
|
WHEN \'owner\' THEN 1
|
|
WHEN \'admin\' THEN 2
|
|
ELSE 3
|
|
END,
|
|
t.name
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute($params);
|
|
$identity = $stmt->fetch();
|
|
|
|
return $identity !== false ? $identity : null;
|
|
}
|
|
|
|
function saas_list_user_memberships(PDO $pdo, int $userId): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
'SELECT
|
|
u.id AS user_id,
|
|
u.email,
|
|
u.email_norm,
|
|
u.display_name,
|
|
u.status AS user_status,
|
|
u.email_verified_at,
|
|
t.id AS tenant_id,
|
|
t.slug AS tenant_slug,
|
|
t.name AS tenant_name,
|
|
t.status AS tenant_status,
|
|
tm.role,
|
|
tm.status AS membership_status
|
|
FROM users u
|
|
JOIN tenant_memberships tm ON tm.user_id = u.id
|
|
JOIN tenants t ON t.id = tm.tenant_id
|
|
WHERE u.id = ?
|
|
AND u.status = ?
|
|
AND tm.status = ?
|
|
AND t.status = ?
|
|
ORDER BY
|
|
CASE tm.role
|
|
WHEN \'owner\' THEN 1
|
|
WHEN \'admin\' THEN 2
|
|
ELSE 3
|
|
END,
|
|
t.name'
|
|
);
|
|
$stmt->execute([$userId, 'active', 'active', 'active']);
|
|
|
|
return $stmt->fetchAll();
|
|
}
|
|
|
|
function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
'SELECT
|
|
u.id AS user_id,
|
|
u.email,
|
|
u.email_norm,
|
|
u.display_name,
|
|
u.status AS user_status,
|
|
u.email_verified_at,
|
|
t.id AS tenant_id,
|
|
t.slug AS tenant_slug,
|
|
t.name AS tenant_name,
|
|
t.status AS tenant_status,
|
|
tm.role,
|
|
tm.status AS membership_status
|
|
FROM users u
|
|
JOIN tenant_memberships tm ON tm.user_id = u.id
|
|
JOIN tenants t ON t.id = tm.tenant_id
|
|
WHERE u.id = ?
|
|
AND t.id = ?
|
|
AND u.status = ?
|
|
AND tm.status = ?
|
|
AND t.status = ?
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([$userId, $tenantId, 'active', 'active', 'active']);
|
|
$identity = $stmt->fetch();
|
|
|
|
return $identity !== false ? $identity : null;
|
|
}
|
|
|
|
function saas_start_pending_tenant_selection(int $userId): void
|
|
{
|
|
app_start_session();
|
|
session_regenerate_id(true);
|
|
|
|
$_SESSION['saas_pending_user_id'] = $userId;
|
|
$_SESSION['saas_pending_started_at'] = time();
|
|
unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']);
|
|
}
|
|
|
|
function saas_pending_tenant_user_id(): ?int
|
|
{
|
|
app_start_session();
|
|
|
|
$userId = isset($_SESSION['saas_pending_user_id']) ? (int)$_SESSION['saas_pending_user_id'] : 0;
|
|
$startedAt = isset($_SESSION['saas_pending_started_at']) ? (int)$_SESSION['saas_pending_started_at'] : 0;
|
|
if ($userId <= 0 || $startedAt <= 0 || $startedAt < time() - 900) {
|
|
saas_clear_pending_tenant_selection();
|
|
return null;
|
|
}
|
|
|
|
return $userId;
|
|
}
|
|
|
|
function saas_clear_pending_tenant_selection(): void
|
|
{
|
|
if (PHP_SAPI !== 'cli') {
|
|
app_start_session();
|
|
}
|
|
|
|
unset($_SESSION['saas_pending_user_id'], $_SESSION['saas_pending_started_at']);
|
|
}
|
|
|
|
function saas_create_auth_token(PDO $pdo, int $userId, string $tokenType, ?int $tenantId, int $ttlMinutes): array
|
|
{
|
|
$token = bin2hex(random_bytes(32));
|
|
|
|
$pdo->prepare(
|
|
'UPDATE user_auth_tokens
|
|
SET consumed_at = NOW()
|
|
WHERE user_id = ?
|
|
AND token_type = ?
|
|
AND consumed_at IS NULL'
|
|
)->execute([$userId, $tokenType]);
|
|
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO user_auth_tokens (user_id, tenant_id, token_type, token_hash, expires_at)
|
|
VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE))'
|
|
);
|
|
$stmt->execute([$userId, $tenantId, $tokenType, saas_token_hash($token), $ttlMinutes]);
|
|
|
|
return [
|
|
'token' => $token,
|
|
'expires_at' => date('Y-m-d H:i:s', time() + ($ttlMinutes * 60)),
|
|
];
|
|
}
|
|
|
|
function saas_fetch_valid_auth_token(PDO $pdo, string $token, string $tokenType): ?array
|
|
{
|
|
if (!preg_match('/^[a-f0-9]{64}$/', $token)) {
|
|
return null;
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'SELECT
|
|
at.id,
|
|
at.user_id,
|
|
at.tenant_id,
|
|
at.token_type,
|
|
at.expires_at,
|
|
u.email,
|
|
u.email_norm,
|
|
u.display_name,
|
|
u.status AS user_status,
|
|
u.email_verified_at
|
|
FROM user_auth_tokens at
|
|
JOIN users u ON u.id = at.user_id
|
|
WHERE at.token_hash = ?
|
|
AND at.token_type = ?
|
|
AND at.consumed_at IS NULL
|
|
AND at.expires_at >= NOW()
|
|
AND u.status = ?
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([saas_token_hash($token), $tokenType, 'active']);
|
|
$row = $stmt->fetch();
|
|
|
|
return $row !== false ? $row : null;
|
|
}
|
|
|
|
function saas_request_password_reset(PDO $pdo, string $email, string $tenantSlug = ''): array
|
|
{
|
|
$emailNorm = saas_email_norm($email);
|
|
if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Bitte eine gültige E-Mail-Adresse eingeben.'],
|
|
];
|
|
}
|
|
|
|
$identity = saas_find_auth_identity($pdo, $emailNorm, $tenantSlug);
|
|
if ($identity === null) {
|
|
return [
|
|
'ok' => true,
|
|
'message' => 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.',
|
|
'token' => null,
|
|
'errors' => [],
|
|
];
|
|
}
|
|
|
|
$token = saas_create_auth_token(
|
|
$pdo,
|
|
(int)$identity['user_id'],
|
|
'password_reset',
|
|
(int)$identity['tenant_id'],
|
|
60
|
|
);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'message' => 'Wenn ein passendes Konto existiert, wurde ein Link vorbereitet.',
|
|
'token' => $token['token'],
|
|
'expires_at' => $token['expires_at'],
|
|
'errors' => [],
|
|
];
|
|
}
|
|
|
|
function saas_reset_password_with_token(PDO $pdo, string $token, string $password, string $passwordConfirm): array
|
|
{
|
|
$errors = [];
|
|
if (strlen($password) < 8) {
|
|
$errors[] = 'Das Passwort muss mindestens 8 Zeichen lang sein.';
|
|
}
|
|
if ($password !== $passwordConfirm) {
|
|
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
|
}
|
|
|
|
$tokenRow = saas_fetch_valid_auth_token($pdo, $token, 'password_reset');
|
|
if ($tokenRow === null) {
|
|
$errors[] = 'Der Link ist ungültig oder abgelaufen.';
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return ['ok' => false, 'errors' => $errors];
|
|
}
|
|
|
|
try {
|
|
$pdo->beginTransaction();
|
|
// Wer den per Mail zugestellten Link oeffnen konnte, hat den Zugriff
|
|
// auf das Postfach nachgewiesen - fachlich dasselbe wie eine
|
|
// Verifikation. Das betrifft vor allem eingeladene Mitglieder: die
|
|
// Zugangsvergabe laeuft ueber genau diesen Reset-Link, ohne dass je
|
|
// eine separate Verifikationsmail verschickt wird.
|
|
$pdo->prepare(
|
|
'UPDATE users
|
|
SET password_hash = ?,
|
|
email_verified_at = COALESCE(email_verified_at, NOW())
|
|
WHERE id = ?'
|
|
)->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]);
|
|
$pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?')
|
|
->execute([(int)$tokenRow['id']]);
|
|
$pdo->commit();
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Das Passwort konnte nicht gespeichert werden.'],
|
|
];
|
|
}
|
|
|
|
return ['ok' => true, 'errors' => []];
|
|
}
|
|
|
|
/**
|
|
* Passwortwechsel fuer den bereits angemeldeten Nutzer. Bewusst getrennt vom
|
|
* Token-Reset: hier weist das aktuelle Passwort die Identitaet nach, nicht
|
|
* ein Mail-Link. Setzt anders als der Reset keine E-Mail-Verifikation, weil
|
|
* dabei nichts ueber das Postfach nachgewiesen wird.
|
|
*/
|
|
function saas_change_password(
|
|
PDO $pdo,
|
|
int $userId,
|
|
string $currentPassword,
|
|
string $password,
|
|
string $passwordConfirm
|
|
): array {
|
|
$errors = [];
|
|
if (strlen($password) < 8) {
|
|
$errors[] = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.';
|
|
}
|
|
if ($password !== $passwordConfirm) {
|
|
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
|
}
|
|
if ($currentPassword !== '' && $password === $currentPassword) {
|
|
$errors[] = 'Das neue Passwort muss sich vom bisherigen unterscheiden.';
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return ['ok' => false, 'errors' => $errors];
|
|
}
|
|
|
|
$stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1');
|
|
$stmt->execute([$userId]);
|
|
$user = $stmt->fetch();
|
|
|
|
if ($user === false || (string)$user['status'] !== 'active') {
|
|
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
|
|
}
|
|
|
|
$hash = (string)($user['password_hash'] ?? '');
|
|
if ($hash === '' || !password_verify($currentPassword, $hash)) {
|
|
return ['ok' => false, 'errors' => ['Das aktuelle Passwort ist nicht korrekt.']];
|
|
}
|
|
|
|
try {
|
|
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
|
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
|
|
} catch (Throwable $e) {
|
|
return ['ok' => false, 'errors' => ['Das Passwort konnte nicht gespeichert werden.']];
|
|
}
|
|
|
|
// Die eigene Session bleibt bestehen, bekommt aber eine neue ID, damit ein
|
|
// eventuell mitgelesener Session-Bezeichner nach dem Wechsel wertlos ist.
|
|
if (PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) {
|
|
session_regenerate_id(true);
|
|
}
|
|
|
|
return ['ok' => true, 'errors' => []];
|
|
}
|
|
|
|
/**
|
|
* Hat der Nutzer seine E-Mail-Adresse nachgewiesen? Wird fuer Aktionen
|
|
* geprueft, die Mails an Dritte ausloesen oder fremde Konten anlegen -
|
|
* siehe saas_require_verified_email().
|
|
*/
|
|
function saas_email_verified(?array $user = null): bool
|
|
{
|
|
$user = $user ?? saas_current_user();
|
|
|
|
return $user !== null && ($user['email_verified_at'] ?? null) !== null;
|
|
}
|
|
|
|
/**
|
|
* Sperrt Aktionen, die aus dem Konto heraus nach aussen wirken (Mitglieder
|
|
* einladen, Massenmail, Jahresabschluss-Mails), solange die eigene Adresse
|
|
* nicht bestaetigt ist. Bewusst nicht auf den gesamten Login angewendet: die
|
|
* eigene Kaffeeliste darf man auch unbestaetigt fuehren, nur nicht im Namen
|
|
* einer moeglicherweise fremden Adresse Mails an Dritte ausloesen.
|
|
*/
|
|
function saas_require_verified_email(?array $user = null): array
|
|
{
|
|
$user = $user ?? saas_require_login();
|
|
|
|
if (saas_email_verified($user)) {
|
|
return $user;
|
|
}
|
|
|
|
http_response_code(403);
|
|
die(
|
|
'Für diese Aktion muss die eigene E-Mail-Adresse bestätigt sein. '
|
|
. 'Den Bestätigungslink kannst du unter "Kundenkonto" erneut anfordern.'
|
|
);
|
|
}
|
|
|
|
function saas_request_email_verification(PDO $pdo, int $userId, ?int $tenantId = null): array
|
|
{
|
|
$stmt = $pdo->prepare(
|
|
'SELECT id, email_verified_at, status
|
|
FROM users
|
|
WHERE id = ?
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([$userId]);
|
|
$user = $stmt->fetch();
|
|
|
|
if ($user === false || (string)$user['status'] !== 'active') {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Dieses Benutzerkonto ist nicht aktiv.'],
|
|
];
|
|
}
|
|
|
|
if ($user['email_verified_at'] !== null) {
|
|
return [
|
|
'ok' => true,
|
|
'already_verified' => true,
|
|
'token' => null,
|
|
'errors' => [],
|
|
];
|
|
}
|
|
|
|
$token = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'already_verified' => false,
|
|
'token' => $token['token'],
|
|
'expires_at' => $token['expires_at'],
|
|
'errors' => [],
|
|
];
|
|
}
|
|
|
|
function saas_verify_email_token(PDO $pdo, string $token): array
|
|
{
|
|
$tokenRow = saas_fetch_valid_auth_token($pdo, $token, 'email_verification');
|
|
if ($tokenRow === null) {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Der Link ist ungültig oder abgelaufen.'],
|
|
];
|
|
}
|
|
|
|
try {
|
|
$pdo->beginTransaction();
|
|
$pdo->prepare('UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = ?')
|
|
->execute([(int)$tokenRow['user_id']]);
|
|
$pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?')
|
|
->execute([(int)$tokenRow['id']]);
|
|
$pdo->commit();
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Die E-Mail-Adresse konnte nicht bestätigt werden.'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'email' => (string)$tokenRow['email'],
|
|
'errors' => [],
|
|
];
|
|
}
|
|
|
|
function saas_authenticate(PDO $pdo, string $email, string $password, string $tenantSlug = ''): array
|
|
{
|
|
$emailNorm = saas_email_norm($email);
|
|
if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL) || $password === '') {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['E-Mail oder Passwort ist ungültig.'],
|
|
];
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'SELECT id, email, email_norm, display_name, password_hash, status
|
|
FROM users
|
|
WHERE email_norm = ?
|
|
LIMIT 1'
|
|
);
|
|
$stmt->execute([$emailNorm]);
|
|
$user = $stmt->fetch();
|
|
|
|
if ($user === false
|
|
|| (string)$user['status'] !== 'active'
|
|
|| empty($user['password_hash'])
|
|
|| !password_verify($password, (string)$user['password_hash'])
|
|
) {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['E-Mail oder Passwort ist ungültig.'],
|
|
];
|
|
}
|
|
|
|
$params = [(int)$user['id'], 'active', 'active'];
|
|
$tenantFilter = '';
|
|
if (trim($tenantSlug) !== '') {
|
|
$tenantFilter = ' AND t.slug = ?';
|
|
$params[] = saas_slugify($tenantSlug);
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'SELECT t.id AS tenant_id
|
|
FROM tenant_memberships tm
|
|
JOIN tenants t ON t.id = tm.tenant_id
|
|
WHERE tm.user_id = ?
|
|
AND tm.status = ?
|
|
AND t.status = ?' . $tenantFilter . '
|
|
ORDER BY
|
|
CASE tm.role
|
|
WHEN \'owner\' THEN 1
|
|
WHEN \'admin\' THEN 2
|
|
ELSE 3
|
|
END,
|
|
t.name'
|
|
);
|
|
$stmt->execute($params);
|
|
$tenantIds = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN));
|
|
|
|
if ($tenantIds === []) {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Für dieses Konto ist kein aktiver Mandant verfügbar.'],
|
|
];
|
|
}
|
|
|
|
if (count($tenantIds) > 1 && trim($tenantSlug) === '') {
|
|
return [
|
|
'ok' => true,
|
|
'needs_tenant_selection' => true,
|
|
'user_id' => (int)$user['id'],
|
|
'memberships' => saas_list_user_memberships($pdo, (int)$user['id']),
|
|
'errors' => [],
|
|
];
|
|
}
|
|
|
|
$identity = saas_identity_for_user_tenant($pdo, (int)$user['id'], $tenantIds[0]);
|
|
if ($identity === null) {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Für dieses Konto ist kein aktiver Mandant verfügbar.'],
|
|
];
|
|
}
|
|
|
|
$pdo->prepare('UPDATE users SET last_login_at = NOW() WHERE id = ?')->execute([(int)$user['id']]);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'identity' => $identity,
|
|
'errors' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Roles an admin can hand out through the member management UI. "owner" is
|
|
* deliberately excluded here; it is only ever set during registration.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
function saas_grantable_roles(): array
|
|
{
|
|
return ['member', 'treasurer', 'admin'];
|
|
}
|
|
|
|
/**
|
|
* Grants (or updates) tenant access for an existing participant: creates the
|
|
* login account if needed, links it to the participant, and (re)activates
|
|
* the tenant membership with the given role. The participant's name/email
|
|
* stay the notification identity regardless of login state.
|
|
*
|
|
* @return array{ok: bool, errors: list<string>, token?: string, user_email?: string}
|
|
*/
|
|
function saas_grant_participant_access(PDO $pdo, int $tenantId, int $participantId, string $role): array
|
|
{
|
|
if (!in_array($role, saas_grantable_roles(), true)) {
|
|
return ['ok' => false, 'errors' => ['Ungültige Rolle.']];
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'SELECT id, display_name, email, email_norm
|
|
FROM participants
|
|
WHERE id = ? AND tenant_id = ?'
|
|
);
|
|
$stmt->execute([$participantId, $tenantId]);
|
|
$participant = $stmt->fetch();
|
|
if ($participant === false) {
|
|
return ['ok' => false, 'errors' => ['Teilnehmer wurde nicht gefunden.']];
|
|
}
|
|
|
|
$emailNorm = (string)($participant['email_norm'] ?? '');
|
|
if ($emailNorm === '' || !filter_var($participant['email'], FILTER_VALIDATE_EMAIL)) {
|
|
return ['ok' => false, 'errors' => ['Für den Zugang wird eine gültige E-Mail-Adresse benötigt.']];
|
|
}
|
|
|
|
try {
|
|
$pdo->beginTransaction();
|
|
|
|
$stmt = $pdo->prepare('SELECT id, status FROM users WHERE email_norm = ? LIMIT 1');
|
|
$stmt->execute([$emailNorm]);
|
|
$user = $stmt->fetch();
|
|
|
|
if ($user === false) {
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO users (email, email_norm, display_name, status) VALUES (?, ?, ?, ?)'
|
|
);
|
|
$stmt->execute([$participant['email'], $emailNorm, $participant['display_name'], 'active']);
|
|
$userId = (int)$pdo->lastInsertId();
|
|
} elseif ((string)$user['status'] !== 'active') {
|
|
$pdo->rollBack();
|
|
|
|
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
|
|
} else {
|
|
$userId = (int)$user['id'];
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, invited_at, joined_at)
|
|
VALUES (?, ?, ?, ?, NOW(), NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
role = VALUES(role),
|
|
status = VALUES(status),
|
|
invited_at = COALESCE(tenant_memberships.invited_at, VALUES(invited_at)),
|
|
joined_at = COALESCE(tenant_memberships.joined_at, VALUES(joined_at))'
|
|
);
|
|
$stmt->execute([$tenantId, $userId, $role, 'active']);
|
|
|
|
$pdo->prepare('UPDATE participants SET user_id = ? WHERE id = ? AND tenant_id = ?')
|
|
->execute([$userId, $participantId, $tenantId]);
|
|
|
|
$pdo->commit();
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
return ['ok' => false, 'errors' => ['Der Zugang konnte nicht angelegt werden.']];
|
|
}
|
|
|
|
$token = saas_create_auth_token($pdo, $userId, 'password_reset', $tenantId, 1440);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'errors' => [],
|
|
'token' => $token['token'],
|
|
'user_email' => (string)$participant['email'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Revokes tenant access without touching the participant's name/email or
|
|
* deleting the membership row, so the grant can be reinstated later and the
|
|
* history stays auditable. Owners cannot be revoked here.
|
|
*
|
|
* @return array{ok: bool, errors: list<string>}
|
|
*/
|
|
function saas_revoke_participant_access(PDO $pdo, int $tenantId, int $participantId): array
|
|
{
|
|
$stmt = $pdo->prepare('SELECT user_id FROM participants WHERE id = ? AND tenant_id = ?');
|
|
$stmt->execute([$participantId, $tenantId]);
|
|
$participant = $stmt->fetch();
|
|
if ($participant === false || $participant['user_id'] === null) {
|
|
return ['ok' => true, 'errors' => []];
|
|
}
|
|
|
|
$userId = (int)$participant['user_id'];
|
|
|
|
$stmt = $pdo->prepare('SELECT role FROM tenant_memberships WHERE tenant_id = ? AND user_id = ?');
|
|
$stmt->execute([$tenantId, $userId]);
|
|
$membership = $stmt->fetch();
|
|
if ($membership !== false && (string)$membership['role'] === 'owner') {
|
|
return ['ok' => false, 'errors' => ['Dem Inhaber kann der Zugang nicht entzogen werden.']];
|
|
}
|
|
|
|
$pdo->prepare("UPDATE tenant_memberships SET status = 'revoked' WHERE tenant_id = ? AND user_id = ?")
|
|
->execute([$tenantId, $userId]);
|
|
|
|
return ['ok' => true, 'errors' => []];
|
|
}
|
|
|
|
function saas_register_tenant_owner(PDO $pdo, array $input): array
|
|
{
|
|
$tenantName = trim((string)($input['tenant_name'] ?? ''));
|
|
$tenantSlug = saas_slugify((string)($input['tenant_slug'] ?? ''));
|
|
if ($tenantSlug === '') {
|
|
$tenantSlug = saas_slugify($tenantName);
|
|
}
|
|
|
|
$displayName = trim((string)($input['display_name'] ?? ''));
|
|
$email = trim((string)($input['email'] ?? ''));
|
|
$emailNorm = saas_email_norm($email);
|
|
$password = (string)($input['password'] ?? '');
|
|
$passwordConfirm = (string)($input['password_confirm'] ?? '');
|
|
|
|
$errors = [];
|
|
if (strlen($tenantName) < 3 || strlen($tenantName) > 255) {
|
|
$errors[] = 'Der Kundenname muss zwischen 3 und 255 Zeichen lang sein.';
|
|
}
|
|
if (!preg_match('/^[a-z0-9][a-z0-9-]{1,98}[a-z0-9]$/', $tenantSlug)) {
|
|
$errors[] = 'Das Kundenkürzel darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.';
|
|
}
|
|
if (strlen($displayName) < 2 || strlen($displayName) > 255) {
|
|
$errors[] = 'Der Name muss zwischen 2 und 255 Zeichen lang sein.';
|
|
}
|
|
if (!filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) {
|
|
$errors[] = 'Bitte eine gültige E-Mail-Adresse eingeben.';
|
|
}
|
|
if (strlen($password) < 8) {
|
|
$errors[] = 'Das Passwort muss mindestens 8 Zeichen lang sein.';
|
|
}
|
|
if ($password !== $passwordConfirm) {
|
|
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
|
}
|
|
|
|
if ($errors !== []) {
|
|
return ['ok' => false, 'errors' => $errors];
|
|
}
|
|
|
|
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email_norm = ? LIMIT 1');
|
|
$stmt->execute([$emailNorm]);
|
|
$existingUser = $stmt->fetch();
|
|
if ($existingUser !== false && (string)$existingUser['status'] !== 'active') {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Dieses Benutzerkonto ist nicht aktiv.'],
|
|
];
|
|
}
|
|
if ($existingUser !== false
|
|
&& !empty($existingUser['password_hash'])
|
|
&& !password_verify($password, (string)$existingUser['password_hash'])
|
|
) {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Diese E-Mail ist bereits vergeben. Bitte mit dem bestehenden Passwort anmelden.'],
|
|
];
|
|
}
|
|
|
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tenants WHERE slug = ?');
|
|
$stmt->execute([$tenantSlug]);
|
|
if ((int)$stmt->fetchColumn() > 0) {
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Dieses Kundenkürzel ist bereits vergeben.'],
|
|
];
|
|
}
|
|
|
|
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
|
|
|
|
try {
|
|
$pdo->beginTransaction();
|
|
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO tenants (slug, name, status, timezone, locale, currency_code)
|
|
VALUES (?, ?, ?, ?, ?, ?)'
|
|
);
|
|
$stmt->execute([$tenantSlug, $tenantName, 'active', 'Europe/Berlin', 'de-DE', 'EUR']);
|
|
$tenantId = (int)$pdo->lastInsertId();
|
|
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO tenant_settings
|
|
(tenant_id, mark_price_cents, self_entry_enabled, paypal_enabled, paypal_url_template)
|
|
VALUES (?, ?, ?, ?, ?)'
|
|
);
|
|
$stmt->execute([$tenantId, 20, 1, 0, '']);
|
|
|
|
faq_seed_default_entries($pdo, $tenantId);
|
|
billing_fetch_or_init($pdo, $tenantId);
|
|
|
|
if ($existingUser === false) {
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO users (email, email_norm, display_name, password_hash, status)
|
|
VALUES (?, ?, ?, ?, ?)'
|
|
);
|
|
$stmt->execute([$email, $emailNorm, $displayName, $passwordHash, 'active']);
|
|
$userId = (int)$pdo->lastInsertId();
|
|
} else {
|
|
$userId = (int)$existingUser['id'];
|
|
$stmt = $pdo->prepare(
|
|
'UPDATE users
|
|
SET email = ?, display_name = ?, password_hash = ?, status = ?
|
|
WHERE id = ?'
|
|
);
|
|
$stmt->execute([$email, $displayName, $passwordHash, 'active', $userId]);
|
|
}
|
|
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at)
|
|
VALUES (?, ?, ?, ?, NOW())'
|
|
);
|
|
$stmt->execute([$tenantId, $userId, 'owner', 'active']);
|
|
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO participants
|
|
(tenant_id, user_id, display_name, email, email_norm, active)
|
|
VALUES (?, ?, ?, ?, ?, ?)'
|
|
);
|
|
$stmt->execute([$tenantId, $userId, $displayName, $email, $emailNorm, 1]);
|
|
|
|
$verificationToken = saas_create_auth_token($pdo, $userId, 'email_verification', $tenantId, 1440);
|
|
|
|
$pdo->commit();
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) {
|
|
$pdo->rollBack();
|
|
}
|
|
|
|
return [
|
|
'ok' => false,
|
|
'errors' => ['Die Registrierung konnte nicht gespeichert werden.'],
|
|
];
|
|
}
|
|
|
|
return [
|
|
'ok' => true,
|
|
'identity' => [
|
|
'user_id' => $userId,
|
|
'email' => $email,
|
|
'email_norm' => $emailNorm,
|
|
'display_name' => $displayName,
|
|
'tenant_id' => $tenantId,
|
|
'tenant_slug' => $tenantSlug,
|
|
'tenant_name' => $tenantName,
|
|
'role' => 'owner',
|
|
],
|
|
'email_verification_token' => $verificationToken['token'],
|
|
'errors' => [],
|
|
];
|
|
}
|