Initial commit — Rosary Presenter App

Full source for loveandrosary.com: slide-based Rosary/novena/Divine Mercy
Chaplet presentation tool with multi-user roles, SVG bead ring, audio uploads,
donate strip, and public session profiles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:44:08 -07:00
commit 663fde3909
46 changed files with 10902 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
<?php
/**
* admin/audio.php — Manage pre-recorded prayer audio files.
* Admin only. Lists all known prayer keys grouped by category.
* Each row shows upload status and allows upload / delete.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_auth();
if (!has_role('admin')) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
$user = current_user();
$site_name = get_setting('site_name', APP_NAME);
// Scan uploads/audio/ to build a map of key → extension
$audio_dir = UPLOADS_DIR . 'audio/';
$uploaded_files = [];
if (is_dir($audio_dir)) {
foreach (glob($audio_dir . '*.*') ?: [] as $f) {
$base = basename($f);
$dot = strrpos($base, '.');
if ($dot !== false) {
$k = substr($base, 0, $dot);
$e = strtolower(substr($base, $dot + 1));
if (preg_match('/^[a-z0-9_]+$/', $k)) {
$uploaded_files[$k] = $e;
}
}
}
}
// All known prayer audio keys grouped by category
$AUDIO_KEYS = [
'Common Prayers' => [
'sign_of_cross' => 'Sign of the Cross',
'apostles_creed' => 'Apostles\' Creed',
'our_father' => 'Our Father (all)',
'hail_mary' => 'Hail Mary (all)',
'glory_be' => 'Glory Be (all)',
'fatima_prayer' => 'Fatima Prayer',
'hail_holy_queen' => 'Hail Holy Queen',
'rosary_closing_prayer' => 'Rosary Closing Prayer',
'closing' => 'Closing Slide',
],
'Sorrowful Mysteries' => [
'mystery_sorrowful_1' => '1st Mystery — Agony in the Garden',
'mystery_sorrowful_2' => '2nd Mystery — Scourging at the Pillar',
'mystery_sorrowful_3' => '3rd Mystery — Crowning with Thorns',
'mystery_sorrowful_4' => '4th Mystery — Carrying of the Cross',
'mystery_sorrowful_5' => '5th Mystery — Crucifixion and Death',
],
'Joyful Mysteries' => [
'mystery_joyful_1' => '1st Mystery — The Annunciation',
'mystery_joyful_2' => '2nd Mystery — The Visitation',
'mystery_joyful_3' => '3rd Mystery — The Nativity',
'mystery_joyful_4' => '4th Mystery — The Presentation',
'mystery_joyful_5' => '5th Mystery — Finding in the Temple',
],
'Glorious Mysteries' => [
'mystery_glorious_1' => '1st Mystery — The Resurrection',
'mystery_glorious_2' => '2nd Mystery — The Ascension',
'mystery_glorious_3' => '3rd Mystery — Descent of the Holy Spirit',
'mystery_glorious_4' => '4th Mystery — The Assumption',
'mystery_glorious_5' => '5th Mystery — Coronation of Mary',
],
'Luminous Mysteries' => [
'mystery_luminous_1' => '1st Mystery — Baptism of Jesus',
'mystery_luminous_2' => '2nd Mystery — Wedding at Cana',
'mystery_luminous_3' => '3rd Mystery — Proclamation of the Kingdom',
'mystery_luminous_4' => '4th Mystery — The Transfiguration',
'mystery_luminous_5' => '5th Mystery — Institution of the Eucharist',
],
'Novena for Deceased' => [
'novena_day_1' => 'Day 1 — Novena Prayer',
'novena_day_2' => 'Day 2 — Novena Prayer',
'novena_day_3' => 'Day 3 — Novena Prayer',
'novena_day_4' => 'Day 4 — Novena Prayer',
'novena_day_5' => 'Day 5 — Novena Prayer',
'novena_day_6' => 'Day 6 — Novena Prayer',
'novena_day_7' => 'Day 7 — Novena Prayer',
'novena_day_8' => 'Day 8 — Novena Prayer',
'novena_day_9' => 'Day 9 — Novena Prayer',
'litany_passion_intro' => 'Litany of Passion — Intro',
'litany_passion_2' => 'Litany of Passion — Entry 2',
'litany_passion_3' => 'Litany of Passion — Entry 3',
'litany_passion_4' => 'Litany of Passion — Entry 4',
'litany_passion_5' => 'Litany of Passion — Entry 5',
'litany_passion_6' => 'Litany of Passion — Entry 6',
'litany_passion_7' => 'Litany of Passion — Entry 7',
'litany_passion_8' => 'Litany of Passion — Entry 8',
'litany_passion_9' => 'Litany of Passion — Entry 9',
'litany_passion_10' => 'Litany of Passion — Entry 10',
'litany_passion_11' => 'Litany of Passion — Entry 11',
'litany_departed_kyrie' => 'Litany for Departed — Kyrie',
'litany_departed_christe' => 'Litany for Departed — Christe',
'litany_departed_lord' => 'Litany for Departed — Lord',
'litany_departed_mary' => 'Litany for Departed — Mary',
'litany_departed_michael' => 'Litany for Departed — Michael',
'litany_departed_angels' => 'Litany for Departed — Angels',
'litany_departed_john' => 'Litany for Departed — John the Baptist',
'litany_departed_joseph' => 'Litany for Departed — Joseph',
'litany_departed_peter_paul' => 'Litany for Departed — Peter & Paul',
'litany_departed_all_saints' => 'Litany for Departed — All Saints',
'litany_departed_deliver_death' => 'Litany for Departed — Deliver from Death',
'litany_departed_deliver_sin' => 'Litany for Departed — Deliver from Sin',
'litany_departed_deliver_judgment' => 'Litany for Departed — Deliver from Judgment',
'litany_departed_agnus_1' => 'Litany for Departed — Agnus Dei 1',
'litany_departed_agnus_2' => 'Litany for Departed — Agnus Dei 2',
'litany_departed_agnus_3' => 'Litany for Departed — Agnus Dei 3',
'litany_departed_eternal_rest_1' => 'Eternal Rest (part 1)',
'litany_departed_eternal_rest_2' => 'Eternal Rest (part 2)',
'litany_departed_concluding' => 'Concluding Prayer',
],
'Divine Mercy Novena' => [
'dm_opening' => 'Opening Prayer',
'dm_blood_water' => 'O Blood and Water (×3)',
'dm_eternal_father' => 'Eternal Father (chaplet)',
'dm_for_sake' => 'For the Sake of His Sorrowful Passion (×10)',
'dm_holy_god' => 'Holy God (×3)',
'dm_intention_day_1' => 'Day 1 — Jesus\' Intention',
'dm_prayer_day_1' => 'Day 1 — Day Prayer',
'dm_intention_day_2' => 'Day 2 — Jesus\' Intention',
'dm_prayer_day_2' => 'Day 2 — Day Prayer',
'dm_intention_day_3' => 'Day 3 — Jesus\' Intention',
'dm_prayer_day_3' => 'Day 3 — Day Prayer',
'dm_intention_day_4' => 'Day 4 — Jesus\' Intention',
'dm_prayer_day_4' => 'Day 4 — Day Prayer',
'dm_intention_day_5' => 'Day 5 — Jesus\' Intention',
'dm_prayer_day_5' => 'Day 5 — Day Prayer',
'dm_intention_day_6' => 'Day 6 — Jesus\' Intention',
'dm_prayer_day_6' => 'Day 6 — Day Prayer',
'dm_intention_day_7' => 'Day 7 — Jesus\' Intention',
'dm_prayer_day_7' => 'Day 7 — Day Prayer',
'dm_intention_day_8' => 'Day 8 — Jesus\' Intention',
'dm_prayer_day_8' => 'Day 8 — Day Prayer',
'dm_intention_day_9' => 'Day 9 — Jesus\' Intention',
'dm_prayer_day_9' => 'Day 9 — Day Prayer',
],
];
$total_keys = array_sum(array_map('count', $AUDIO_KEYS));
$uploaded_count = 0;
foreach ($AUDIO_KEYS as $keys) {
foreach (array_keys($keys) as $k) {
if (isset($uploaded_files[$k])) $uploaded_count++;
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title>Audio — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<style>
.audio-summary { display:flex; gap:24px; margin-bottom:28px; flex-wrap:wrap; }
.audio-summary-stat { background:#fff; border:1px solid #e5e7eb; border-radius:8px; padding:16px 24px; }
.audio-summary-stat .num { font-size:32px; font-weight:800; color:#1e3a5f; line-height:1; }
.audio-summary-stat .lbl { font-size:13px; color:#6b7280; margin-top:4px; }
.audio-section { margin-bottom:32px; }
.audio-section h3 { font-size:16px; font-weight:700; color:#1e3a5f; margin:0 0 12px;
border-bottom:2px solid #c9973d; padding-bottom:8px; display:inline-block; }
.audio-table { width:100%; border-collapse:collapse; font-size:14px; }
.audio-table th { text-align:left; padding:8px 12px; background:#f9fafb;
font-weight:600; border-bottom:2px solid #e5e7eb; }
.audio-table td { padding:8px 12px; border-bottom:1px solid #f3f4f6; vertical-align:middle; }
.audio-table tr:last-child td { border-bottom:none; }
.status-uploaded { color:#15803d; font-weight:600; font-size:13px; }
.status-missing { color:#9ca3af; font-size:13px; }
.file-ext { background:#e0f2fe; color:#0369a1; padding:2px 7px; border-radius:4px;
font-size:11px; font-weight:700; text-transform:uppercase; margin-left:6px; }
.upload-btn { font-size:13px; padding:5px 12px; }
.delete-btn { font-size:13px; padding:5px 10px; margin-left:6px; }
.key-code { font-family:monospace; font-size:12px; color:#6b7280; }
.help-note { background:#f0f9ff; border:1px solid #bae6fd; border-radius:8px;
padding:16px 20px; font-size:14px; color:#0c4a6e; margin-bottom:28px; }
.help-note strong { display:block; margin-bottom:6px; }
</style>
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<?php if (has_role('admin')): ?>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<?php endif; ?>
<?php if (has_role('superadmin')): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">&#x2190; Dashboard</a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<h2 style="margin-bottom:8px">Prayer Audio</h2>
<p style="color:#6b7280;margin:0 0 24px">
Upload pre-recorded audio for each prayer. A 🔊 toggle appears in the presenter when audio is available.
</p>
<div class="help-note">
<strong>How it works</strong>
Accepted formats: MP3, M4A, OGG, WAV &nbsp;&bull;&nbsp; Max file size: 50 MB per file.<br>
Prayers that repeat (e.g. Our Father, Hail Mary, Glory Be) share a single recording — upload once and it plays on every occurrence.<br>
Uploading a new file for a key automatically replaces the old one.
</div>
<div class="audio-summary">
<div class="audio-summary-stat">
<div class="num"><?= $uploaded_count ?></div>
<div class="lbl">of <?= $total_keys ?> prayers recorded</div>
</div>
</div>
<div id="upload-msg" class="alert" style="display:none;margin-bottom:16px"></div>
<?php foreach ($AUDIO_KEYS as $category => $keys): ?>
<div class="audio-section">
<h3><?= htmlspecialchars($category) ?></h3>
<div class="sessions-table-wrap">
<table class="audio-table">
<thead>
<tr>
<th style="width:38%">Prayer</th>
<th style="width:28%">Key</th>
<th style="width:16%">Status</th>
<th style="width:18%">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($keys as $key => $label):
$has = isset($uploaded_files[$key]);
$ext = $has ? $uploaded_files[$key] : null;
?>
<tr id="row-<?= $key ?>">
<td><?= htmlspecialchars($label) ?></td>
<td><span class="key-code"><?= htmlspecialchars($key) ?></span></td>
<td>
<?php if ($has): ?>
<span class="status-uploaded">&#x2713; Uploaded<span class="file-ext"><?= $ext ?></span></span>
<?php else: ?>
<span class="status-missing">&#8212; Not uploaded</span>
<?php endif; ?>
</td>
<td>
<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">
<?= $has ? 'Replace' : 'Upload' ?>
<input type="file" style="display:none" accept="audio/*"
data-key="<?= htmlspecialchars($key) ?>">
</label>
<?php if ($has): ?>
<button class="btn btn-sm btn-danger delete-btn"
data-key="<?= htmlspecialchars($key) ?>">Delete</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
</main>
</div>
<script>
(function () {
'use strict';
var msgBox = document.getElementById('upload-msg');
function showMsg(type, text) {
msgBox.className = 'alert alert-' + type;
msgBox.textContent = text;
msgBox.style.display = '';
setTimeout(function () { msgBox.style.display = 'none'; }, 4000);
}
function updateRow(key, ext) {
var row = document.getElementById('row-' + key);
if (!row) return;
var statusCell = row.cells[2];
var actionsCell = row.cells[3];
if (ext) {
statusCell.innerHTML =
'<span class="status-uploaded">&#x2713; Uploaded' +
'<span class="file-ext">' + ext + '</span></span>';
// Rebuild actions cell
actionsCell.innerHTML =
'<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">' +
'Replace<input type="file" style="display:none" accept="audio/*" data-key="' + key + '"></label>' +
' <button class="btn btn-sm btn-danger delete-btn" data-key="' + key + '">Delete</button>';
} else {
statusCell.innerHTML = '<span class="status-missing">&#8212; Not uploaded</span>';
actionsCell.innerHTML =
'<label class="btn btn-sm btn-secondary upload-btn" style="cursor:pointer;display:inline-block">' +
'Upload<input type="file" style="display:none" accept="audio/*" data-key="' + key + '"></label>';
}
// Re-attach listeners on rebuilt elements
wireRow(row);
}
function wireRow(row) {
var fileInput = row.querySelector('input[type="file"]');
if (fileInput) {
fileInput.addEventListener('change', handleUpload);
}
var delBtn = row.querySelector('.delete-btn');
if (delBtn) {
delBtn.addEventListener('click', handleDelete);
}
}
function handleUpload() {
var key = this.dataset.key;
var file = this.files[0];
if (!file) return;
var label = this.closest('label');
if (label) { label.textContent = 'Uploading\u2026'; }
var fd = new FormData();
fd.append('key', key);
fd.append('audio', file);
fetch(BASE_URL + '/api/upload_audio.php', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showMsg('error', 'Upload failed: ' + data.error);
updateRow(key, null); // reset UI
} else {
showMsg('success', '\u2713 Uploaded: ' + key + '.' + data.ext);
updateRow(key, data.ext);
}
})
.catch(function () {
showMsg('error', 'Network error during upload.');
updateRow(key, null);
});
}
function handleDelete() {
var key = this.dataset.key;
if (!confirm('Delete audio for "' + key + '"?')) return;
var fd = new FormData();
fd.append('key', key);
fetch(BASE_URL + '/api/delete_audio.php', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.error) {
showMsg('error', 'Delete failed: ' + data.error);
} else {
showMsg('success', 'Deleted: ' + key);
updateRow(key, null);
}
})
.catch(function () { showMsg('error', 'Network error.'); });
}
// Wire all rows on load
document.querySelectorAll('tr[id^="row-"]').forEach(function (row) {
wireRow(row);
});
}());
</script>
</body>
</html>
+247
View File
@@ -0,0 +1,247 @@
<?php
/**
* admin/index.php — Dashboard. Shows sessions for current user (or all for admin+).
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_auth();
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$is_admin = has_role('admin');
$site_name = get_setting('site_name', APP_NAME);
// Handle deletions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['delete_group_id'])) {
$gid = (int)$_POST['delete_group_id'];
// Verify ownership or admin
if ($is_admin) {
$pdo->prepare('DELETE FROM sessions WHERE novena_group_id = ?')->execute([$gid]);
$pdo->prepare('DELETE FROM novena_groups WHERE id = ?')->execute([$gid]);
} else {
$pdo->prepare('DELETE FROM sessions WHERE novena_group_id = ? AND (SELECT user_id FROM novena_groups WHERE id = ?) = ?')
->execute([$gid, $gid, $uid]);
$pdo->prepare('DELETE FROM novena_groups WHERE id = ? AND user_id = ?')->execute([$gid, $uid]);
}
header('Location: ' . BASE_URL . '/admin/');
exit;
}
if (isset($_POST['delete_id'])) {
$id = (int)$_POST['delete_id'];
if ($is_admin) {
$pdo->prepare('DELETE FROM sessions WHERE id = ?')->execute([$id]);
} else {
$pdo->prepare('DELETE FROM sessions WHERE id = ? AND user_id = ?')->execute([$id, $uid]);
}
header('Location: ' . BASE_URL . '/admin/');
exit;
}
}
// Load sessions
if ($is_admin) {
$regular = $pdo->query("
SELECT s.*, 'session' AS row_type, u.username, u.display_name
FROM sessions s
LEFT JOIN users u ON u.id = s.user_id
WHERE s.novena_group_id IS NULL
ORDER BY s.created_at DESC
")->fetchAll();
$novena_groups = $pdo->query("
SELECT ng.*, 'novena_group' AS row_type, COUNT(s.id) AS day_count,
u.username, u.display_name
FROM novena_groups ng
LEFT JOIN sessions s ON s.novena_group_id = ng.id
LEFT JOIN users u ON u.id = ng.user_id
GROUP BY ng.id
ORDER BY ng.created_at DESC
")->fetchAll();
} else {
$regular = $pdo->prepare("
SELECT s.*, 'session' AS row_type, u.username, u.display_name
FROM sessions s
LEFT JOIN users u ON u.id = s.user_id
WHERE s.novena_group_id IS NULL AND s.user_id = ?
ORDER BY s.created_at DESC
");
$regular->execute([$uid]);
$regular = $regular->fetchAll();
$ng_stmt = $pdo->prepare("
SELECT ng.*, 'novena_group' AS row_type, COUNT(s.id) AS day_count,
u.username, u.display_name
FROM novena_groups ng
LEFT JOIN sessions s ON s.novena_group_id = ng.id
LEFT JOIN users u ON u.id = ng.user_id
WHERE ng.user_id = ?
GROUP BY ng.id
ORDER BY ng.created_at DESC
");
$ng_stmt->execute([$uid]);
$novena_groups = $ng_stmt->fetchAll();
}
$all_rows = array_merge($regular, $novena_groups);
usort($all_rows, fn($a, $b) => strcmp($b['created_at'], $a['created_at']));
$occasion_labels = [
'novena_deceased' => 'Novena for Deceased',
'divine_mercy_novena' => 'Divine Mercy Novena',
'general_rosary' => 'General Rosary',
'memorial' => 'Memorial',
];
$mystery_labels = [
'sorrowful' => 'Sorrowful',
'joyful' => 'Joyful',
'glorious' => 'Glorious',
'luminous' => 'Luminous',
'by_day_of_week' => 'By Day',
];
$novena_created = isset($_GET['novena_created']) ? (int)$_GET['novena_created'] : 0;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title>Dashboard — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<?php if (has_role('admin')): ?>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<a href="<?= BASE_URL ?>/admin/audio.php" class="btn btn-ghost">Audio</a>
<?php endif; ?>
<?php if (has_role('superadmin')): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<?php if ($novena_created > 0): ?>
<div class="alert alert-success">
&#x2713; <?= $novena_created ?> novena day sessions created.
</div>
<?php endif; ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="margin:0"><?= $is_admin ? 'All Sessions' : 'My Sessions' ?></h2>
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">+ New Session</a>
</div>
<?php if (empty($all_rows)): ?>
<div class="empty-state">
<p>No sessions yet.</p>
<a href="<?= BASE_URL ?>/admin/setup.php" class="btn btn-primary">Create Your First Session</a>
</div>
<?php else: ?>
<div class="sessions-table-wrap">
<table class="sessions-table">
<thead>
<tr>
<th>Name</th>
<th>Occasion</th>
<th>Mysteries</th>
<?php if ($is_admin): ?><th>Owner</th><?php endif; ?>
<th>Public</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($all_rows as $row): ?>
<?php if ($row['row_type'] === 'novena_group'): ?>
<tr class="novena-group-row">
<td class="session-name">
<span class="novena-group-icon">&#x271D;</span>
<?= htmlspecialchars($row['name']) ?>
<?php if ($row['subject_name']): ?>
<span class="subject-name"><?= htmlspecialchars($row['subject_name']) ?></span>
<?php endif; ?>
<span class="novena-badge">9-Day Novena</span>
</td>
<td><?= ($row['mystery_set'] === 'chaplet') ? 'Divine Mercy Novena' : 'Novena for Deceased' ?></td>
<td><?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?></td>
<?php if ($is_admin): ?>
<td><?= htmlspecialchars($row['display_name'] ?: ($row['username'] ?? '—')) ?></td>
<?php endif; ?>
<td><?= $row['is_public'] ? '<span style="color:#15803d">&#x2713; Yes</span>' : '<span style="color:#9ca3af">No</span>' ?></td>
<td><?= date('M j, Y', strtotime($row['created_at'])) ?></td>
<td class="actions">
<a href="<?= BASE_URL ?>/admin/novena_group.php?id=<?= $row['id'] ?>"
class="btn btn-sm btn-primary">View Days &rarr;</a>
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete this entire novena (all 9 days)?')">
<input type="hidden" name="delete_group_id" value="<?= $row['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php else: ?>
<tr>
<td class="session-name">
<?= htmlspecialchars($row['name']) ?>
<?php if ($row['subject_name']): ?>
<span class="subject-name"><?= htmlspecialchars($row['subject_name']) ?></span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($occasion_labels[$row['occasion']] ?? $row['occasion']) ?></td>
<td><?= htmlspecialchars($mystery_labels[$row['mystery_set']] ?? $row['mystery_set']) ?></td>
<?php if ($is_admin): ?>
<td><?= htmlspecialchars($row['display_name'] ?: ($row['username'] ?? '—')) ?></td>
<?php endif; ?>
<td><?= $row['is_public'] ? '<span style="color:#15803d">&#x2713; Yes</span>' : '<span style="color:#9ca3af">No</span>' ?></td>
<td><?= date('M j, Y', strtotime($row['created_at'])) ?></td>
<td class="actions">
<?php
$slug_val = $row['slug'] ?? '';
$owner = $row['username'] ?? '';
$present_url = ($slug_val && $owner)
? BASE_URL . '/' . rawurlencode($owner) . '/' . rawurlencode($slug_val)
: BASE_URL . '/present.php?id=' . $row['id'];
?>
<a href="<?= htmlspecialchars($present_url) ?>"
target="_blank"
class="btn btn-sm btn-primary">Present</a>
<?php if ($is_admin || (int)$row['user_id'] === $uid): ?>
<a href="<?= BASE_URL ?>/admin/setup.php?id=<?= $row['id'] ?>"
class="btn btn-sm btn-secondary">Edit</a>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete this session?')">
<input type="hidden" name="delete_id" value="<?= $row['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</main>
</div>
</body>
</html>
+338
View File
@@ -0,0 +1,338 @@
<?php
/**
* admin/novena_group.php — View and manage one novena group.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_auth();
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$gid = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$site_name = get_setting('site_name', APP_NAME);
if ($gid < 1) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
// Load group and verify ownership
$group = $pdo->prepare('SELECT * FROM novena_groups WHERE id = ?');
$group->execute([$gid]);
$group = $group->fetch();
if (!$group) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
// Ownership check
if (!has_role('admin') && (int)$group['user_id'] !== $uid) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
$is_dm = ($group['mystery_set'] === 'chaplet');
// Handle delete of a single day session
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['delete_day_id'])) {
$did = (int)$_POST['delete_day_id'];
$pdo->prepare('DELETE FROM sessions WHERE id = ? AND novena_group_id = ?')->execute([$did, $gid]);
$remaining = (int)$pdo->query("SELECT COUNT(*) FROM sessions WHERE novena_group_id = {$gid}")->fetchColumn();
if ($remaining < 1) {
$pdo->prepare('DELETE FROM novena_groups WHERE id = ?')->execute([$gid]);
header('Location: ' . BASE_URL . '/admin/');
exit;
}
header('Location: ' . BASE_URL . '/admin/novena_group.php?id=' . $gid . '&deleted=1');
exit;
}
// Handle group-level metadata edit
$save_error = '';
$save_success = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_group'])) {
$g_name = trim($_POST['g_name'] ?? '');
$g_photo = trim($_POST['g_photo'] ?? '') ?: null;
$g_public = isset($_POST['is_public']) ? 1 : 0;
if ($is_dm) {
// Divine Mercy: keep mystery_set as 'chaplet', no subject fields
$g_mystery = 'chaplet';
$g_subject = null;
$g_pronoun = null;
$g_dates = null;
} else {
$g_mystery = trim($_POST['g_mystery'] ?? '');
$g_subject = trim($_POST['g_subject'] ?? '') ?: null;
$g_pronoun = trim($_POST['g_pronoun'] ?? '') ?: null;
$g_dates = trim($_POST['g_dates'] ?? '') ?: null;
}
$valid_mysteries = ['sorrowful', 'joyful', 'glorious', 'luminous', 'by_day_of_week', 'chaplet'];
if ($g_name === '') {
$save_error = 'Name is required.';
} elseif (!in_array($g_mystery, $valid_mysteries, true)) {
$save_error = 'Invalid mystery set.';
} else {
$pdo->prepare('
UPDATE novena_groups
SET name = ?, mystery_set = ?, subject_name = ?,
subject_pronoun = ?, subject_dates = ?,
photo_path = COALESCE(?, photo_path),
is_public = ?
WHERE id = ?
')->execute([$g_name, $g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_public, $gid]);
$pdo->prepare('
UPDATE sessions
SET mystery_set = ?, subject_name = ?,
subject_pronoun = ?, subject_dates = ?,
photo_path = COALESCE(?, photo_path),
name = CONCAT(?, CONCAT(\' — Day \', novena_day)),
is_public = ?
WHERE novena_group_id = ?
')->execute([$g_mystery, $g_subject, $g_pronoun, $g_dates, $g_photo, $g_name, $g_public, $gid]);
$save_success = true;
// Reload group
$grp_stmt = $pdo->prepare('SELECT * FROM novena_groups WHERE id = ?');
$grp_stmt->execute([$gid]);
$group = $grp_stmt->fetch();
}
}
$days = $pdo->prepare('SELECT * FROM sessions WHERE novena_group_id = ? ORDER BY novena_day');
$days->execute([$gid]);
$days = $days->fetchAll();
$mystery_labels = [
'sorrowful' => 'Sorrowful Mysteries',
'joyful' => 'Joyful Mysteries',
'glorious' => 'Glorious Mysteries',
'luminous' => 'Luminous Mysteries',
'by_day_of_week' => 'By Day of Week',
'chaplet' => 'Chaplet of Divine Mercy',
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title><?= htmlspecialchars($group['name']) ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<?php if (has_role('admin')): ?>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<?php endif; ?>
<?php if (has_role('superadmin')): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">&#x2190; All Sessions</a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<nav class="breadcrumb">
<a href="<?= BASE_URL ?>/admin/">Sessions</a>
<span class="bc-sep">/</span>
<span><?= htmlspecialchars($group['name']) ?></span>
</nav>
<?php if (isset($_GET['deleted'])): ?>
<div class="alert alert-success">Day deleted successfully.</div>
<?php endif; ?>
<?php if ($save_success): ?>
<div class="alert alert-success">&#x2713; Novena updated. Changes applied to all 9 day sessions.</div>
<?php elseif ($save_error): ?>
<div class="alert alert-error"><?= htmlspecialchars($save_error) ?></div>
<?php endif; ?>
<section class="card" style="margin-bottom:32px">
<h2 class="card-title">Novena Details</h2>
<form method="post">
<input type="hidden" name="save_group" value="1">
<div class="form-grid">
<div class="form-group">
<label class="form-label" for="g_name">Novena Name</label>
<input type="text" id="g_name" name="g_name" class="form-input"
value="<?= htmlspecialchars($group['name']) ?>" required>
<p class="form-help">Used as the base name for all 9 days.</p>
</div>
<?php if (!$is_dm): ?>
<div class="form-group">
<label class="form-label" for="g_mystery">Mysteries</label>
<select id="g_mystery" name="g_mystery" class="form-input">
<?php foreach ([
'sorrowful' => 'All Sorrowful Mysteries',
'by_day_of_week' => 'By Day of Week',
'joyful' => 'All Joyful Mysteries',
'glorious' => 'All Glorious Mysteries',
'luminous' => 'All Luminous Mysteries',
] as $val => $label): ?>
<option value="<?= $val ?>" <?= $group['mystery_set'] === $val ? 'selected' : '' ?>>
<?= $label ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label" for="g_subject">Name of Deceased</label>
<input type="text" id="g_subject" name="g_subject" class="form-input"
value="<?= htmlspecialchars($group['subject_name'] ?? '') ?>">
</div>
<div class="form-group">
<label class="form-label">Pronoun</label>
<div class="radio-row">
<label><input type="radio" name="g_pronoun" value="he"
<?= ($group['subject_pronoun'] ?? 'he') === 'he' ? 'checked' : '' ?>> He / Him</label>
<label><input type="radio" name="g_pronoun" value="she"
<?= ($group['subject_pronoun'] ?? '') === 'she' ? 'checked' : '' ?>> She / Her</label>
</div>
</div>
<div class="form-group">
<label class="form-label" for="g_dates">Life Dates</label>
<input type="text" id="g_dates" name="g_dates" class="form-input"
placeholder="e.g., March 15, 1942 June 3, 2025"
value="<?= htmlspecialchars($group['subject_dates'] ?? '') ?>">
</div>
<?php endif; ?>
<div class="form-group" id="photo-group">
<label class="form-label">Photo</label>
<?php if ($group['photo_path']): ?>
<div class="photo-preview-wrap">
<img id="photo-preview" src="<?= htmlspecialchars('/' . ltrim($group['photo_path'], '/')) ?>"
alt="Current photo" class="photo-preview">
</div>
<?php else: ?>
<div class="photo-preview-wrap" style="display:none">
<img id="photo-preview" src="" alt="" class="photo-preview">
</div>
<?php endif; ?>
<input type="hidden" id="g_photo" name="g_photo"
value="<?= htmlspecialchars($group['photo_path'] ?? '') ?>">
<label class="btn btn-secondary btn-upload" style="margin-top:8px">
<?= $group['photo_path'] ? 'Replace Photo' : 'Upload Photo' ?>
<input type="file" id="photo-file" accept="image/*" style="display:none">
</label>
<span id="photo-status" class="form-help"></span>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
<input type="checkbox" name="is_public" value="1"
<?= $group['is_public'] ? 'checked' : '' ?>
style="width:18px;height:18px">
<span>Public (visible on your profile)</span>
</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Changes to All Days</button>
</div>
</form>
</section>
<section>
<h2 style="margin-bottom:16px">Days (<?= count($days) ?> of 9)</h2>
<div class="sessions-table-wrap">
<table class="sessions-table">
<thead>
<tr>
<th>Day</th>
<th>Mysteries</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php for ($d = 1; $d <= 9; $d++):
$ses = null;
foreach ($days as $day_row) {
if ((int)$day_row['novena_day'] === $d) { $ses = $day_row; break; }
}
?>
<tr <?= !$ses ? 'class="day-missing"' : '' ?>>
<td><strong>Day <?= $d ?></strong></td>
<td><?= $ses ? htmlspecialchars($mystery_labels[$ses['mystery_set']] ?? $ses['mystery_set']) : '<em>missing</em>' ?></td>
<td class="actions">
<?php if ($ses): ?>
<a href="<?= BASE_URL ?>/present.php?id=<?= $ses['id'] ?>"
target="_blank"
class="btn btn-sm btn-primary">Present</a>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete Day <?= $d ?>?')">
<input type="hidden" name="delete_day_id" value="<?= $ses['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endfor; ?>
</tbody>
</table>
</div>
</section>
</main>
</div>
<script>
(function () {
var fileInput = document.getElementById('photo-file');
var photoHidden = document.getElementById('g_photo');
var photoStatus = document.getElementById('photo-status');
var previewWrap = document.querySelector('.photo-preview-wrap');
var previewImg = document.getElementById('photo-preview');
if (!fileInput) return;
fileInput.addEventListener('change', function () {
var file = fileInput.files[0];
if (!file) return;
var fd = new FormData();
fd.append('photo', file);
photoStatus.textContent = 'Uploading\u2026';
fetch(BASE_URL + '/api/upload_photo.php', { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.path) {
photoHidden.value = data.path;
previewImg.src = '/' + data.path.replace(/^\//, '');
previewWrap.style.display = '';
photoStatus.textContent = 'Photo ready.';
} else {
photoStatus.textContent = 'Upload failed: ' + (data.error || 'unknown error');
}
})
.catch(function () { photoStatus.textContent = 'Upload failed.'; });
});
}());
</script>
</body>
</html>
+253
View File
@@ -0,0 +1,253 @@
<?php
/**
* admin/profile.php — Current user's account settings.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_auth();
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$site_name = get_setting('site_name', APP_NAME);
$messages = [];
$errors = [];
// Superadmin can view/edit another user's profile via ?id=X
$target_id = $uid;
if (has_role('superadmin') && isset($_GET['id'])) {
$target_id = (int)$_GET['id'];
}
// Load target user
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$target_id]);
$profile = $stmt->fetch();
if (!$profile) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
// ── Handle form submissions ───────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
// ── Update profile ───────────────────────────────────────────────────────
if ($action === 'update_profile') {
$new_display = trim($_POST['display_name'] ?? '');
$pdo->prepare('UPDATE users SET display_name=? WHERE id=?')->execute([$new_display, $target_id]);
// Refresh session if editing own profile
if ($target_id === $uid) {
$_SESSION['display_name'] = $new_display;
}
$messages[] = 'Profile updated.';
$profile['display_name'] = $new_display;
}
// ── Update email ─────────────────────────────────────────────────────────
if ($action === 'update_email') {
$new_email = trim($_POST['new_email'] ?? '');
$cur_pass = $_POST['current_password'] ?? '';
if (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email address.';
} elseif (!password_verify($cur_pass, $profile['password_hash'])) {
$errors[] = 'Current password is incorrect.';
} else {
$chk = $pdo->prepare('SELECT id FROM users WHERE email=? AND id!=?');
$chk->execute([$new_email, $target_id]);
if ($chk->fetch()) {
$errors[] = 'That email is already in use.';
} else {
$pdo->prepare('UPDATE users SET email=? WHERE id=?')->execute([$new_email, $target_id]);
if ($target_id === $uid) $_SESSION['email'] = $new_email;
$messages[] = 'Email updated.';
$profile['email'] = $new_email;
}
}
}
// ── Change password ───────────────────────────────────────────────────────
if ($action === 'change_password') {
$cur_pass = $_POST['current_password'] ?? '';
$new_pass = $_POST['new_password'] ?? '';
$conf_pass = $_POST['confirm_password'] ?? '';
if (!password_verify($cur_pass, $profile['password_hash'])) {
$errors[] = 'Current password is incorrect.';
} elseif (strlen($new_pass) < 8) {
$errors[] = 'New password must be at least 8 characters.';
} elseif ($new_pass !== $conf_pass) {
$errors[] = 'New passwords do not match.';
} else {
$hash = password_hash($new_pass, PASSWORD_BCRYPT);
$pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([$hash, $target_id]);
$messages[] = 'Password changed successfully.';
$profile['password_hash'] = $hash;
}
}
// ── Superadmin: change rosary limit ──────────────────────────────────────
if ($action === 'update_limit' && has_role('superadmin')) {
$new_limit = (int)($_POST['rosary_limit'] ?? 1);
$pdo->prepare('UPDATE users SET rosary_limit=? WHERE id=?')->execute([$new_limit, $target_id]);
if ($target_id === $uid) $_SESSION['rosary_limit'] = $new_limit;
$messages[] = 'Rosary limit updated.';
$profile['rosary_limit'] = $new_limit;
}
}
$is_own = ($target_id === $uid);
$is_super = has_role('superadmin');
$role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superuser','user'=>'User'];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title><?= $is_own ? 'My Account' : 'Edit User' ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<style>
.profile-section{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:28px;margin-bottom:24px}
.profile-section h3{margin:0 0 20px;font-size:16px;font-weight:700;color:#1e3a5f;border-bottom:2px solid #e5e7eb;padding-bottom:10px}
.readonly-field{background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:8px 12px;font-size:14px;color:#374151}
</style>
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
<?php if (has_role('admin')): ?>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<?php endif; ?>
<?php if ($is_super): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<?php foreach ($messages as $m): ?>
<div class="alert alert-success">&#x2713; <?= htmlspecialchars($m) ?></div>
<?php endforeach; ?>
<?php foreach ($errors as $e): ?>
<div class="alert alert-error"><?= htmlspecialchars($e) ?></div>
<?php endforeach; ?>
<h2 style="margin-bottom:24px"><?= $is_own ? 'My Account' : 'Edit: ' . htmlspecialchars($profile['username']) ?></h2>
<!-- Account Info -->
<div class="profile-section">
<h3>Account Info</h3>
<div class="form-group">
<label>Username</label>
<div class="readonly-field"><?= htmlspecialchars($profile['username']) ?></div>
</div>
<div class="form-group">
<label>Role</label>
<div class="readonly-field"><?= htmlspecialchars($role_labels[$profile['role']] ?? $profile['role']) ?></div>
</div>
<div class="form-group">
<label>Rosary Limit</label>
<div class="readonly-field">
<?= $profile['rosary_limit'] < 0 ? 'Unlimited' : (int)$profile['rosary_limit'] ?>
<?php if ($is_super && $target_id !== $uid): ?>
&nbsp;<a href="#limit-section" style="font-size:12px">(edit below)</a>
<?php endif; ?>
</div>
</div>
<div class="form-group">
<label>Member Since</label>
<div class="readonly-field"><?= date('F j, Y', strtotime($profile['created_at'])) ?></div>
</div>
</div>
<!-- Display Name -->
<div class="profile-section">
<h3>Display Name</h3>
<form method="post">
<input type="hidden" name="action" value="update_profile">
<div class="form-group">
<label for="display_name">Display Name</label>
<input type="text" id="display_name" name="display_name"
maxlength="100"
value="<?= htmlspecialchars($profile['display_name'] ?? '') ?>">
<p class="help-text">Shown publicly on your profile and rosary cards.</p>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
<!-- Email -->
<div class="profile-section">
<h3>Email Address</h3>
<form method="post">
<input type="hidden" name="action" value="update_email">
<div class="form-group">
<label>Current Email</label>
<div class="readonly-field"><?= htmlspecialchars($profile['email']) ?></div>
</div>
<div class="form-group">
<label for="new_email">New Email</label>
<input type="email" id="new_email" name="new_email" required>
</div>
<div class="form-group">
<label for="cur_pass_email">Current Password (to confirm)</label>
<input type="password" id="cur_pass_email" name="current_password" required>
</div>
<button type="submit" class="btn btn-primary">Update Email</button>
</form>
</div>
<!-- Change Password -->
<div class="profile-section">
<h3>Change Password</h3>
<form method="post">
<input type="hidden" name="action" value="change_password">
<div class="form-group">
<label for="cur_pass">Current Password</label>
<input type="password" id="cur_pass" name="current_password" required>
</div>
<div class="form-group">
<label for="new_pass">New Password</label>
<input type="password" id="new_pass" name="new_password" minlength="8" required>
<p class="help-text">At least 8 characters.</p>
</div>
<div class="form-group">
<label for="conf_pass">Confirm New Password</label>
<input type="password" id="conf_pass" name="confirm_password" minlength="8" required>
</div>
<button type="submit" class="btn btn-primary">Change Password</button>
</form>
</div>
<!-- Rosary Limit (superadmin editing another user) -->
<?php if ($is_super && !$is_own): ?>
<div class="profile-section" id="limit-section">
<h3>Rosary Limit</h3>
<form method="post">
<input type="hidden" name="action" value="update_limit">
<div class="form-group">
<label for="rosary_limit">Limit (-1 = unlimited)</label>
<input type="number" id="rosary_limit" name="rosary_limit"
min="-1" value="<?= (int)$profile['rosary_limit'] ?>">
<p class="help-text">How many rosary sessions this user can create.</p>
</div>
<button type="submit" class="btn btn-primary">Save Limit</button>
</form>
</div>
<?php endif; ?>
</main>
</div>
</body>
</html>
+260
View File
@@ -0,0 +1,260 @@
<?php
/**
* admin/settings.php — Site settings. Requires superadmin role.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/mailer.php';
require_role('superadmin');
$user = current_user();
$site_name = get_setting('site_name', APP_NAME);
$message = '';
$error = '';
// Save settings
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? 'save';
if ($action === 'save') {
$keys = ['site_name','site_url','smtp_host','smtp_port','smtp_user','smtp_pass','smtp_from','smtp_from_name',
'donate_enabled','donate_type','donate_handle','donate_label'];
foreach ($keys as $k) {
if (isset($_POST[$k])) {
set_setting($k, trim($_POST[$k]));
}
}
$message = 'Settings saved.';
$site_name = get_setting('site_name', APP_NAME); // refresh
}
if ($action === 'test_email') {
$to = $user['email'];
$tname = $user['display_name'] ?: $user['username'];
$body = email_template(
'Test Email — ' . $site_name,
"<h2 style='margin-top:0;color:#1e3a5f'>Test Email</h2>
<p>Hello, <strong>" . htmlspecialchars($tname) . "</strong>!</p>
<p>This is a test email from your {$site_name} installation. If you received this, your SMTP settings are working correctly.</p>"
);
$ok = send_email($to, $tname, 'Test Email — ' . $site_name, $body);
if ($ok) {
$message = 'Test email sent to ' . htmlspecialchars($to);
} else {
$error = 'Failed to send test email. Check your SMTP settings and server error log.';
}
}
}
$settings = [
'site_name' => get_setting('site_name', 'Rosary Presenter'),
'site_url' => get_setting('site_url'),
'smtp_host' => get_setting('smtp_host'),
'smtp_port' => get_setting('smtp_port', '587'),
'smtp_user' => get_setting('smtp_user'),
'smtp_pass' => get_setting('smtp_pass'),
'smtp_from' => get_setting('smtp_from'),
'smtp_from_name' => get_setting('smtp_from_name', 'Rosary Presenter'),
'donate_enabled' => get_setting('donate_enabled', '0'),
'donate_type' => get_setting('donate_type', 'custom'),
'donate_handle' => get_setting('donate_handle', ''),
'donate_label' => get_setting('donate_label', ''),
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title>Settings — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<style>
.settings-section { background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:28px;margin-bottom:24px }
.settings-section h3 { margin:0 0 20px;font-size:16px;font-weight:700;color:#1e3a5f;border-bottom:2px solid #e5e7eb;padding-bottom:10px }
.pass-wrap { position:relative }
.pass-wrap input { padding-right:80px }
.pass-toggle { position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:none;cursor:pointer;font-size:12px;color:#6b7280;font-weight:600 }
</style>
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<?php if ($message): ?>
<div class="alert alert-success">&#x2713; <?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<h2 style="margin-bottom:24px">Site Settings</h2>
<form method="post">
<input type="hidden" name="action" value="save">
<div class="settings-section">
<h3>General</h3>
<div class="form-group">
<label for="site_name">Site Name</label>
<input type="text" id="site_name" name="site_name"
value="<?= htmlspecialchars($settings['site_name']) ?>" required>
<p class="help-text">Displayed in the browser tab, emails, and the site header.</p>
</div>
<div class="form-group">
<label for="site_url">Site URL</label>
<input type="url" id="site_url" name="site_url"
placeholder="https://yourdomain.com"
value="<?= htmlspecialchars($settings['site_url']) ?>">
<p class="help-text">Used for links in emails. Include protocol, no trailing slash.</p>
</div>
</div>
<div class="settings-section">
<h3>SMTP Email</h3>
<p class="help-text" style="margin-top:0;margin-bottom:20px">
Leave SMTP Host blank to use PHP's built-in <code>mail()</code> function.
For Gmail: host=smtp.gmail.com, port=587, use an App Password.
</p>
<div class="form-grid">
<div class="form-group">
<label for="smtp_host">SMTP Host</label>
<input type="text" id="smtp_host" name="smtp_host"
placeholder="smtp.gmail.com"
value="<?= htmlspecialchars($settings['smtp_host']) ?>">
</div>
<div class="form-group">
<label for="smtp_port">SMTP Port</label>
<input type="number" id="smtp_port" name="smtp_port"
placeholder="587" min="1" max="65535"
value="<?= htmlspecialchars($settings['smtp_port']) ?>">
<p class="help-text">587 for STARTTLS, 465 for SSL.</p>
</div>
<div class="form-group">
<label for="smtp_user">SMTP Username</label>
<input type="text" id="smtp_user" name="smtp_user"
autocomplete="off"
value="<?= htmlspecialchars($settings['smtp_user']) ?>">
</div>
<div class="form-group">
<label for="smtp_pass">SMTP Password</label>
<div class="pass-wrap">
<input type="password" id="smtp_pass" name="smtp_pass"
autocomplete="new-password"
value="<?= htmlspecialchars($settings['smtp_pass']) ?>">
<button type="button" class="pass-toggle" onclick="togglePass()">Show</button>
</div>
</div>
<div class="form-group">
<label for="smtp_from">From Email</label>
<input type="email" id="smtp_from" name="smtp_from"
placeholder="noreply@yourdomain.com"
value="<?= htmlspecialchars($settings['smtp_from']) ?>">
</div>
<div class="form-group">
<label for="smtp_from_name">From Name</label>
<input type="text" id="smtp_from_name" name="smtp_from_name"
value="<?= htmlspecialchars($settings['smtp_from_name']) ?>">
</div>
</div>
</div>
<div class="settings-section">
<h3>Donate Button</h3>
<p class="help-text" style="margin-top:0;margin-bottom:20px">
Show a small donation strip on the public home page. Choose a service, enter your handle or URL, and enable it when ready.
</p>
<div class="form-group">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
<input type="checkbox" name="donate_enabled" value="1"
id="donate_enabled"
<?= $settings['donate_enabled'] ? 'checked' : '' ?>
style="width:18px;height:18px">
<span>Enable donate strip on public pages</span>
</label>
</div>
<div class="form-grid" id="donate-fields">
<div class="form-group">
<label for="donate_type">Service</label>
<select id="donate_type" name="donate_type" class="form-input" onchange="updateDonateHint()">
<option value="paypal" <?= $settings['donate_type'] === 'paypal' ? 'selected' : '' ?>>PayPal (paypal.me)</option>
<option value="venmo" <?= $settings['donate_type'] === 'venmo' ? 'selected' : '' ?>>Venmo</option>
<option value="buymeacoffee" <?= $settings['donate_type'] === 'buymeacoffee' ? 'selected' : '' ?>>Buy Me a Coffee</option>
<option value="custom" <?= $settings['donate_type'] === 'custom' ? 'selected' : '' ?>>Custom URL</option>
</select>
</div>
<div class="form-group">
<label for="donate_handle" id="donate_handle_label">Handle / URL</label>
<input type="text" id="donate_handle" name="donate_handle"
value="<?= htmlspecialchars($settings['donate_handle']) ?>"
placeholder="">
<p class="help-text" id="donate_hint"></p>
</div>
<div class="form-group">
<label for="donate_label">Button Label <span style="font-weight:400;color:#6b7280">(optional)</span></label>
<input type="text" id="donate_label" name="donate_label"
value="<?= htmlspecialchars($settings['donate_label']) ?>"
placeholder="Leave blank for default">
<p class="help-text">Overrides the default label for the selected service.</p>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Save Settings</button>
</div>
</form>
<div class="settings-section" style="margin-top:24px">
<h3>Test Email</h3>
<p class="help-text">Send a test email to <strong><?= htmlspecialchars($user['email']) ?></strong> to verify your SMTP settings.</p>
<form method="post">
<input type="hidden" name="action" value="test_email">
<button type="submit" class="btn btn-secondary">Send Test Email</button>
</form>
</div>
</main>
</div>
<script>
var donateHints = {
paypal: { label: 'PayPal.me username', hint: 'Enter your PayPal.me handle, e.g. YourName — link becomes paypal.me/YourName' },
venmo: { label: 'Venmo username', hint: 'Enter your Venmo handle without @, e.g. YourName — link becomes venmo.com/u/YourName' },
buymeacoffee: { label: 'Buy Me a Coffee username', hint: 'Enter your buymeacoffee.com handle, e.g. YourName' },
custom: { label: 'Full URL', hint: 'Enter the complete URL, e.g. https://example.com/donate' },
};
function updateDonateHint() {
var type = document.getElementById('donate_type').value;
var info = donateHints[type] || donateHints.custom;
document.getElementById('donate_handle_label').textContent = info.label;
document.getElementById('donate_hint').textContent = info.hint;
}
updateDonateHint();
function togglePass() {
var inp = document.getElementById('smtp_pass');
var btn = inp.nextElementSibling;
if (inp.type === 'password') {
inp.type = 'text';
btn.textContent = 'Hide';
} else {
inp.type = 'password';
btn.textContent = 'Show';
}
}
</script>
</body>
</html>
+250
View File
@@ -0,0 +1,250 @@
<?php
/**
* admin/setup.php — Create or edit a rosary session.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_auth();
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$site_name = get_setting('site_name', APP_NAME);
// Load existing session for editing
$session = null;
if (isset($_GET['id'])) {
$stmt = $pdo->prepare('SELECT * FROM sessions WHERE id = ?');
$stmt->execute([(int)$_GET['id']]);
$session = $stmt->fetch();
if (!$session) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
// Ownership check: must own or be admin
if (!has_role('admin') && (int)$session['user_id'] !== $uid) {
header('Location: ' . BASE_URL . '/admin/');
exit;
}
}
// Creating new session? Check rosary limit
$limit_error = '';
if (!$session && !can_create_rosary($uid, $user['rosary_limit'])) {
$limit = $user['rosary_limit'];
$limit_error = "You have reached your rosary limit ({$limit}). Please contact an administrator to increase your limit.";
}
$page_title = $session ? 'Edit Session' : 'New Session';
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title><?= $page_title ?> — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<script>var BASE_URL = '<?= BASE_URL ?>';</script>
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<?php if (has_role('admin')): ?>
<a href="<?= BASE_URL ?>/admin/users.php" class="btn btn-ghost">Users</a>
<?php endif; ?>
<?php if (has_role('superadmin')): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">&#x2190; Back</a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<h2><?= $page_title ?></h2>
<?php if ($limit_error): ?>
<div class="alert alert-error"><?= htmlspecialchars($limit_error) ?></div>
<?php else: ?>
<div id="form-message" class="alert" style="display:none"></div>
<form id="session-form" novalidate>
<?php if ($session): ?>
<input type="hidden" name="id" value="<?= (int)$session['id'] ?>">
<?php endif; ?>
<!-- Session Name -->
<div class="form-group">
<label for="name">Session Label <span class="required">*</span></label>
<input type="text" id="name" name="name"
placeholder="e.g. Medy"
value="<?= htmlspecialchars($session['name'] ?? '') ?>"
required>
<p class="help-text" id="name-help">
For novena: enter just the name (e.g. "Medy") — sessions will be created as
"Medy — Day 1" through "Medy — Day 9".
</p>
</div>
<!-- Occasion -->
<div class="form-group">
<label for="occasion">Occasion <span class="required">*</span></label>
<select id="occasion" name="occasion" required>
<option value="">— Select —</option>
<option value="novena_deceased"
<?= ($session['occasion'] ?? '') === 'novena_deceased' ? 'selected' : '' ?>>
Novena for Deceased
</option>
<option value="divine_mercy_novena"
<?= ($session['occasion'] ?? '') === 'divine_mercy_novena' ? 'selected' : '' ?>>
Divine Mercy Novena
</option>
<option value="general_rosary"
<?= ($session['occasion'] ?? '') === 'general_rosary' ? 'selected' : '' ?>>
General Rosary
</option>
<option value="memorial"
<?= ($session['occasion'] ?? '') === 'memorial' ? 'selected' : '' ?>>
Memorial / Month's Mind
</option>
</select>
</div>
<!-- Mystery Set (non-novena) -->
<div class="form-group" id="field-mystery_set_standard">
<label for="mystery_set">Mystery Set <span class="required">*</span></label>
<select id="mystery_set" name="mystery_set" required>
<option value="">— Select —</option>
<option value="sorrowful"
<?= ($session['mystery_set'] ?? '') === 'sorrowful' ? 'selected' : '' ?>>
Sorrowful Mysteries
</option>
<option value="joyful"
<?= ($session['mystery_set'] ?? '') === 'joyful' ? 'selected' : '' ?>>
Joyful Mysteries
</option>
<option value="glorious"
<?= ($session['mystery_set'] ?? '') === 'glorious' ? 'selected' : '' ?>>
Glorious Mysteries
</option>
<option value="luminous"
<?= ($session['mystery_set'] ?? '') === 'luminous' ? 'selected' : '' ?>>
Luminous Mysteries
</option>
</select>
</div>
<!-- Novena Mystery Mode (only shown for novena_deceased) -->
<div class="form-group conditional-field" id="field-novena_mystery_mode" style="display:none">
<label for="novena_mystery_mode">Mysteries for this Novena <span class="required">*</span></label>
<select id="novena_mystery_mode" name="novena_mystery_mode">
<option value="all_sorrowful"
<?= ($session['mystery_set'] ?? '') === 'sorrowful' || ($session['mystery_set'] ?? '') === 'all_sorrowful' ? 'selected' : '' ?>>
All Sorrowful (traditional for deceased)
</option>
<option value="by_day_of_week"
<?= ($session['mystery_set'] ?? '') === 'by_day_of_week' ? 'selected' : '' ?>>
By Day of Week — auto-selected at presentation time
</option>
</select>
<p class="help-text">
Day-of-week schedule: Sun &amp; Wed = Glorious &middot; Mon &amp; Sat = Joyful &middot;
Tue &amp; Fri = Sorrowful &middot; Thu = Luminous
</p>
</div>
<!-- Novena Day -->
<div class="form-group conditional-field" id="field-novena_day" style="display:none">
<?php if ($session): ?>
<label>Novena Day</label>
<div class="form-static">Day <?= (int)($session['novena_day'] ?? 1) ?> of 9</div>
<?php else: ?>
<div class="novena-auto-notice">
&#x2713;&nbsp; Will automatically create <strong>Day 1 through Day 9</strong>.
</div>
<?php endif; ?>
</div>
<!-- Subject Name (conditional) -->
<div class="form-group conditional-field" id="field-subject_name" style="display:none">
<label for="subject_name">Name of Deceased / Honoree</label>
<input type="text" id="subject_name" name="subject_name"
placeholder="e.g. Maria Santos"
value="<?= htmlspecialchars($session['subject_name'] ?? '') ?>">
</div>
<!-- Pronoun (conditional) -->
<div class="form-group conditional-field" id="field-subject_pronoun" style="display:none">
<label for="subject_pronoun">Pronoun</label>
<select id="subject_pronoun" name="subject_pronoun">
<option value="he"
<?= ($session['subject_pronoun'] ?? 'he') === 'he' ? 'selected' : '' ?>>
He / Him / His
</option>
<option value="she"
<?= ($session['subject_pronoun'] ?? '') === 'she' ? 'selected' : '' ?>>
She / Her / Her
</option>
</select>
</div>
<!-- Dates (conditional) -->
<div class="form-group conditional-field" id="field-subject_dates" style="display:none">
<label for="subject_dates">Life Dates</label>
<input type="text" id="subject_dates" name="subject_dates"
placeholder="e.g. Aug 12, 1949 July 25, 2025"
value="<?= htmlspecialchars($session['subject_dates'] ?? '') ?>">
</div>
<!-- Photo -->
<div class="form-group" id="field-photo">
<label for="photo">Photo (optional)</label>
<?php if (!empty($session['photo_path'])): ?>
<div class="photo-preview">
<img src="<?= htmlspecialchars('/' . ltrim($session['photo_path'], '/')) ?>"
alt="Current photo" style="max-height:120px">
<p class="help-text">Current photo. Upload a new one to replace it.</p>
</div>
<?php endif; ?>
<input type="file" id="photo" name="photo" accept="image/*">
<p class="help-text">JPEG, PNG, WebP — max 5 MB. Recommended: square or landscape, at least 800 × 600 px. Shown on the home page card and cover slide.</p>
<input type="hidden" id="photo_path" name="photo_path"
value="<?= htmlspecialchars($session['photo_path'] ?? '') ?>">
<div id="upload-status" style="display:none" class="upload-status"></div>
</div>
<!-- Public toggle -->
<div class="form-group">
<label style="display:flex;align-items:center;gap:10px;cursor:pointer">
<input type="checkbox" name="is_public" value="1"
<?= (!$session || $session['is_public']) ? 'checked' : '' ?>
style="width:18px;height:18px">
<span>Make this session public (visible on your profile and the home page)</span>
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary btn-large" id="submit-btn"
data-label-default="<?= $session ? 'Save Changes' : 'Create Session' ?>"
data-label-novena-deceased="<?= $session ? 'Save Changes' : 'Create 9-Day Novena' ?>"
data-label-divine-mercy="<?= $session ? 'Save Changes' : 'Create Divine Mercy Novena' ?>">
<?= $session ? 'Save Changes' : 'Create Session' ?>
</button>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Cancel</a>
</div>
</form>
<?php endif; ?>
</main>
</div>
<script src="<?= BASE_URL ?>/assets/js/setup.js?v=5"></script>
</body>
</html>
+425
View File
@@ -0,0 +1,425 @@
<?php
/**
* admin/users.php — User management. Requires admin role.
*/
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../includes/auth.php';
require_once __DIR__ . '/../includes/mailer.php';
require_role('admin');
$pdo = get_pdo();
$user = current_user();
$uid = (int)$user['id'];
$is_super = has_role('superadmin');
$site_name = get_setting('site_name', APP_NAME);
$messages = [];
$errors = [];
// ── Handle actions ───────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
// ── Create user ──────────────────────────────────────────────────────────
if ($action === 'create_user') {
$new_username = trim($_POST['new_username'] ?? '');
$new_display_name = trim($_POST['new_display_name'] ?? '');
$new_email = trim($_POST['new_email'] ?? '');
$new_password = $_POST['new_password'] ?? '';
$new_role = $_POST['new_role'] ?? 'user';
$new_limit = (int)($_POST['new_rosary_limit'] ?? 1);
// Validate role — admins can't set superadmin
$allowed_roles = $is_super ? ['user','superuser','admin','superadmin'] : ['user','superuser'];
if (!in_array($new_role, $allowed_roles, true)) $new_role = 'user';
if (!preg_match('/^[a-zA-Z0-9_]{3,30}$/', $new_username)) {
$errors[] = 'Invalid username.';
} elseif (!filter_var($new_email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email.';
} elseif (strlen($new_password) < 8) {
$errors[] = 'Password must be at least 8 characters.';
} else {
$chk = $pdo->prepare('SELECT id FROM users WHERE username=? OR email=?');
$chk->execute([$new_username, $new_email]);
if ($chk->fetch()) {
$errors[] = 'Username or email already in use.';
} else {
$hash = password_hash($new_password, PASSWORD_BCRYPT);
$pdo->prepare("
INSERT INTO users (username,email,password_hash,display_name,role,rosary_limit,email_confirmed)
VALUES (?,?,?,?,?,?,1)
")->execute([$new_username, $new_email, $hash, $new_display_name ?: $new_username, $new_role, $new_limit]);
$messages[] = "User '{$new_username}' created.";
}
}
}
// ── Update user ──────────────────────────────────────────────────────────
if ($action === 'update_user') {
$target_id = (int)($_POST['target_id'] ?? 0);
$upd_display = trim($_POST['upd_display_name'] ?? '');
$upd_email = trim($_POST['upd_email'] ?? '');
$upd_role = $_POST['upd_role'] ?? '';
$upd_limit = (int)($_POST['upd_rosary_limit'] ?? 1);
// Fetch target to check if superadmin
$tgt = $pdo->prepare('SELECT * FROM users WHERE id=?');
$tgt->execute([$target_id]);
$tgt = $tgt->fetch();
if (!$tgt) {
$errors[] = 'User not found.';
} else {
// Role protection
$allowed_roles = $is_super ? ['user','superuser','admin','superadmin'] : ['user','superuser'];
if (!in_array($upd_role, $allowed_roles, true)) $upd_role = $tgt['role'];
// Non-superadmin cannot change superadmin's role
if (!$is_super && $tgt['role'] === 'superadmin') {
$errors[] = 'Cannot modify a superadmin account.';
} else {
if (!filter_var($upd_email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email.';
} else {
$chk = $pdo->prepare('SELECT id FROM users WHERE email=? AND id!=?');
$chk->execute([$upd_email, $target_id]);
if ($chk->fetch()) {
$errors[] = 'Email already in use by another account.';
} else {
$pdo->prepare('UPDATE users SET display_name=?,email=?,role=?,rosary_limit=? WHERE id=?')
->execute([$upd_display, $upd_email, $upd_role, $upd_limit, $target_id]);
$messages[] = "User '{$tgt['username']}' updated.";
}
}
}
}
}
// ── Reset password ───────────────────────────────────────────────────────
if ($action === 'reset_password') {
$target_id = (int)($_POST['target_id'] ?? 0);
$new_pass = $_POST['new_pass'] ?? '';
if (strlen($new_pass) < 8) {
$errors[] = 'New password must be at least 8 characters.';
} else {
$hash = password_hash($new_pass, PASSWORD_BCRYPT);
$pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([$hash, $target_id]);
$messages[] = 'Password reset successfully.';
}
}
// ── Resend confirmation email ─────────────────────────────────────────────
if ($action === 'resend_confirmation') {
$target_id = (int)($_POST['target_id'] ?? 0);
$tgt = $pdo->prepare('SELECT * FROM users WHERE id = ? AND email_confirmed = 0');
$tgt->execute([$target_id]);
$tgt = $tgt->fetch();
if (!$tgt) {
$errors[] = 'User not found or already confirmed.';
} else {
$smtp_host = get_setting('smtp_host');
if ($smtp_host === '') {
// No SMTP — confirm directly
$pdo->prepare('UPDATE users SET email_confirmed = 1, confirm_token = NULL WHERE id = ?')
->execute([$target_id]);
$messages[] = "No SMTP configured — {$tgt['username']} has been confirmed directly.";
} else {
$token = bin2hex(random_bytes(32));
$site_url = rtrim(get_setting('site_url'), '/');
$site_name = get_setting('site_name', APP_NAME);
$link = $site_url . '/confirm?token=' . urlencode($token);
$disp = $tgt['display_name'] ?: $tgt['username'];
$pdo->prepare('UPDATE users SET confirm_token = ? WHERE id = ?')
->execute([$token, $target_id]);
$body_html = "
<h2 style='margin-top:0;color:#1e3a5f'>Confirm your email</h2>
<p>Hello, <strong>" . htmlspecialchars($disp) . "</strong>!</p>
<p>An administrator has resent your confirmation email for {$site_name}. Click the button below to confirm your email address and activate your account:</p>
<p style='text-align:center;margin:28px 0'>
<a href='" . htmlspecialchars($link) . "' style='display:inline-block;background:#1e3a5f;color:#fff;padding:12px 28px;border-radius:6px;text-decoration:none;font-weight:600'>Confirm Email</a>
</p>
<p style='color:#6b7280;font-size:13px'>Or copy this link: " . htmlspecialchars($link) . "</p>
<p style='color:#6b7280;font-size:13px'>If you did not register, ignore this email.</p>
";
$html = email_template('Confirm your email — ' . $site_name, $body_html);
$ok = send_email($tgt['email'], $disp, 'Confirm your email — ' . $site_name, $html);
if ($ok) {
$messages[] = "Confirmation email resent to {$tgt['email']}.";
} else {
$errors[] = 'Failed to send email. Check your SMTP settings.';
}
}
}
}
// ── Delete user ──────────────────────────────────────────────────────────
if ($action === 'delete_user') {
$target_id = (int)($_POST['target_id'] ?? 0);
if ($target_id === $uid) {
$errors[] = 'You cannot delete your own account.';
} else {
$tgt = $pdo->prepare('SELECT role,username FROM users WHERE id=?');
$tgt->execute([$target_id]);
$tgt = $tgt->fetch();
if ($tgt && $tgt['role'] === 'superadmin' && !$is_super) {
$errors[] = 'Cannot delete a superadmin account.';
} elseif ($tgt) {
$pdo->prepare('DELETE FROM users WHERE id=?')->execute([$target_id]);
$messages[] = "User '{$tgt['username']}' deleted.";
}
}
}
}
// ── Load all users with rosary counts ────────────────────────────────────────
$users = $pdo->query("
SELECT u.*,
(SELECT COUNT(*) FROM sessions WHERE user_id=u.id AND occasion != 'novena_deceased') +
(SELECT COUNT(*) FROM novena_groups WHERE user_id=u.id)
AS rosary_count
FROM users u
ORDER BY u.created_at DESC
")->fetchAll();
$role_labels = ['superadmin'=>'Superadmin','admin'=>'Admin','superuser'=>'Superuser','user'=>'User'];
$role_colors = [
'superadmin' => '#dc2626',
'admin' => '#ea580c',
'superuser' => '#2563eb',
'user' => '#6b7280',
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="<?= BASE_URL ?>/favicon.svg">
<title>Users — <?= htmlspecialchars($site_name) ?></title>
<link rel="stylesheet" href="<?= BASE_URL ?>/assets/css/setup.css">
<style>
.role-badge{display:inline-block;padding:2px 10px;border-radius:99px;font-size:12px;font-weight:700;color:#fff}
.edit-panel{display:none;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:20px;margin-top:12px}
.edit-panel.open{display:block}
.user-row td{vertical-align:top;padding:12px 10px}
.mini-form{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end}
.mini-form .form-group{margin:0;min-width:160px}
.mini-form label{font-size:12px;font-weight:600;display:block;margin-bottom:4px;color:#374151}
.mini-form input,.mini-form select{font-size:14px;padding:6px 10px;border:1px solid #d1d5db;border-radius:6px;width:100%}
.create-panel{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:24px;margin-bottom:28px;display:none}
.create-panel.open{display:block}
</style>
</head>
<body>
<div class="admin-container">
<header class="admin-header">
<h1>&#x271D; <?= htmlspecialchars($site_name) ?></h1>
<div class="header-actions">
<a href="<?= BASE_URL ?>/" class="btn btn-ghost" style="font-size:13px">&#x2190; View Site</a>
<a href="<?= BASE_URL ?>/admin/" class="btn btn-ghost">Dashboard</a>
<?php if ($is_super): ?>
<a href="<?= BASE_URL ?>/admin/settings.php" class="btn btn-ghost">Settings</a>
<?php endif; ?>
<a href="<?= BASE_URL ?>/admin/profile.php" class="btn btn-ghost"><?= htmlspecialchars($user['display_name'] ?: $user['username']) ?></a>
<a href="<?= BASE_URL ?>/logout" class="btn btn-ghost">Logout</a>
</div>
</header>
<main>
<?php foreach ($messages as $m): ?>
<div class="alert alert-success">&#x2713; <?= htmlspecialchars($m) ?></div>
<?php endforeach; ?>
<?php foreach ($errors as $e): ?>
<div class="alert alert-error"><?= htmlspecialchars($e) ?></div>
<?php endforeach; ?>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<h2 style="margin:0">Users (<?= count($users) ?>)</h2>
<button class="btn btn-primary" onclick="toggleCreate()">+ Create User</button>
</div>
<!-- Create User Panel -->
<div class="create-panel" id="create-panel">
<h3 style="margin:0 0 16px">Create New User</h3>
<form method="post">
<input type="hidden" name="action" value="create_user">
<div class="mini-form">
<div class="form-group">
<label>Username *</label>
<input type="text" name="new_username" pattern="[a-zA-Z0-9_]{3,30}" required>
</div>
<div class="form-group">
<label>Display Name</label>
<input type="text" name="new_display_name" maxlength="100">
</div>
<div class="form-group">
<label>Email *</label>
<input type="email" name="new_email" required>
</div>
<div class="form-group">
<label>Password * (min 8)</label>
<input type="password" name="new_password" minlength="8" required>
</div>
<div class="form-group">
<label>Role</label>
<select name="new_role">
<option value="user">User</option>
<option value="superuser">Superuser</option>
<option value="admin">Admin</option>
<?php if ($is_super): ?>
<option value="superadmin">Superadmin</option>
<?php endif; ?>
</select>
</div>
<div class="form-group">
<label>Rosary Limit (-1 = unlimited)</label>
<input type="number" name="new_rosary_limit" value="1" min="-1">
</div>
<div>
<button type="submit" class="btn btn-primary">Create</button>
<button type="button" class="btn btn-ghost" onclick="toggleCreate()">Cancel</button>
</div>
</div>
</form>
</div>
<!-- Users Table -->
<div class="sessions-table-wrap">
<table class="sessions-table">
<thead>
<tr>
<th>Username</th>
<th>Display Name</th>
<th>Email</th>
<th>Role</th>
<th>Limit</th>
<th>Rosaries</th>
<th>Joined</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $u): ?>
<tr class="user-row">
<td>
<strong><?= htmlspecialchars($u['username']) ?></strong>
<?= !$u['email_confirmed'] ? '<br><span style="font-size:11px;color:#d97706">Unconfirmed</span>' : '' ?>
</td>
<td><?= htmlspecialchars($u['display_name'] ?? '') ?></td>
<td style="font-size:13px"><?= htmlspecialchars($u['email']) ?></td>
<td>
<span class="role-badge" style="background:<?= $role_colors[$u['role']] ?? '#6b7280' ?>">
<?= htmlspecialchars($role_labels[$u['role']] ?? $u['role']) ?>
</span>
</td>
<td><?= $u['rosary_limit'] < 0 ? '&#x221e;' : (int)$u['rosary_limit'] ?></td>
<td><?= (int)$u['rosary_count'] ?></td>
<td style="font-size:12px"><?= date('M j, Y', strtotime($u['created_at'])) ?></td>
<td class="actions">
<?php $can_edit = $is_super || ($u['role'] !== 'superadmin' && $u['role'] !== 'admin'); ?>
<?php if ($can_edit || (int)$u['id'] !== $uid): ?>
<button class="btn btn-sm btn-secondary"
onclick="toggleEdit(<?= $u['id'] ?>)">Edit</button>
<?php endif; ?>
<?php if (!$u['email_confirmed']): ?>
<form method="post" style="display:inline">
<input type="hidden" name="action" value="resend_confirmation">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<button type="submit" class="btn btn-sm"
style="background:#d97706;color:#fff;border:none"
title="Resend confirmation email to <?= htmlspecialchars($u['email']) ?>">
Resend Email
</button>
</form>
<?php endif; ?>
<?php if ((int)$u['id'] !== $uid && ($is_super || $u['role'] !== 'superadmin')): ?>
<form method="post" style="display:inline"
onsubmit="return confirm('Delete user <?= htmlspecialchars(addslashes($u['username'])) ?>? Their sessions will remain.')">
<input type="hidden" name="action" value="delete_user">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
<?php endif; ?>
<!-- Edit Panel -->
<div class="edit-panel" id="edit-<?= $u['id'] ?>">
<form method="post" style="margin-bottom:16px">
<input type="hidden" name="action" value="update_user">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<div class="mini-form">
<div class="form-group">
<label>Display Name</label>
<input type="text" name="upd_display_name" maxlength="100"
value="<?= htmlspecialchars($u['display_name'] ?? '') ?>">
</div>
<div class="form-group">
<label>Email</label>
<input type="email" name="upd_email" required
value="<?= htmlspecialchars($u['email']) ?>">
</div>
<div class="form-group">
<label>Role</label>
<select name="upd_role">
<option value="user" <?= $u['role']==='user' ?'selected':'' ?>>User</option>
<option value="superuser" <?= $u['role']==='superuser' ?'selected':'' ?>>Superuser</option>
<option value="admin" <?= $u['role']==='admin' ?'selected':'' ?>>Admin</option>
<?php if ($is_super): ?>
<option value="superadmin" <?= $u['role']==='superadmin'?'selected':'' ?>>Superadmin</option>
<?php endif; ?>
</select>
</div>
<div class="form-group">
<label>Rosary Limit (-1=unlimited)</label>
<input type="number" name="upd_rosary_limit" min="-1"
value="<?= (int)$u['rosary_limit'] ?>">
</div>
<div>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
</div>
</div>
</form>
<form method="post">
<input type="hidden" name="action" value="reset_password">
<input type="hidden" name="target_id" value="<?= $u['id'] ?>">
<div class="mini-form">
<div class="form-group">
<label>New Password (min 8)</label>
<input type="password" name="new_pass" minlength="8" required>
</div>
<div>
<button type="submit" class="btn btn-secondary btn-sm"
onclick="return confirm('Reset this user\'s password?')">
Reset Password
</button>
</div>
</div>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</main>
</div>
<script>
function toggleCreate() {
var p = document.getElementById('create-panel');
p.classList.toggle('open');
}
function toggleEdit(id) {
var p = document.getElementById('edit-' + id);
p.classList.toggle('open');
}
</script>
</body>
</html>