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 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 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 ($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_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(); $pdo->prepare('UPDATE users SET password_hash = ? 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' => []]; } 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 */ 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, 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} */ 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' => [], ]; }