Files
alwisp/www/pages/admin-inbox.php
Claude 40e3f73aaf Add contact form DB storage and hidden staff inbox
- contact.php now inserts submissions into MySQL via PDO prepared
  statements; raw values stored (htmlspecialchars moved to output only)
- www/includes/db.php: shared PDO helper with auto-migration that adds
  the is_read column to existing deployments without a full DB reset
- docker/mysql/init.sql: added is_read TINYINT column to contacts table
  for fresh deploys
- www/pages/admin-inbox.php: self-contained staff inbox at /staff-portal
  with session-based password login, per-message mark-as-read, and
  mark-all-read; unread count shown in browser tab title
- index.php: routes /staff-portal before public header/footer so the
  admin page is fully standalone
- docker-compose.yml: ADMIN_PASS env var wired to web container

Set ADMIN_PASS in .env (gitignored) before deploying.
If the DB volume already exists, the auto-migration in db.php will
add the is_read column automatically on first request.

https://claude.ai/code/session_015wpwmheufcxkBuXivrSHhd
2026-03-01 03:05:18 +00:00

240 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* ALWISP Staff Inbox
* Hidden admin page for viewing contact form submissions.
* Access: /staff-portal (not linked anywhere on the public site)
* Password is set via ADMIN_PASS environment variable.
*/
session_start();
$admin_pass = getenv('ADMIN_PASS') ?: '';
// ── Logout ────────────────────────────────────────────────────────────────
if (isset($_GET['logout'])) {
session_destroy();
header('Location: /staff-portal');
exit;
}
// ── Login ─────────────────────────────────────────────────────────────────
$login_error = '';
if (!isset($_SESSION['alwisp_admin'])) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['password'])) {
if ($admin_pass !== '' && hash_equals($admin_pass, $_POST['password'])) {
$_SESSION['alwisp_admin'] = true;
header('Location: /staff-portal');
exit;
}
$login_error = 'Incorrect password.';
}
// Show login gate
http_response_code(200);
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Staff Portal ALWISP</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0a0f1e; color: #e2e8f0; font-family: system-ui, sans-serif;
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.card { background: #111827; border: 1px solid #1e2d4a; border-radius: 12px;
padding: 2.5rem 2rem; width: 100%; max-width: 360px; }
h1 { font-size: 1.25rem; margin-bottom: 1.5rem; color: #fff; }
label { display: block; font-size: .85rem; color: #94a3b8; margin-bottom: .4rem; }
input { width: 100%; padding: .65rem .9rem; border-radius: 8px;
border: 1px solid #1e2d4a; background: #0a0f1e; color: #e2e8f0;
font-size: 1rem; margin-bottom: 1.2rem; }
button { width: 100%; padding: .7rem; border-radius: 8px; border: none;
background: #2563eb; color: #fff; font-size: 1rem; cursor: pointer; }
button:hover { background: #1d4ed8; }
.error { background: #450a0a; color: #fca5a5; border-radius: 8px;
padding: .7rem 1rem; margin-bottom: 1rem; font-size: .9rem; }
</style>
</head>
<body>
<div class="card">
<h1>ALWISP Staff Portal</h1>
<?php if ($login_error): ?>
<div class="error"><?= htmlspecialchars($login_error) ?></div>
<?php endif; ?>
<form method="post">
<label for="pw">Password</label>
<input type="password" id="pw" name="password" autofocus autocomplete="current-password">
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>
<?php
exit;
}
// ── Authenticated handle actions ────────────────────────────────────────
require_once __DIR__ . '/../includes/db.php';
try {
$db = get_db();
} catch (PDOException $e) {
die('<p style="font-family:sans-serif;padding:2rem;color:red">Database connection failed: ' . htmlspecialchars($e->getMessage()) . '</p>');
}
// Mark a single message as read
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mark_read'])) {
$db->prepare("UPDATE contacts SET is_read = 1 WHERE id = ?")->execute([(int)$_POST['mark_read']]);
header('Location: /staff-portal');
exit;
}
// Mark all as read
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mark_all_read'])) {
$db->exec("UPDATE contacts SET is_read = 1");
header('Location: /staff-portal');
exit;
}
// ── Fetch messages ────────────────────────────────────────────────────────
$messages = $db->query("SELECT * FROM contacts ORDER BY created_at DESC")->fetchAll();
$unread_count = (int)$db->query("SELECT COUNT(*) FROM contacts WHERE is_read = 0")->fetchColumn();
$subject_labels = [
'new-service' => 'New Service Inquiry',
'support' => 'Technical Support',
'billing' => 'Billing Question',
'coverage' => 'Coverage Question',
'other' => 'Other',
];
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES); }
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $unread_count ? "($unread_count) " : '' ?>Inbox ALWISP Staff</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0a0f1e; color: #e2e8f0; font-family: system-ui, sans-serif;
min-height: 100vh; padding: 0 0 4rem; }
/* ── Top bar ── */
.topbar { background: #111827; border-bottom: 1px solid #1e2d4a;
display: flex; align-items: center; justify-content: space-between;
padding: .9rem 1.5rem; }
.topbar__brand { font-weight: 700; font-size: 1rem; color: #fff; letter-spacing: .02em; }
.topbar__brand span { color: #3b82f6; }
.topbar__actions { display: flex; gap: .75rem; align-items: center; }
/* ── Buttons ── */
.btn { display: inline-block; padding: .45rem .9rem; border-radius: 7px; border: none;
font-size: .85rem; cursor: pointer; text-decoration: none; }
.btn--primary { background: #2563eb; color: #fff; }
.btn--primary:hover { background: #1d4ed8; }
.btn--ghost { background: transparent; color: #94a3b8; border: 1px solid #1e2d4a; }
.btn--ghost:hover { background: #1e2d4a; color: #e2e8f0; }
.btn--sm { padding: .3rem .65rem; font-size: .78rem; }
/* ── Container ── */
.wrap { max-width: 960px; margin: 2rem auto; padding: 0 1.25rem; }
/* ── Summary bar ── */
.summary { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
.badge { background: #2563eb; color: #fff; border-radius: 999px;
padding: .2rem .65rem; font-size: .8rem; font-weight: 600; }
.badge--zero { background: #1e2d4a; color: #64748b; }
.summary__text { color: #94a3b8; font-size: .9rem; }
/* ── Message cards ── */
.msg { background: #111827; border: 1px solid #1e2d4a; border-radius: 10px;
margin-bottom: 1rem; overflow: hidden; }
.msg--unread { border-color: #2563eb; }
.msg__header { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem;
padding: .9rem 1.1rem; border-bottom: 1px solid #1e2d4a; }
.msg__name { font-weight: 600; color: #fff; }
.msg__email { color: #3b82f6; font-size: .875rem; }
.msg__phone { color: #64748b; font-size: .85rem; }
.msg__tag { background: #1e2d4a; color: #94a3b8; border-radius: 5px;
padding: .15rem .55rem; font-size: .75rem; }
.msg__time { margin-left: auto; color: #64748b; font-size: .8rem; white-space: nowrap; }
.msg__body { padding: .9rem 1.1rem; }
.msg__message { color: #cbd5e1; font-size: .9rem; line-height: 1.65;
white-space: pre-wrap; word-break: break-word; }
.msg__footer { padding: .65rem 1.1rem; border-top: 1px solid #1e2d4a;
display: flex; align-items: center; gap: .5rem; }
.dot-unread { width: 8px; height: 8px; border-radius: 50%; background: #3b82f6;
flex-shrink: 0; }
.dot-read { width: 8px; height: 8px; border-radius: 50%; background: #1e2d4a;
flex-shrink: 0; }
.status-label { font-size: .8rem; color: #64748b; flex: 1; }
/* ── Empty state ── */
.empty { text-align: center; padding: 4rem 1rem; color: #64748b; }
.empty__icon { font-size: 2.5rem; margin-bottom: .75rem; }
</style>
</head>
<body>
<div class="topbar">
<div class="topbar__brand">AL<span>WISP</span> Staff Portal</div>
<div class="topbar__actions">
<?php if ($unread_count > 0): ?>
<form method="post" style="display:inline">
<button name="mark_all_read" value="1" class="btn btn--ghost btn--sm">Mark all read</button>
</form>
<?php endif; ?>
<a href="?logout=1" class="btn btn--ghost btn--sm">Sign out</a>
</div>
</div>
<div class="wrap">
<div class="summary">
<span class="badge <?= $unread_count === 0 ? 'badge--zero' : '' ?>"><?= $unread_count ?> unread</span>
<span class="summary__text"><?= count($messages) ?> total submission<?= count($messages) !== 1 ? 's' : '' ?></span>
</div>
<?php if (empty($messages)): ?>
<div class="empty">
<div class="empty__icon">📭</div>
<p>No messages yet. They'll show up here once someone submits the contact form.</p>
</div>
<?php else: ?>
<?php foreach ($messages as $m): ?>
<?php $unread = !$m['is_read']; ?>
<div class="msg <?= $unread ? 'msg--unread' : '' ?>">
<div class="msg__header">
<span class="msg__name"><?= h($m['name']) ?></span>
<a class="msg__email" href="mailto:<?= h($m['email']) ?>"><?= h($m['email']) ?></a>
<?php if ($m['phone']): ?>
<span class="msg__phone"><?= h($m['phone']) ?></span>
<?php endif; ?>
<?php if ($m['subject']): ?>
<span class="msg__tag"><?= h($subject_labels[$m['subject']] ?? $m['subject']) ?></span>
<?php endif; ?>
<span class="msg__time"><?= date('M j, Y g:ia', strtotime($m['created_at'])) ?></span>
</div>
<div class="msg__body">
<p class="msg__message"><?= h($m['message']) ?></p>
</div>
<div class="msg__footer">
<span class="<?= $unread ? 'dot-unread' : 'dot-read' ?>"></span>
<span class="status-label"><?= $unread ? 'Unread' : 'Read' ?></span>
<?php if ($unread): ?>
<form method="post">
<input type="hidden" name="mark_read" value="<?= (int)$m['id'] ?>">
<button class="btn btn--primary btn--sm">Mark as read</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</body>
</html>