diff --git a/admin.php b/admin.php index a23d3e7..7298848 100644 --- a/admin.php +++ b/admin.php @@ -1,11 +1,9 @@ 0 ? '?webinar_id=' . u

Webinar registrations dashboard

-

Welcome, . Review attendee data, edit registrations, export CSV, and monitor daily signup volume.

+

Welcome, . Review attendee data, edit registrations, export CSV, and monitor daily signup volume.

@@ -368,6 +362,8 @@ $export_link = 'export_csv.php' . ($selected_webinar_id > 0 ? '?webinar_id=' . u
Download CSV + View site + Log out
diff --git a/db/ensure_schema.php b/db/ensure_schema.php index 796dade..cdd61a5 100644 --- a/db/ensure_schema.php +++ b/db/ensure_schema.php @@ -34,6 +34,15 @@ function ensure_app_schema(PDO $pdo): void { created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); + $pdo->exec('CREATE TABLE IF NOT EXISTS admin_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL DEFAULT "Admin", + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_admin_users_email (email) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'); + $pdo->exec('CREATE TABLE IF NOT EXISTS attendees ( id INT AUTO_INCREMENT PRIMARY KEY, webinar_id INT NOT NULL, diff --git a/db/migrations/012_create_admin_users_table.sql b/db/migrations/012_create_admin_users_table.sql new file mode 100644 index 0000000..1ba40a2 --- /dev/null +++ b/db/migrations/012_create_admin_users_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS admin_users ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL DEFAULT 'Admin', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uniq_admin_users_email (email) +); diff --git a/delete_attendee.php b/delete_attendee.php index 25495cd..7f7a85e 100644 --- a/delete_attendee.php +++ b/delete_attendee.php @@ -1,8 +1,9 @@ execute([$id]); if ($stmt->rowCount() > 0) { - $_SESSION['message'] = "Attendee #{$id} has been archived successfully."; + admin_set_flash("Attendee #{$id} has been archived successfully."); } else { - $_SESSION['message'] = "No active attendee found for ID #{$id}."; + admin_set_flash("No active attendee found for ID #{$id}."); } } else { - $_SESSION['message'] = 'Error: Invalid archive request.'; + admin_set_flash('Error: Invalid archive request.'); } header('Location: admin.php'); diff --git a/edit_attendee.php b/edit_attendee.php index 4c9a4dd..071da7b 100644 --- a/edit_attendee.php +++ b/edit_attendee.php @@ -1,142 +1,143 @@ query('SELECT id, title FROM webinars ORDER BY scheduled_at DESC, id DESC')->fetchAll(PDO::FETCH_ASSOC); if ($_SERVER['REQUEST_METHOD'] === 'POST') { - // Update logic - $fields = ['first_name', 'last_name', 'email', 'company', 'how_did_you_hear', 'consented']; - $sql = 'UPDATE attendees SET '; - $params = []; - foreach ($fields as $field) { - if (isset($_POST[$field])) { - $sql .= "$field = ?, "; - $params[] = $_POST[$field]; - } - } - $sql = rtrim($sql, ', ') . ' WHERE id = ?'; - $params[] = $_POST['id']; + $webinarId = max(1, (int) ($_POST['webinar_id'] ?? 1)); + $firstName = trim((string) ($_POST['first_name'] ?? '')); + $lastName = trim((string) ($_POST['last_name'] ?? '')); + $email = strtolower(trim((string) ($_POST['email'] ?? ''))); + $company = trim((string) ($_POST['company'] ?? '')); + $timezone = trim((string) ($_POST['timezone'] ?? '')); + $howDidYouHear = trim((string) ($_POST['how_did_you_hear'] ?? '')); + $consented = !empty($_POST['consented']) ? 1 : 0; - $stmt = $pdo->prepare($sql); - $stmt->execute($params); + if ($firstName === '' || $lastName === '') { + throw new RuntimeException('First name and last name are required.'); + } + if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Enter a valid email address.'); + } + if ($timezone !== '' && !in_array($timezone, timezone_identifiers_list(), true)) { + throw new RuntimeException('Timezone must be a valid IANA timezone, for example Europe/Berlin or America/New_York.'); + } + + $webinarCheck = $pdo->prepare('SELECT COUNT(*) FROM webinars WHERE id = ?'); + $webinarCheck->execute([$webinarId]); + if ((int) $webinarCheck->fetchColumn() === 0) { + throw new RuntimeException('Selected webinar was not found.'); + } + + $update = $pdo->prepare('UPDATE attendees SET webinar_id = ?, first_name = ?, last_name = ?, email = ?, company = ?, timezone = ?, how_did_you_hear = ?, consented = ? WHERE id = ?'); + $update->execute([$webinarId, $firstName, $lastName, $email, $company !== '' ? $company : null, $timezone !== '' ? $timezone : null, $howDidYouHear !== '' ? $howDidYouHear : null, $consented, $id]); + + admin_set_flash('Attendee #' . $id . ' was updated successfully.'); header('Location: admin.php'); exit; } - // Fetch logic - $stmt = $pdo->prepare('SELECT * FROM attendees WHERE id = ?'); + $stmt = $pdo->prepare('SELECT * FROM attendees WHERE id = ? LIMIT 1'); $stmt->execute([$id]); $attendee = $stmt->fetch(PDO::FETCH_ASSOC); if (!$attendee) { + admin_set_flash('Attendee not found.'); header('Location: admin.php'); exit; } - +} catch (RuntimeException $e) { + admin_set_flash($e->getMessage()); + header('Location: edit_attendee.php?id=' . urlencode((string) $id)); + exit; } catch (PDOException $e) { - die("Database error: " . $e->getMessage()); + error_log('Edit attendee error: ' . $e->getMessage()); + admin_set_flash('Unable to load or save attendee changes right now.'); + header('Location: admin.php'); + exit; } +$message = admin_get_flash(); ?> - Edit Attendee + Edit attendee | Webinar admin + + -
-

