M5: Hinweise auf tenant-scoped Notices umziehen, Mitgliederzugang auf Rollen umstellen
Hinweise: - Neue Tabelle notices (tenant-scoped, Soft-Delete via deleted_at) loest die global unscoped kl_hinweise als aktive Datenquelle ab; Migration uebernimmt einmalig aktuell gueltige kl_hinweise-Eintraege fuer den Default-Mandanten. kl_hinweise bleibt als Golden-Master-Referenz stehen. - hinweise.php und der Banner in header.php sind tenant-scoped umgestellt. Mitgliederverwaltung: - mitarbeiterverwalten.php verwaltet jetzt participants (tenant-scoped) statt der global unscoped kl_Mitarbeiter-Tabelle als primaere Quelle. Das behebt nebenbei ein Mandanten-Datenleck: jeder SaaS-Mandant mit Owner/Admin-Rolle haette zuvor die komplette Default-Mandanten- Mitgliederliste sehen und bearbeiten koennen. - Fuer den Default-Mandanten bleibt Dual-Write nach kl_Mitarbeiter bestehen, damit stricheintragen.php/einzahlung.php weiter funktionieren; andere Mandanten werden rein participant-nativ verwaltet. - Die Legacy-Administrator-Checkbox ist raus. Stattdessen kann ein Admin je Mitglied unabhaengig von Name/E-Mail einen Login-Zugang mit Rolle (member/treasurer/admin) gewaehren oder entziehen (saas_grant_participant_access / saas_revoke_participant_access). Einladung laeuft ueber den bestehenden Passwort-Reset-Mechanismus, Entzug setzt die Mitgliedschaft auf revoked statt sie zu loeschen. - Kompletter Flow live getestet: anlegen, Zugang gewaehren, Einladungsmail, Passwort setzen, Login, Rollenschutz, Zugang entziehen, Login-Sperre. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+170
-23
@@ -319,34 +319,181 @@ function ledger_mirror_legacy_consumption(PDO $pdo, int $tenantId, int $legacyCo
|
||||
}
|
||||
}
|
||||
|
||||
function ledger_is_default_tenant(PDO $pdo, int $tenantId): bool
|
||||
{
|
||||
$tenant = ledger_fetch_default_tenant($pdo);
|
||||
|
||||
return $tenant !== null && (int)$tenant['id'] === $tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors a single kl_Mitarbeiter row into participants, so member changes
|
||||
* made through the legacy admin UI are immediately visible to ledger-backed
|
||||
* reads instead of waiting for the next scripts/backfill-default-tenant.php run.
|
||||
* @return list<array{participant_id: int, display_name: string, email: ?string, email_norm: ?string, paypal_name: ?string, active: bool, legacy_mitarbeiter_id: ?int, user_id: ?int, role: ?string, membership_status: ?string}>
|
||||
*/
|
||||
function ledger_mirror_legacy_participant(PDO $pdo, int $tenantId, int $legacyMitarbeiterId): void
|
||||
function ledger_fetch_participants_for_admin(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO participants
|
||||
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
|
||||
SELECT
|
||||
?,
|
||||
NULLIF(TRIM(m.Name), ''),
|
||||
NULLIF(TRIM(m.Email), ''),
|
||||
NULLIF(LOWER(TRIM(m.Email)), ''),
|
||||
NULLIF(TRIM(m.paypalname), ''),
|
||||
m.aktiv,
|
||||
m.MitarbeiterID
|
||||
FROM kl_Mitarbeiter m
|
||||
WHERE m.MitarbeiterID = ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = COALESCE(VALUES(display_name), participants.display_name),
|
||||
email = VALUES(email),
|
||||
email_norm = VALUES(email_norm),
|
||||
paypal_name = VALUES(paypal_name),
|
||||
active = VALUES(active)"
|
||||
"SELECT
|
||||
p.id AS participant_id,
|
||||
p.display_name,
|
||||
p.email,
|
||||
p.email_norm,
|
||||
p.paypal_name,
|
||||
p.active,
|
||||
p.legacy_mitarbeiter_id,
|
||||
p.user_id,
|
||||
tm.role,
|
||||
tm.status AS membership_status
|
||||
FROM participants p
|
||||
LEFT JOIN tenant_memberships tm
|
||||
ON tm.tenant_id = p.tenant_id
|
||||
AND tm.user_id = p.user_id
|
||||
WHERE p.tenant_id = ?
|
||||
ORDER BY p.display_name, p.id"
|
||||
);
|
||||
$stmt->execute([$tenantId, $legacyMitarbeiterId]);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
$rows = [];
|
||||
while ($row = $stmt->fetch()) {
|
||||
$rows[] = [
|
||||
'participant_id' => (int)$row['participant_id'],
|
||||
'display_name' => (string)$row['display_name'],
|
||||
'email' => $row['email'] !== null ? (string)$row['email'] : null,
|
||||
'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null,
|
||||
'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null,
|
||||
'active' => (int)$row['active'] === 1,
|
||||
'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null,
|
||||
'user_id' => $row['user_id'] !== null ? (int)$row['user_id'] : null,
|
||||
'role' => $row['role'] !== null ? (string)$row['role'] : null,
|
||||
'membership_status' => $row['membership_status'] !== null ? (string)$row['membership_status'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tenant-scoped participant. For the default tenant, a matching
|
||||
* kl_Mitarbeiter row is dual-written too, so the still-legacy bulk entry
|
||||
* pages (stricheintragen.php, einzahlung.php) keep listing the member; other
|
||||
* tenants have no legacy shadow table and get a participants-only row.
|
||||
*
|
||||
* @throws Throwable on constraint violations (e.g. duplicate email)
|
||||
*/
|
||||
function ledger_create_participant(PDO $pdo, int $tenantId, string $displayName, string $email, ?string $paypalName, bool $active): int
|
||||
{
|
||||
$displayName = trim($displayName);
|
||||
$email = trim($email);
|
||||
$emailNorm = strtolower($email);
|
||||
$paypalName = $paypalName !== null ? trim($paypalName) : null;
|
||||
$paypalName = $paypalName !== '' ? $paypalName : null;
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$legacyMitarbeiterId = null;
|
||||
if (ledger_is_default_tenant($pdo, $tenantId)) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO kl_Mitarbeiter (Name, Email, paypalname, aktiv, admin) VALUES (?, ?, ?, ?, 0)'
|
||||
);
|
||||
$stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0]);
|
||||
$legacyMitarbeiterId = (int)$pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO participants
|
||||
(tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]);
|
||||
$participantId = (int)$pdo->lastInsertId();
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return $participantId;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId, string $displayName, string $email, ?string $paypalName, bool $active): bool
|
||||
{
|
||||
$displayName = trim($displayName);
|
||||
$email = trim($email);
|
||||
$emailNorm = strtolower($email);
|
||||
$paypalName = $paypalName !== null ? trim($paypalName) : null;
|
||||
$paypalName = $paypalName !== '' ? $paypalName : null;
|
||||
|
||||
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
|
||||
$stmt->execute([$participantId, $tenantId]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row === false) {
|
||||
return false;
|
||||
}
|
||||
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
if ($legacyMitarbeiterId !== null) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = ?, aktiv = ? WHERE MitarbeiterID = ?'
|
||||
);
|
||||
$stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE participants
|
||||
SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ?
|
||||
WHERE id = ? AND tenant_id = ?'
|
||||
);
|
||||
$stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participantId, bool $active): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?');
|
||||
$stmt->execute([$participantId, $tenantId]);
|
||||
$row = $stmt->fetch();
|
||||
if ($row === false) {
|
||||
return false;
|
||||
}
|
||||
$legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null;
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
if ($legacyMitarbeiterId !== null) {
|
||||
$pdo->prepare('UPDATE kl_Mitarbeiter SET aktiv = ? WHERE MitarbeiterID = ?')
|
||||
->execute([$active ? 1 : 0, $legacyMitarbeiterId]);
|
||||
}
|
||||
|
||||
$pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?')
|
||||
->execute([$active ? 1 : 0, $participantId, $tenantId]);
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/bootstrap.php';
|
||||
|
||||
/**
|
||||
* @return array{id: int, message: string, valid_from: string, valid_until: string}|null
|
||||
*/
|
||||
function notices_fetch_active(PDO $pdo, int $tenantId): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, message, valid_from, valid_until
|
||||
FROM notices
|
||||
WHERE tenant_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND valid_from <= NOW()
|
||||
AND valid_until >= NOW()
|
||||
ORDER BY valid_until ASC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
$row = $stmt->fetch();
|
||||
|
||||
return $row !== false ? $row : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int, message: string, valid_from: string, valid_until: string}>
|
||||
*/
|
||||
function notices_fetch_all(PDO $pdo, int $tenantId): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT id, message, valid_from, valid_until
|
||||
FROM notices
|
||||
WHERE tenant_id = ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY valid_until DESC"
|
||||
);
|
||||
$stmt->execute([$tenantId]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function notices_create(PDO $pdo, int $tenantId, string $message, string $validUntil, ?int $createdByUserId): bool
|
||||
{
|
||||
$message = trim($message);
|
||||
if ($message === '' || $validUntil === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO notices (tenant_id, message, valid_from, valid_until, created_by_user_id)
|
||||
VALUES (?, ?, NOW(), ?, ?)'
|
||||
);
|
||||
|
||||
return $stmt->execute([$tenantId, $message, $validUntil, $createdByUserId]);
|
||||
}
|
||||
|
||||
function notices_soft_delete(PDO $pdo, int $tenantId, int $noticeId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE notices
|
||||
SET deleted_at = NOW()
|
||||
WHERE id = ?
|
||||
AND tenant_id = ?
|
||||
AND deleted_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$noticeId, $tenantId]);
|
||||
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
@@ -848,6 +848,132 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'] ?? ''));
|
||||
|
||||
@@ -131,6 +131,26 @@ function saas_send_password_reset_mail(string $to, string $token): array
|
||||
return saas_send_mail($to, 'Kaffeeliste Passwort zurücksetzen', $body);
|
||||
}
|
||||
|
||||
function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array
|
||||
{
|
||||
$link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token));
|
||||
$body = implode("\n", [
|
||||
'Hallo,',
|
||||
'',
|
||||
"du wurdest für \"{$tenantName}\" in der Kaffeeliste als {$role} eingeladen.",
|
||||
'Öffne diesen Link, um ein Passwort zu setzen und dich anzumelden:',
|
||||
'',
|
||||
$link,
|
||||
'',
|
||||
'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.',
|
||||
'Falls du diese Einladung nicht erwartet hast, kannst du diese Nachricht ignorieren.',
|
||||
'',
|
||||
'Deine Kaffeeliste',
|
||||
]);
|
||||
|
||||
return saas_send_mail($to, 'Einladung zur Kaffeeliste', $body);
|
||||
}
|
||||
|
||||
function saas_send_email_verification_mail(string $to, string $token): array
|
||||
{
|
||||
$link = saas_app_url('email-verifizieren.php?token=' . urlencode($token));
|
||||
|
||||
Reference in New Issue
Block a user