Bisher gab es nur PayPal. Jetzt sind pro Mandant drei unabhaengig aktivierbare Zahlungswege konfigurierbar (mandant-einstellungen.php): - Barzahlung mit frei waehlbarem Ansprechpartner. - PayPal wie bisher, mit Zahlungslink. - Ueberweisung mit Kontoinhaber und IBAN (Format wird geprueft, Leerzeichen werden normalisiert). Das Mitglieder-Dashboard (index.php) zeigt unter "Bezahlen" alle aktivierten Methoden mit dem jeweils offenen Betrag; die IBAN wird zur besseren Lesbarkeit in Viererbloecken angezeigt. Neue tenant_settings-Spalten via Migration 0021 (cash_enabled, cash_contact, bank_transfer_enabled, bank_account_holder, bank_iban).
1218 lines
39 KiB
PHP
1218 lines
39 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();
|
|
$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<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' => [],
|
|
];
|
|
}
|