Edit Attendee #

-
- -
- - +
+
+

Edit attendee #

+

Update registration details safely. Changes here affect the admin table, CSV export, and registration analytics.

+ + +
+ + +
+
+
Registered at
+
+
+
+
Current timezone
+
+
+
+
Lead source
+
+
-
- - -
-
- - -
-
- - -
-
- - -
-
- - > - -
- - Cancel - -
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + > + +
+
+
+
+ + Back to admin + Log out +
+
+ +
diff --git a/export_csv.php b/export_csv.php index e090c5a..44a81ac 100644 --- a/export_csv.php +++ b/export_csv.php @@ -1,8 +1,9 @@ execute(); $attendees = $stmt->fetchAll(PDO::FETCH_ASSOC); header('Content-Type: text/csv; charset=utf-8'); -header('Content-Disposition: attachment; filename=attendees.csv'); + +$filename = 'attendees'; +if ($selected_webinar_id > 0) { + $filename .= '-webinar-' . $selected_webinar_id; +} +$filename .= '-' . date('Y-m-d') . '.csv'; +header('Content-Disposition: attachment; filename=' . $filename); $output = fopen('php://output', 'w'); fputs($output, "\xEF\xBB\xBF"); diff --git a/includes/admin_auth.php b/includes/admin_auth.php new file mode 100644 index 0000000..768a4d7 --- /dev/null +++ b/includes/admin_auth.php @@ -0,0 +1,70 @@ + 0 && (($_SESSION['user'] ?? null) === 'admin'); +} + +function admin_require_login(): void { + if (!admin_is_logged_in()) { + header('Location: login.php'); + exit; + } +} + +function admin_logout(): void { + admin_session_start_if_needed(); + unset($_SESSION['admin_user_id'], $_SESSION['admin_email'], $_SESSION['admin_name'], $_SESSION['user']); +} + +function admin_set_flash(string $message): void { + admin_session_start_if_needed(); + $_SESSION['message'] = $message; +} + +function admin_get_flash(): string { + admin_session_start_if_needed(); + $message = isset($_SESSION['message']) ? (string) $_SESSION['message'] : ''; + unset($_SESSION['message']); + return $message; +} + +function admin_count_users(): int { + $stmt = db()->query('SELECT COUNT(*) FROM admin_users'); + return (int) $stmt->fetchColumn(); +} + +function admin_get_by_email(string $email): ?array { + $stmt = db()->prepare('SELECT id, email, password_hash, display_name, created_at FROM admin_users WHERE email = ? LIMIT 1'); + $stmt->execute([$email]); + $admin = $stmt->fetch(PDO::FETCH_ASSOC); + return $admin ?: null; +} + +function admin_create_user(string $email, string $password, string $displayName = 'Admin'): int { + $passwordHash = password_hash($password, PASSWORD_DEFAULT); + $stmt = db()->prepare('INSERT INTO admin_users (email, password_hash, display_name) VALUES (?, ?, ?)'); + $stmt->execute([$email, $passwordHash, $displayName]); + return (int) db()->lastInsertId(); +} + +function admin_login_user(array $admin): void { + admin_session_start_if_needed(); + $_SESSION['user'] = 'admin'; + $_SESSION['admin_user_id'] = (int) $admin['id']; + $_SESSION['admin_email'] = (string) $admin['email']; + $_SESSION['admin_name'] = (string) ($admin['display_name'] ?? 'Admin'); +} + +function admin_current_name(): string { + admin_session_start_if_needed(); + return (string) ($_SESSION['admin_name'] ?? 'Admin'); +} diff --git a/login.php b/login.php index 8fafe3e..b1118c7 100644 --- a/login.php +++ b/login.php @@ -1,39 +1,109 @@ prepare("SELECT * FROM attendees WHERE email = ?"); - $stmt->execute([$email]); - $attendee = $stmt->fetch(); +try { + $adminCount = admin_count_users(); +} catch (Throwable $e) { + error_log('Admin bootstrap error: ' . $e->getMessage()); + $adminCount = 0; + $adminError = 'Unable to load admin access right now. Please try again in a moment.'; +} - if ($attendee && password_verify($password, $attendee['password'])) { - $_SESSION['user_id'] = $attendee['id']; - header('Location: dashboard.php'); - exit; +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $loginType = $_POST['login_type'] ?? ''; + + if ($loginType === 'admin_setup' && $adminCount === 0) { + $displayName = trim((string) ($_POST['display_name'] ?? '')); + $email = strtolower(trim((string) ($_POST['email'] ?? ''))); + $password = (string) ($_POST['password'] ?? ''); + $passwordConfirm = (string) ($_POST['password_confirm'] ?? ''); + + if ($displayName === '') { + $adminError = 'Enter an admin name to finish setup.'; + } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $adminError = 'Enter a valid admin email address.'; + } elseif (strlen($password) < 10) { + $adminError = 'Admin password must be at least 10 characters.'; + } elseif ($password !== $passwordConfirm) { + $adminError = 'Admin passwords do not match.'; } else { - $error = 'Invalid email or password. Would you like to register for the webinar?'; + try { + $newAdminId = admin_create_user($email, $password, $displayName); + admin_login_user([ + 'id' => $newAdminId, + 'email' => $email, + 'display_name' => $displayName, + ]); + admin_set_flash('Admin access has been created successfully.'); + header('Location: admin.php'); + exit; + } catch (PDOException $e) { + error_log('Admin setup failed: ' . $e->getMessage()); + $adminError = 'Could not create the admin user. Please try a different email.'; + } + } + } + + if ($loginType === 'admin_login' && $adminCount > 0) { + $email = strtolower(trim((string) ($_POST['email'] ?? ''))); + $password = (string) ($_POST['password'] ?? ''); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') { + $adminError = 'Enter your admin email and password.'; + } else { + try { + $admin = admin_get_by_email($email); + if ($admin && password_verify($password, $admin['password_hash'])) { + admin_login_user($admin); + header('Location: admin.php'); + exit; + } + $adminError = 'Invalid admin email or password.'; + } catch (PDOException $e) { + error_log('Admin login failed: ' . $e->getMessage()); + $adminError = 'Admin login is temporarily unavailable.'; + } + } + } + + if ($loginType === 'participant_login') { + $email = strtolower(trim((string) ($_POST['email'] ?? ''))); + $password = (string) ($_POST['password'] ?? ''); + + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') { + $participantError = 'Enter your registration email and password.'; + } else { + try { + $stmt = db()->prepare('SELECT * FROM attendees WHERE email = ? AND deleted_at IS NULL LIMIT 1'); + $stmt->execute([$email]); + $attendee = $stmt->fetch(PDO::FETCH_ASSOC); + + if ($attendee && !empty($attendee['password']) && password_verify($password, $attendee['password'])) { + $_SESSION['user_id'] = (int) $attendee['id']; + header('Location: dashboard.php'); + exit; + } + + $participantError = 'Invalid email or password. If you have not registered yet, please use the webinar form.'; + } catch (PDOException $e) { + error_log('Participant login failed: ' . $e->getMessage()); + $participantError = 'Participant login is temporarily unavailable.'; + } } - } catch (PDOException $e) { - error_log($e->getMessage()); - $error = 'A database error occurred. Please try again later.'; } } ?> @@ -42,7 +112,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['email'])) { - Login - Webinar Platform + Login | Webinar Platform + + @@ -50,191 +122,289 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['email'])) { :root { --primary-color: #2c4bd1; --primary-hover: #1029a0; - --background-start: #0b2083; + --background-start: #071659; --background-end: #1029a0; - --card-background: rgba(9, 24, 47, 0.84); + --card-background: rgba(9, 24, 47, 0.8); + --card-soft: rgba(221, 226, 253, 0.08); --text-color: #f3f9ff; --muted-text: #bbc8fb; --input-border: rgba(221, 226, 253, 0.18); - --input-focus-border: #2c4bd1; - --error-color: #ff8ea0; + --input-focus-border: #6ed4ff; + --error-color: #ff99a9; --success-color: #18d1b3; --font-body: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font-display: "Space Grotesk", "Inter", system-ui, sans-serif; --button-shadow: 0 18px 36px rgba(11, 32, 131, 0.34); } - * { - box-sizing: border-box; - } + * { box-sizing: border-box; } body { font-family: var(--font-body); background: - radial-gradient(circle at top left, rgba(221, 226, 253, 0.22), transparent 28%), + radial-gradient(circle at top left, rgba(221, 226, 253, 0.22), transparent 24%), + radial-gradient(circle at bottom right, rgba(110, 212, 255, 0.12), transparent 28%), linear-gradient(135deg, var(--background-start) 0%, #1029a0 55%, var(--background-end) 100%); color: var(--text-color); margin: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; min-height: 100vh; + padding: 32px 20px; } - .logo { - font-family: var(--font-display); - font-weight: 700; - font-size: 28px; - margin-bottom: 1.5rem; - color: #bbc8fb; - letter-spacing: -0.02em; - } - .login-container { + .shell { width: 100%; - max-width: 440px; - padding: 2rem; + max-width: 1160px; + margin: 0 auto; } - .login-card { - background-color: var(--card-background); - border-radius: 22px; - padding: 2.5rem; - box-shadow: 0 24px 60px rgba(3, 11, 25, 0.4); - border: 1px solid var(--input-border); - backdrop-filter: blur(16px); - } - h1 { - font-family: var(--font-display); - font-size: 30px; - font-weight: 700; - letter-spacing: -0.04em; - margin-top: 0; - margin-bottom: 0.5rem; - text-align: center; - } - .subtitle { + .brand { text-align: center; margin-bottom: 2rem; + } + .brand-name { + font-family: var(--font-display); + font-size: 32px; + font-weight: 700; + letter-spacing: -0.04em; + margin: 0 0 0.5rem; + } + .brand-copy { color: var(--muted-text); - font-size: 0.98rem; - font-weight: 500; + max-width: 700px; + margin: 0 auto; + } + .grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + align-items: start; + } + .card { + background: var(--card-background); + border-radius: 24px; + padding: 28px; + border: 1px solid var(--input-border); + box-shadow: 0 24px 60px rgba(3, 11, 25, 0.35); + backdrop-filter: blur(16px); + } + .eyebrow { + display: inline-block; + padding: 7px 12px; + border-radius: 999px; + background: rgba(110, 212, 255, 0.12); + color: #d9f6ff; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: 0.9rem; + } + h1, h2 { + font-family: var(--font-display); + letter-spacing: -0.04em; + margin-top: 0; + } + h1 { font-size: 40px; margin-bottom: 0.85rem; } + h2 { font-size: 26px; margin-bottom: 0.6rem; } + .subtitle, .helper, .notice { + color: var(--muted-text); + line-height: 1.6; + } + .notice { + background: var(--card-soft); + border: 1px solid rgba(221, 226, 253, 0.12); + border-radius: 16px; + padding: 14px 16px; + margin-bottom: 1rem; + } + .status { + padding: 12px 14px; + border-radius: 14px; + margin-bottom: 1rem; + font-size: 0.95rem; + } + .status.error { + background: rgba(255, 153, 169, 0.12); + border: 1px solid rgba(255, 153, 169, 0.25); + color: #ffd7de; + } + .status.success { + background: rgba(24, 209, 179, 0.12); + border: 1px solid rgba(24, 209, 179, 0.24); + color: #d7fff6; + } + .form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; } .form-group { - margin-bottom: 1.25rem; + margin-bottom: 1rem; + } + .form-group.full { + grid-column: 1 / -1; } label { display: block; - margin-bottom: 0.55rem; + margin-bottom: 0.45rem; font-weight: 600; - font-size: 0.85rem; - letter-spacing: 0.02em; - color: var(--text-color); + font-size: 0.9rem; } - input[type="email"], input[type="password"] { + input { width: 100%; padding: 13px 14px; border: 1px solid var(--input-border); border-radius: 12px; - box-sizing: border-box; font-size: 16px; - transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; background: rgba(221, 226, 253, 0.08); color: var(--text-color); } - input[type="email"]::placeholder, input[type="password"]::placeholder { - color: var(--muted-text); - } - input[type="email"]:focus, input[type="password"]:focus { + input::placeholder { color: var(--muted-text); } + input:focus { outline: none; border-color: var(--input-focus-border); - box-shadow: 0 0 0 3px rgba(187, 200, 251, 0.18); - background: rgba(221, 226, 253, 0.10); + box-shadow: 0 0 0 3px rgba(110, 212, 255, 0.14); + background: rgba(221, 226, 253, 0.12); } - .submit-button { + .btn { width: 100%; - padding: 14px; border: 1px solid rgba(221, 226, 253, 0.16); + border-radius: 14px; + padding: 14px 18px; background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-hover) 100%); - color: white; + color: #fff; font-family: var(--font-display); font-weight: 700; - font-size: 0.96rem; - letter-spacing: 0.04em; - text-transform: uppercase; - border-radius: 15px; + font-size: 1rem; cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease; box-shadow: var(--button-shadow); } - .submit-button:hover { - transform: translateY(-1px); - box-shadow: 0 22px 38px rgba(11, 32, 131, 0.4); - filter: brightness(1.05); + .btn:hover { transform: translateY(-1px); } + .actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 1rem; } - .message { - padding: 1rem; - margin-bottom: 1.5rem; - border-radius: 12px; - text-align: center; - font-size: 14px; + .text-link { + color: #d7ecff; + text-decoration: none; + font-weight: 600; } - .error { - background-color: rgba(255, 142, 160, 0.12); - color: #ffdbe2; - border: 1px solid rgba(255, 142, 160, 0.34); - } - .success { - background-color: rgba(24, 209, 179, 0.12); - color: #dcfff8; - border: 1px solid rgba(24, 209, 179, 0.34); - } - .footer-link { - text-align: center; - margin-top: 1.5rem; - font-size: 14px; + .text-link:hover { text-decoration: underline; } + ul.feature-list { + margin: 1rem 0 0; + padding-left: 1.2rem; color: var(--muted-text); } - .footer-link a, - .back-link a { - font-family: var(--font-display); - letter-spacing: 0.01em; + ul.feature-list li { margin-bottom: 0.55rem; } + @media (max-width: 900px) { + .grid { grid-template-columns: 1fr; } } - .footer-link a { - color: var(--primary-color); - text-decoration: none; - font-weight: 500; - } - .footer-link a:hover { - text-decoration: underline; + @media (max-width: 640px) { + h1 { font-size: 32px; } + .card { padding: 22px; } + .form-grid { grid-template-columns: 1fr; } } -
- -
-

Welcome Back

-

Login to access your dashboard

+
+
+
Webinar access
+

Secure login for your webinar workspace

+

Use the admin area to manage registrations, edit attendee data, export CSV, and track daily signup trends. Attendees can still log in separately to view their webinar dashboard.

+
-
-
+
+
+
Admin
+

+

+ +

-
-
- - + +
+ + +
+ + + +
First-run setup is only shown until one admin account exists. After that, access is password-hashed and database-backed.
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+ +
+ + +
+
+ + +
+ +
+ + +
    +
  • See all webinar registrations in one place.
  • +
  • Edit attendee details and archive old records safely.
  • +
  • Export filtered data to CSV and watch the daily chart update automatically.
  • +
+
+ +
- -
+ +

Tip: if you changed static assets recently and do not see updates, hard refresh the page with Ctrl/Cmd + Shift + R.

+ + + - \ No newline at end of file +