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:
2026-07-15 00:18:55 +02:00
co-authored by Claude Sonnet 5
parent dcc8dc3421
commit aeb687f42e
10 changed files with 790 additions and 273 deletions
+126
View File
@@ -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'] ?? ''));