diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php new file mode 100644 index 0000000..8afb08d --- /dev/null +++ b/src/App/AccountPages.php @@ -0,0 +1,383 @@ +flash()->get(); + $isLoggedIn = isset($_SESSION['user_id']); + $error = ''; + $displayName = ''; + $email = ''; + + if ($isLoggedIn) { + redirect('/dashboard'); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $displayName = trim((string)($_POST['display_name'] ?? '')); + $email = trim((string)($_POST['email'] ?? '')); + $password = (string)($_POST['password'] ?? ''); + $password2 = (string)($_POST['password_confirm'] ?? ''); + + if ($password !== $password2) { + $error = 'Passwörter stimmen nicht überein.'; + } elseif (strlen($password) < 8) { + $error = 'Passwort muss mindestens 8 Zeichen haben.'; + } else { + try { + $auth = new Auth($app); + $userId = $auth->register($displayName, $email, $password); + $code = $auth->createVerifyCode($userId, $email); + $mailer = new Mailer($app); + $mailer->sendTemplate('registration_confirm', $email, [ + 'code' => $code, + 'display_name' => $displayName, + ]); + $_SESSION['verify_email'] = $email; + $app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.'); + redirect('/verify'); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + } + + return compact('flash', 'error', 'displayName', 'email'); + } + + public static function login(App $app): array + { + $flash = $app->flash()->get(); + $isLoggedIn = isset($_SESSION['user_id']); + $error = ''; + $emailPrefill = ''; + + if ($isLoggedIn) { + redirect('/dashboard'); + } + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $email = trim((string)($_POST['email'] ?? '')); + $emailPrefill = $email; + $password = (string)($_POST['password'] ?? ''); + try { + $auth = new Auth($app); + $res = $auth->login($email, $password); + if ($res['status'] === 'pending') { + $code = $auth->createVerifyCode($res['id'], $email); + $mailer = new Mailer($app); + $mailer->sendTemplate('registration_confirm', $email, [ + 'code' => $code, + 'display_name' => $email, + ]); + $_SESSION['verify_email'] = $email; + $app->flash()->set('info', 'Bitte bestätige deine Registrierung mit dem Code aus der E-Mail.'); + redirect('/verify'); + } + $_SESSION['user_id'] = $res['id']; + $app->flash()->set('success', 'Erfolgreich angemeldet.'); + redirect('/dashboard'); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + return compact('flash', 'error', 'emailPrefill', 'isLoggedIn'); + } + + public static function verify(App $app): array + { + $pdo = $app->pdo(); + $flash = $app->flash()->get(); + $error = ''; + $info = ''; + $email = $_SESSION['verify_email'] ?? ''; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? 'verify'; + $email = trim((string)($_POST['email'] ?? '')); + $code = strtoupper(trim((string)($_POST['code'] ?? ''))); + $auth = new Auth($app); + $mailer = new Mailer($app); + + if ($action === 'resend') { + try { + $stmt = $pdo?->prepare('SELECT id, display_name, status FROM users u JOIN user_profiles p ON p.user_id = u.id WHERE u.email = :email LIMIT 1'); + $stmt?->execute(['email' => $email]); + $row = $stmt?->fetch(\PDO::FETCH_ASSOC); + if (!$row) { + throw new \RuntimeException('E-Mail nicht gefunden.'); + } + $userId = (int)$row['id']; + $codeNew = $auth->createVerifyCode($userId, $email); + $mailer->sendTemplate('registration_resend_code', $email, [ + 'code' => $codeNew, + 'display_name' => $row['display_name'] ?? '', + ]); + $info = 'Neuer Code wurde versendet.'; + $_SESSION['verify_email'] = $email; + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } else { + try { + $userId = $auth->verifyCode($email, $code); + $_SESSION['user_id'] = $userId; + unset($_SESSION['verify_email']); + $mailer->sendTemplate('registration_welcome', $email, ['display_name' => $email]); + $app->flash()->set('success', 'Registrierung bestätigt. Willkommen!'); + redirect('/dashboard'); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + } + + return compact('flash', 'error', 'info', 'email'); + } + + public static function dashboard(App $app): array + { + if (!isset($_SESSION['user_id'])) { + redirect('/login'); + } + + $pdo = $app->pdo(); + $flash = $app->flash()->get(); + $userId = (int)$_SESSION['user_id']; + $error = ''; + $info = ''; + $crypto = null; + try { $crypto = new Crypto($app->config()); } catch (\Throwable) {} + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = $_POST['action'] ?? ''; + try { + if ($action === 'profile') { + $languages = $_POST['languages'] ?? ''; + if (is_array($languages)) { + $languages = implode(', ', array_map('trim', $languages)); + } + $phoneEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['contact_phone'])) : trim((string)$_POST['contact_phone']); + $stmt = $pdo?->prepare('UPDATE user_profiles SET display_name=:name, first_name=:fname, last_name=:lname, zip=:zip, city=:city, profession=:prof, languages=:langs, about=:about, contact_phone=:phone, updated_at=NOW() WHERE user_id=:id'); + $stmt?->execute([ + 'name' => trim((string)$_POST['display_name']), + 'fname' => trim((string)$_POST['first_name']), + 'lname' => trim((string)$_POST['last_name']), + 'zip' => trim((string)$_POST['zip']), + 'city' => trim((string)$_POST['city']), + 'prof' => trim((string)$_POST['profession']), + 'langs' => trim((string)$languages), + 'about' => trim((string)$_POST['about']), + 'phone' => $phoneEnc, + 'id' => $userId, + ]); + $info = 'Profil gespeichert.'; + } elseif ($action === 'child_add') { + $firstNameEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['first_name'])) : trim((string)$_POST['first_name']); + $noteEnc = $crypto ? $crypto->encrypt(trim((string)$_POST['note'])) : trim((string)$_POST['note']); + $stmt = $pdo?->prepare('INSERT INTO children (user_id, gender, birthdate, age_years, encrypted_first_name, note, created_at, updated_at) VALUES (:uid, :gender, :birthdate, :age, :name, :note, NOW(), NOW())'); + $stmt?->execute([ + 'uid' => $userId, + 'gender' => $_POST['gender'] ?? 'unknown', + 'birthdate' => $_POST['birthdate'] ?: null, + 'age' => $_POST['age_years'] ?: null, + 'name' => $firstNameEnc, + 'note' => $noteEnc, + ]); + $info = 'Kind hinzugefügt.'; + } elseif ($action === 'event_add' || $action === 'event_update') { + $street = trim((string)($_POST['street'] ?? '')); + $zip = trim((string)($_POST['zip'] ?? '')); + $city = trim((string)($_POST['city'] ?? '')); + $region = trim((string)($_POST['region'] ?? '')); + $lat = isset($_POST['lat']) && $_POST['lat'] !== '' ? (float)$_POST['lat'] : null; + $lng = isset($_POST['lng']) && $_POST['lng'] !== '' ? (float)$_POST['lng'] : null; + if ($lat === null || $lng === null) { + [$lat, $lng] = self::geocodeAddress($street, $zip, $city, $region); + } + + if ($action === 'event_add') { + $stmt = $pdo?->prepare('INSERT INTO events (created_by, title, teaser_public, description, location_label, street, zip, city, region, lat, lng, starts_at, allow_kids, visibility, status, created_at, updated_at) VALUES (:uid, :title, :teaser, :descr, :loc, :street, :zip, :city, :region, :lat, :lng, :start, :allow, :vis, :status, NOW(), NOW())'); + $stmt?->execute([ + 'uid' => $userId, + 'title' => trim((string)$_POST['title']), + 'teaser' => trim((string)$_POST['teaser']), + 'descr' => trim((string)$_POST['description']), + 'loc' => trim((string)$_POST['location_label']), + 'street' => $street ?: null, + 'zip' => $zip, + 'city' => $city, + 'region' => $region, + 'lat' => $lat, + 'lng' => $lng, + 'start' => $_POST['starts_at'] ?? null, + 'allow' => isset($_POST['allow_kids']) ? 0 : 1, + 'vis' => $_POST['visibility'] ?? 'public', + 'status' => 'published', + ]); + $info = 'Event gespeichert.'; + } else { + $eventId = (int)($_POST['event_id'] ?? 0); + $stmt = $pdo?->prepare('UPDATE events SET title=:title, teaser_public=:teaser, description=:descr, location_label=:loc, street=:street, zip=:zip, city=:city, region=:region, lat=:lat, lng=:lng, starts_at=:start, allow_kids=:allow, visibility=:vis, updated_at=NOW() WHERE id=:id AND created_by=:uid'); + $stmt?->execute([ + 'id' => $eventId, + 'uid' => $userId, + 'title' => trim((string)$_POST['title']), + 'teaser' => trim((string)$_POST['teaser']), + 'descr' => trim((string)$_POST['description']), + 'loc' => trim((string)$_POST['location_label']), + 'street' => $street ?: null, + 'zip' => $zip, + 'city' => $city, + 'region' => $region, + 'lat' => $lat, + 'lng' => $lng, + 'start' => $_POST['starts_at'] ?? null, + 'allow' => isset($_POST['allow_kids']) ? 0 : 1, + 'vis' => $_POST['visibility'] ?? 'public', + ]); + $info = 'Event aktualisiert.'; + } + } elseif ($action === 'event_delete') { + $eventId = (int)($_POST['event_id'] ?? 0); + $stmt = $pdo?->prepare('SELECT id, created_by, status, (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = events.id) AS participant_count FROM events WHERE id = :id LIMIT 1'); + $stmt?->execute(['id' => $eventId]); + $ev = $stmt?->fetch(\PDO::FETCH_ASSOC); + if (!$ev || (int)$ev['created_by'] !== $userId) { + throw new \RuntimeException('Event nicht gefunden.'); + } + if ((int)$ev['participant_count'] > 0) { + throw new \RuntimeException('Event hat Anmeldungen und kann nicht gelöscht werden.'); + } + $pdo?->prepare('DELETE FROM events WHERE id = :id')->execute(['id' => $eventId]); + $info = 'Event gelöscht.'; + } elseif ($action === 'event_cancel') { + $eventId = (int)($_POST['event_id'] ?? 0); + $stmt = $pdo?->prepare('SELECT id, created_by FROM events WHERE id = :id LIMIT 1'); + $stmt?->execute(['id' => $eventId]); + $ev = $stmt?->fetch(\PDO::FETCH_ASSOC); + if (!$ev || (int)$ev['created_by'] !== $userId) { + throw new \RuntimeException('Event nicht gefunden.'); + } + $pdo?->prepare('UPDATE events SET status = :st, updated_at = NOW() WHERE id = :id')->execute([ + 'st' => 'cancelled', + 'id' => $eventId, + ]); + $info = 'Event wurde abgesagt.'; + } + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + // Daten laden + $profile = [ + 'display_name' => '', + 'first_name' => '', + 'last_name' => '', + 'zip' => '', + 'city' => '', + 'profession' => '', + 'languages' => '', + 'about' => '', + 'email' => '', + 'contact_phone' => '', + ]; + $stmt = $pdo?->prepare('SELECT u.email, u.status, p.display_name, p.first_name, p.last_name, p.zip, p.city, p.profession, p.languages, p.about, p.contact_phone FROM users u LEFT JOIN user_profiles p ON p.user_id = u.id WHERE u.id = :id LIMIT 1'); + $stmt?->execute(['id' => $userId]); + $row = $stmt?->fetch(\PDO::FETCH_ASSOC); + if ($row) { + $profile = array_merge($profile, array_filter($row, fn($v) => $v !== null)); + if ($crypto && !empty($profile['contact_phone'])) { + $profile['contact_phone'] = $crypto->decrypt((string)$profile['contact_phone']) ?: ''; + } + } + + $children = []; + $stmt = $pdo?->prepare('SELECT id, encrypted_first_name AS first_name, note, gender, birthdate, age_years FROM children WHERE user_id = :id ORDER BY id DESC'); + $stmt?->execute(['id' => $userId]); + $childrenRaw = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; + foreach ($childrenRaw as $c) { + if ($crypto) { + $c['first_name'] = $crypto->decrypt((string)$c['first_name']) ?: ''; + $c['note'] = $crypto->decrypt((string)($c['note'] ?? '')) ?: ''; + } + $children[] = $c; + } + + $eventsUpcoming = []; + $eventsPast = []; + $editEvent = null; + $stmt = $pdo?->prepare( + 'SELECT e.id, e.title, e.teaser_public, e.description, e.location_label, e.street, e.zip, e.city, e.region, e.starts_at, e.allow_kids, e.visibility, e.status, e.lat, e.lng, + (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count + FROM events e + WHERE e.created_by = :id AND e.starts_at >= NOW() + ORDER BY e.starts_at ASC' + ); + $stmt?->execute(['id' => $userId]); + $eventsUpcoming = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; + + $stmt = $pdo?->prepare( + 'SELECT e.id, e.title, e.teaser_public, e.starts_at, e.city, e.visibility, e.status, + (SELECT COUNT(*) FROM event_participants ep WHERE ep.event_id = e.id) AS participant_count + FROM events e + WHERE e.created_by = :id AND e.starts_at < NOW() + ORDER BY e.starts_at DESC' + ); + $stmt?->execute(['id' => $userId]); + $eventsPast = $stmt?->fetchAll(\PDO::FETCH_ASSOC) ?: []; + + if (isset($_GET['edit_event'])) { + $editId = (int)$_GET['edit_event']; + $stmt = $pdo?->prepare('SELECT * FROM events WHERE id = :id AND created_by = :uid AND starts_at >= NOW() LIMIT 1'); + $stmt?->execute(['id' => $editId, 'uid' => $userId]); + $editEvent = $stmt?->fetch(\PDO::FETCH_ASSOC) ?: null; + } + + return compact('flash','info','error','profile','children','eventsUpcoming','eventsPast','editEvent'); + } + + private static function geocodeAddress(?string $street, ?string $zip, ?string $city, ?string $region): array + { + $parts = array_filter([ + $street ?: null, + $zip ?: null, + $city ?: null, + $region ?: null, + ]); + if (!$parts) { + return [null, null]; + } + + $query = implode(', ', $parts); + $url = 'https://nominatim.openstreetmap.org/search?' . http_build_query([ + 'format' => 'jsonv2', + 'limit' => 1, + 'q' => $query, + ]); + + $ctx = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: papa-kind-treff/1.0\r\nAccept-Language: de\r\n", + 'timeout' => 6, + ], + ]); + + $resp = @file_get_contents($url, false, $ctx); + if ($resp === false) { + return [null, null]; + } + $json = json_decode($resp, true); + if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) { + return [null, null]; + } + return [round((float)$json[0]['lat'], 7), round((float)$json[0]['lon'], 7)]; + } +}