diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd74533..6614bb9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,45 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+### Added
+
+- Site redesign, phase 3: the fixed indigo/purple brand palette is now applied consistently across the whole site — every remaining page that still used the old ad-hoc indigo/blue colors (home, events, registration flow, auth pages, legal pages, payment result pages, and every Admin/Supervisor/Staff/User tool page) now uses the same design tokens as the redesigned dashboards. Deliberate exceptions (status badges like "Confirmed", multi-option selectors like Email/WhatsApp/Both, the WhatsApp role badge) were left alone since they're meaningfully distinct colors, not leftover brand color.
+- New help guides for account pages (login/register/forgot-password) and My Payments/Donate, replacing the generic fallback.
+- Site redesign, deep pass: beyond the phase 3 color sweep, the home page, events list, and event detail page were rebuilt with real layout upgrades (hero section, icon-chip headers, image placeholders, a sticky two-column tickets layout on event detail). Every Admin/Supervisor/Staff/User tool page now has an icon-chip header consistent with the dashboards, and the smaller public/auth pages (Login, Register, Forgot Password, Activate Account, Reset Password, Site Banner, Registration Success, Payment Success/Failure/Cancel, the standalone Attendee Forms page) were restyled to match, including wrapping the previously bare Attendee Forms page in the site's Navbar/Footer for the first time.
+- 17 new dedicated help guides were added (home, events, event detail, and every remaining Admin/Supervisor/Staff/User tool page), so the whole site now has page-specific help content instead of falling back to the generic guide.
+
+- Site redesign, phase 1 (foundation): fixed indigo/purple design tokens wired into Tailwind and the previously-unstyled shadcn components (Button, Card, Badge, Table, Sheet, etc. were silently missing their CSS variables); a site-wide floating "Need help?" button, present on every page, showing a contextual guide with quick links (e.g. "Browse events", "View my tickets") — content is resolved per page via a new registry, with a general fallback (how to register / manage tickets) for any page without dedicated content yet.
+- Dashboard sidebar (My Events, Profile & Security, Admin, Site Settings) now also shows on `/dashboard/user/profile` and `/dashboard/admin/settings`, not just each role's root page, and is now fixed in place so it no longer scrolls with page content.
+- New shared components (`StatCard`, `QuickActionTile`, `AreaTrendChart`) scaffolded for the upcoming dashboard redesign.
+- Contact now has its own dedicated page (`/contact`) instead of being a scroll-to section on the home page — same org email/phone/address, restyled to match the new design.
+- The general (public) and My Events help guides now go into much more detail: the general guide explains guest vs. account-required registration with quick links to log in or create an account, and the My Events guide walks through registration status badges, attendee forms, early-bird pricing, editing/cancelling, bulk ticket actions, and the difference between paying a balance, payment history, and donations. The Admin, Supervisor, Staff, and Site Settings pages now have their own dedicated help guides too, instead of falling back to the general one.
+- Site redesign, phase 2 (dashboards): Admin and Supervisor dashboards now show a KPI row (active events, revenue, donations, registrations, tickets sold — each with a "vs last month" comparison), a revenue trend chart for the current month, and a top-performing-events table, backed by a new `GET /api/stats/overview` endpoint. Staff and My Events dashboards are visually reskinned onto the new design tokens only — no new financial data for Staff, by design.
+- Admin/Supervisor dashboard stat tiles now link somewhere useful: Revenue/Donations/Tickets sold jump straight into the matching Reports report, pre-filtered to this month (new `?report=&range=this_month` deep-linking support in Reports); Active events links to Manage events.
+- Reports revenue trend chart now shows gridlines, axis labels, and a marker dot per data point, so a sparse day-or-two of data still renders as a visible chart instead of an empty-looking box.
+- WhatsApp API management moved from its own page into a new "WhatsApp" tab on Site Settings (`/dashboard/admin/settings?tab=whatsapp`); the old `/dashboard/admin/whatsapp` route now just redirects there.
+- Site Settings restyled to match the rest of the redesign (icon tab bar, card layout, brand colors).
+- My Events dashboard now shows event thumbnails on registrations, upcoming events, and tickets, with upcoming events and tickets laid out side by side.
+- Profile & Security page restyled into a two-column layout with a new "Account activity" section showing recent logins and password changes (device + timestamp), backed by a new append-only `SecurityEvent` log and `GET /api/users/activity` endpoint.
+- Help guide modal now animates in instead of appearing abruptly, and got a general visual polish pass (rounded corners, active-tab styling, hover states).
+
+### Fixed
+
+- Admin/Supervisor dashboard "Revenue" figures (today/week/month) were overcounting: they didn't exclude donation-application "legs" (the money was already counted once via the original donation) the way Reports and the admin payments-stats endpoint already did. Both endpoints now use the same exclusion.
+- Admin/Supervisor dashboard "Revenue" KPI, trend chart, and top-performing-events table were undercounting the other way — donations themselves weren't being counted as revenue at all, only tracked in the separate "Donations" figure. Donations now count toward revenue (donation-application legs are still excluded either way, since that money was already counted once via the original donation).
+
+### Changed
+
+- Reports' "Reporting guide" is now powered by the new site-wide help system instead of a Reports-only modal — same content, opened from the floating help button instead of a sidebar button.
+- Navbar/bottom nav link colors are now fixed to the site's indigo brand color instead of the admin-configurable accent color; the accent color setting now only affects the org name text next to the logo.
+- "Contact" links across the nav and help content now point to `/contact` instead of `/#contact`.
+- Admin/Supervisor dashboard quick actions no longer list "Manage sections" or "Event tickets & printing" (redundant with the Events and At-the-door pages) or "Manage WhatsApp API" (moved into Site Settings). Staff keeps "Event tickets & printing" — their only other tool is ticket scanning, and they can't reach At-the-door.
+- Admin dashboard no longer shows ticket-scanning stats ("Recent scans") — kept on Supervisor/Staff, where it's actually actionable.
+- The old hand-rolled `Button` component (`components/shared/Button.tsx`) is gone — its one remaining caller now uses the standard `components/ui/button.tsx`.
+
+### Removed
+
+- Deleted dead code found while migrating the last `Button` usage: an unused `EventForm` component, a stale pre-redesign `Reports.tsx`, and a stray `ReportsV2_backup.tsx` — none were imported anywhere.
+
## [1.4.2] - 2026-08-06
### Fixed
diff --git a/backend/prisma/migrations/20260806120000_add_security_events/migration.sql b/backend/prisma/migrations/20260806120000_add_security_events/migration.sql
new file mode 100644
index 0000000..bec0191
--- /dev/null
+++ b/backend/prisma/migrations/20260806120000_add_security_events/migration.sql
@@ -0,0 +1,20 @@
+-- CreateEnum
+CREATE TYPE "SecurityEventType" AS ENUM ('login', 'password_changed', 'password_reset');
+
+-- CreateTable
+CREATE TABLE "SecurityEvent" (
+ "id" TEXT NOT NULL,
+ "userId" TEXT,
+ "type" "SecurityEventType" NOT NULL,
+ "ip" TEXT,
+ "device" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "SecurityEvent_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "SecurityEvent_userId_createdAt_idx" ON "SecurityEvent"("userId", "createdAt");
+
+-- AddForeignKey
+ALTER TABLE "SecurityEvent" ADD CONSTRAINT "SecurityEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index dbc1db2..9f4f75a 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -47,6 +47,12 @@ enum EventCostType {
per_item
}
+enum SecurityEventType {
+ login
+ password_changed
+ password_reset
+}
+
model User {
id String @id @default(uuid())
name String
@@ -78,6 +84,8 @@ model User {
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
+
+ securityEvents SecurityEvent[]
}
model Event {
@@ -291,6 +299,21 @@ model PasswordReset {
@@index([userId])
}
+// Append-only "Account activity" log for the Profile & Security page (logins, password
+// changes). Nullable FK with SetNull (not Cascade) so entries survive account
+// close/anonymization, the same pattern EventCashup uses for its performedBy audit trail.
+model SecurityEvent {
+ id String @id @default(uuid())
+ userId String?
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+ type SecurityEventType
+ ip String?
+ device String?
+ createdAt DateTime @default(now())
+
+ @@index([userId, createdAt])
+}
+
model EventAttachment {
id String @id @default(uuid())
eventId String
diff --git a/backend/src/controllers/statsController.js b/backend/src/controllers/statsController.js
index a63d3cd..4ecab53 100644
--- a/backend/src/controllers/statsController.js
+++ b/backend/src/controllers/statsController.js
@@ -1,5 +1,6 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
+const { isDonationLeg } = require('../utils/cashupUtils');
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
@@ -56,19 +57,26 @@ function getActiveEventsCount() {
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
}
+// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
+// money — see isDonationLeg in cashupUtils.js. Matches the same exclusion already applied in
+// paymentController.getPaymentStats and every report; without it these totals overcount.
+const EXCLUDE_DONATION_LEGS = {
+ NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
+};
+
async function computePaymentStats({ includeWeekMonth }) {
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
const queries = [
- prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay } } }),
+ prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
];
if (includeWeekMonth) {
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
queries.push(
- prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek } } }),
- prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth } } }),
+ prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
+ prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
);
}
@@ -134,8 +142,148 @@ const getAdminDashboardStats = async (req, res) => {
}
};
+// Subtracts `months` calendar months from `date`, clamping to the last day of the target
+// month if the original day doesn't exist there (e.g. 31 Mar - 1 month -> 28/29 Feb, not
+// 3 Mar which is what naive setMonth() arithmetic would silently produce).
+function subtractMonths(date, months) {
+ const d = new Date(date.getTime());
+ const originalDate = d.getDate();
+ d.setMonth(d.getMonth() - months, 1); // move to the 1st of the target month first, so setDate below can't spill into the following month
+ const daysInTargetMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
+ d.setDate(Math.min(originalDate, daysInTargetMonth));
+ return d;
+}
+
+// Returns a rolling one-month window ending at the end of today (so today is fully
+// included), and the equal-length window immediately before it — e.g. if today is 8 June,
+// thisStart/thisEnd covers 9 May through the end of 8 June, and lastStart/lastEnd covers
+// 9 April through 8 May. This replaced a calendar-month-to-date window (1st of the month
+// through now), which under-counted for most of the month — e.g. on the 3rd it only
+// covered 3 days' worth of data instead of a full trailing month.
+function trailingMonthRanges(now = new Date()) {
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
+ const thisEnd = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); // exclusive upper bound, covers all of today
+ const thisStart = subtractMonths(thisEnd, 1);
+ const lastEnd = thisStart;
+ const lastStart = subtractMonths(thisStart, 1);
+ return { thisStart, thisEnd, lastStart, lastEnd };
+}
+
+function pctChange(current, previous) {
+ if (previous === 0) return current === 0 ? 0 : null;
+ return ((current - previous) / previous) * 100;
+}
+
+function fetchPaymentsWithOriginal(where) {
+ return prisma.payment.findMany({
+ where,
+ select: {
+ id: true, amount: true, isDonation: true, originalPaymentId: true, eventId: true, createdAt: true,
+ originalPayment: { select: { isDonation: true } },
+ },
+ });
+}
+
+// Classifies each payment the same way computeEventFinancials does (cashupUtils.js):
+// donation-application legs are excluded entirely (not new money — see isDonationLeg).
+// Everything else — registration payments, donations themselves, and refunds of either —
+// is real money in/out and counts toward "revenue". "donations" is a breakdown *within*
+// that revenue (donations plus any refund of a donation), not a separate bucket.
+function classifyAndSum(payments) {
+ let revenue = 0;
+ let donations = 0;
+ for (const p of payments) {
+ if (isDonationLeg(p)) continue;
+ revenue += p.amount;
+ if (p.isDonation) {
+ donations += p.amount;
+ } else if (p.originalPaymentId && p.amount < 0 && p.originalPayment?.isDonation) {
+ donations += p.amount;
+ }
+ }
+ return { revenue, donations };
+}
+
+// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
+// month) vs the month before that, a daily revenue trend for the trailing month,
+// and the top performing events overall.
+// @route GET /api/stats/overview
+// @access Private/Supervisor+
+const getOverviewStats = async (req, res) => {
+ try {
+ const now = new Date();
+ const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
+
+ const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
+ fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
+ fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
+ prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
+ prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
+ prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
+ prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
+ getActiveEventsCount(),
+ ]);
+
+ const thisTotals = classifyAndSum(thisPayments);
+ const lastTotals = classifyAndSum(lastPayments);
+ const thisTicketsSold = thisTickets._sum.quantity || 0;
+ const lastTicketsSold = lastTickets._sum.quantity || 0;
+
+ // Daily revenue trend for the trailing month, from the payment rows already fetched above.
+ // Same definition as classifyAndSum: everything except donation-application legs.
+ const trendMap = new Map();
+ for (const p of thisPayments) {
+ if (isDonationLeg(p)) continue;
+ const day = p.createdAt.toISOString().slice(0, 10);
+ trendMap.set(day, (trendMap.get(day) || 0) + p.amount);
+ }
+ const trend = Array.from(trendMap.entries())
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([date, revenue]) => ({ date, revenue }));
+
+ // Top performing events overall (not scoped to this month), ranked by revenue — same
+ // "real revenue" definition as the KPI above: everything except application legs.
+ const revenueByEvent = await prisma.payment.groupBy({
+ by: ['eventId'],
+ where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
+ _sum: { amount: true },
+ orderBy: { _sum: { amount: 'desc' } },
+ take: 5,
+ });
+ const topEventIds = revenueByEvent.map(r => r.eventId).filter(Boolean);
+ const [events, regCounts, ticketSums] = await Promise.all([
+ prisma.event.findMany({ where: { id: { in: topEventIds } }, select: { id: true, title: true } }),
+ prisma.registration.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds }, status: { not: 'cancelled' } }, _count: { _all: true } }),
+ prisma.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds } }, _sum: { quantity: true } }),
+ ]);
+ const eventTitleById = Object.fromEntries(events.map(e => [e.id, e.title]));
+ const regCountById = Object.fromEntries(regCounts.map(r => [r.eventId, r._count._all]));
+ const ticketSumById = Object.fromEntries(ticketSums.map(t => [t.eventId, t._sum.quantity || 0]));
+ const topEvents = revenueByEvent.map(r => ({
+ eventId: r.eventId,
+ title: eventTitleById[r.eventId] || 'Untitled event',
+ revenue: r._sum.amount || 0,
+ registrations: regCountById[r.eventId] || 0,
+ ticketsSold: ticketSumById[r.eventId] || 0,
+ }));
+
+ res.json({
+ activeEvents,
+ registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
+ ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
+ revenue: { thisMonth: thisTotals.revenue, lastMonth: lastTotals.revenue, pctChange: pctChange(thisTotals.revenue, lastTotals.revenue) },
+ donations: { thisMonth: thisTotals.donations, lastMonth: lastTotals.donations, pctChange: pctChange(thisTotals.donations, lastTotals.donations) },
+ trend,
+ topEvents,
+ });
+ } catch (error) {
+ res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
+ }
+};
+
module.exports = {
getStaffDashboardStats,
getSupervisorDashboardStats,
getAdminDashboardStats,
+ getOverviewStats,
};
\ No newline at end of file
diff --git a/backend/src/controllers/userController.js b/backend/src/controllers/userController.js
index a741920..18a0e6c 100644
--- a/backend/src/controllers/userController.js
+++ b/backend/src/controllers/userController.js
@@ -2,6 +2,7 @@ const prisma = require('../config/db');
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
+const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const axios = require('axios');
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -273,6 +274,7 @@ const loginUser = async (req, res) => {
// Send login notification in the background
sendLoginNotification(updated, req).catch(() => {});
+ logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: updated.id,
@@ -399,6 +401,7 @@ const updateUserProfile = async (req, res) => {
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
+ logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({
@@ -744,6 +747,8 @@ const resetPassword = async (req, res) => {
prisma.passwordReset.update({ where: { token }, data: { used: true } })
]);
+ logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
+
res.json({ message: 'Password has been reset successfully' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -929,6 +934,18 @@ const closeAccount = async (req, res) => {
}
};
+// @desc Recent account activity (logins, password changes) for the current user
+// @route GET /api/users/activity
+// @access Private
+const getMyActivity = async (req, res) => {
+ try {
+ const events = await getRecentSecurityEvents(req.user.id, 10);
+ res.json(events);
+ } catch (error) {
+ res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
+ }
+};
+
module.exports = {
registerUser,
loginUser,
@@ -946,4 +963,5 @@ module.exports = {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
+ getMyActivity,
};
\ No newline at end of file
diff --git a/backend/src/controllers/whatsappController.js b/backend/src/controllers/whatsappController.js
index 0dee38e..ba70f0d 100644
--- a/backend/src/controllers/whatsappController.js
+++ b/backend/src/controllers/whatsappController.js
@@ -195,7 +195,7 @@ const handleWebhook = async (req, res) => {
const { getSettingSync } = require('../utils/settingsCache');
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
- const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/whatsapp`;
+ const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
await sendMail({
to: adminEmail,
diff --git a/backend/src/routes/statsRoutes.js b/backend/src/routes/statsRoutes.js
index 16cd881..90267ce 100644
--- a/backend/src/routes/statsRoutes.js
+++ b/backend/src/routes/statsRoutes.js
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
-const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats } = require('../controllers/statsController');
+const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
@@ -10,4 +10,8 @@ router.get('/staff', protect, staff, getStaffDashboardStats);
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
router.get('/admin', protect, admin, getAdminDashboardStats);
+// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
+// Staff never sees financial data, so this has no staff-accessible route.
+router.get('/overview', protect, supervisor, getOverviewStats);
+
module.exports = router;
\ No newline at end of file
diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.js
index 1a9c84d..1f8f574 100644
--- a/backend/src/routes/userRoutes.js
+++ b/backend/src/routes/userRoutes.js
@@ -17,6 +17,7 @@ const {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
+ getMyActivity,
} = require('../controllers/userController');
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
@@ -34,6 +35,7 @@ router.route('/profile')
router.post('/revoke-sessions', protect, revokeMySession);
router.post('/close-account', protect, closeAccount);
+router.get('/activity', protect, getMyActivity);
// Admin routes
router.route('/')
diff --git a/backend/src/utils/securityEvents.js b/backend/src/utils/securityEvents.js
new file mode 100644
index 0000000..d90a323
--- /dev/null
+++ b/backend/src/utils/securityEvents.js
@@ -0,0 +1,34 @@
+const prisma = require('../config/db');
+const { describeUserAgent } = require('./userAgent');
+
+// Fire-and-forget by design — a logging failure must never break login/password-change,
+// so this swallows its own errors rather than propagating them to the caller (same
+// posture as the existing email/WhatsApp notification sends elsewhere in this codebase).
+async function logSecurityEvent({ userId, type, ip, userAgent }) {
+ try {
+ await prisma.securityEvent.create({
+ data: {
+ userId: userId || null,
+ type,
+ ip: ip || null,
+ device: describeUserAgent(userAgent),
+ },
+ });
+ } catch (e) {
+ console.error('Failed to log security event:', e?.message);
+ }
+}
+
+// Recent activity for a user's Profile & Security page — no raw IP in the response,
+// just what the mockup shows (what happened, on what device, when).
+async function getRecentSecurityEvents(userId, limit = 10) {
+ const rows = await prisma.securityEvent.findMany({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ take: limit,
+ select: { id: true, type: true, device: true, createdAt: true },
+ });
+ return rows;
+}
+
+module.exports = { logSecurityEvent, getRecentSecurityEvents };
diff --git a/backend/src/utils/userAgent.js b/backend/src/utils/userAgent.js
new file mode 100644
index 0000000..466d175
--- /dev/null
+++ b/backend/src/utils/userAgent.js
@@ -0,0 +1,36 @@
+// Lightweight User-Agent -> "Browser on OS" summary for account-activity logging.
+// No UA-parsing library is installed in this project; this covers the common cases
+// (desktop/mobile browsers, major OSes) without pulling one in for a single display string.
+
+function detectBrowser(ua) {
+ if (/Edg\//.test(ua)) return 'Edge';
+ if (/OPR\//.test(ua) || /Opera/.test(ua)) return 'Opera';
+ if (/SamsungBrowser/.test(ua)) return 'Samsung Internet';
+ if (/CriOS/.test(ua)) return 'Chrome';
+ if (/FxiOS/.test(ua)) return 'Firefox';
+ if (/Firefox\//.test(ua)) return 'Firefox';
+ if (/Chrome\//.test(ua)) return 'Chrome';
+ if (/Safari\//.test(ua) && /Version\//.test(ua)) return 'Safari';
+ return null;
+}
+
+function detectOS(ua) {
+ if (/Windows/.test(ua)) return 'Windows';
+ if (/iPhone|iPad|iPod/.test(ua)) return 'iOS';
+ if (/Mac OS X/.test(ua)) return 'macOS';
+ if (/Android/.test(ua)) return 'Android';
+ if (/Linux/.test(ua)) return 'Linux';
+ return null;
+}
+
+function describeUserAgent(uaString) {
+ if (!uaString) return 'Unknown device';
+ const browser = detectBrowser(uaString);
+ const os = detectOS(uaString);
+ if (browser && os) return `${browser} on ${os}`;
+ if (browser) return browser;
+ if (os) return os;
+ return 'Unknown device';
+}
+
+module.exports = { describeUserAgent };
diff --git a/frontend/src/app/(auth)/forgot-password/page.tsx b/frontend/src/app/(auth)/forgot-password/page.tsx
index a16f977..c442e75 100644
--- a/frontend/src/app/(auth)/forgot-password/page.tsx
+++ b/frontend/src/app/(auth)/forgot-password/page.tsx
@@ -3,6 +3,7 @@ import { useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
+import { KeyRound } from "lucide-react";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
@@ -32,16 +33,21 @@ export default function ForgotPasswordPage() {
return (
-
-
-
Forgot your password?
-
Enter your account email and we'll send you a link to reset your password.
+
+
+
+
+
+
+
Forgot your password?
+
+
Enter your account email and we'll send you a link to reset your password.
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index ea3afae..2b101a1 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -3,14 +3,23 @@ import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { LoginForm } from "@/components/auth/LoginForm";
+import { LogIn } from "lucide-react";
export default function LoginPage() {
return (
-
-
-
Login
+
+
+
+
+
+
+
+
Welcome back
+
Log in to manage your events and tickets.
+
+
}>
diff --git a/frontend/src/app/(auth)/register/page.tsx b/frontend/src/app/(auth)/register/page.tsx
index 231f2e2..e942c46 100644
--- a/frontend/src/app/(auth)/register/page.tsx
+++ b/frontend/src/app/(auth)/register/page.tsx
@@ -3,14 +3,23 @@ import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { RegisterForm } from "@/components/auth/RegisterForm";
+import { UserPlus } from "lucide-react";
export default function RegisterPage() {
return (
-
-
-
Create Account
+
+
+
+
+
+
+
+
Create account
+
One account for all your registrations and tickets.
}
-
- {status === "FAILED" && (
-
- The session has failed. The system will attempt to auto-restart. You can also
- restart manually below.
-
- )}
-
- {status && POLLING_STATUSES.has(status) && (
-
- doAction("start")}
- />
- doAction("restart")}
- />
- {
- if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
- doAction("logout");
- }}
- />
-
-
-
- {/* Instance management */}
-
-
Instance Management
-
- Create a brand-new instance or permanently delete the current one. Deleting will
- require you to set up a new instance.
-
-
- doAction("create-instance")}
- />
- {
- if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
- doAction("delete-instance");
- }}
- />
-
))}
@@ -112,37 +120,18 @@ export default function StaffDashboardPage() {
-
Scanner stats
- {loadingStats &&
Loading stats…
}
- {stats && (
-
-
-
Today
-
{stats.totalToday}
-
-
-
My scans
-
{stats.myToday}
-
-
-
Last hour
-
{stats.lastHour}
-
-
- )}
-
- {stats?.byStaff?.length > 0 && (
-
-
Today by staff
-
- {stats.byStaff.map((s: any) => (
-
- {s.name || 'Staff'}
- {s.count}
-
- ))}
-
-
+
Today by staff
+ {stats?.byStaff?.length > 0 ? (
+
+ {stats.byStaff.map((s: any) => (
+
+ {s.name || 'Staff'}
+ {s.count}
+
+ ))}
+
+ ) : (
+
No scans recorded yet today.
)}
diff --git a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx
index 6ba6019..6d3aa47 100644
--- a/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx
+++ b/frontend/src/app/dashboard/staff/ticket-scanning/page.tsx
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
+import { QrCode } from "lucide-react";
export default function TicketScanningPage() {
const router = useRouter();
@@ -246,9 +247,14 @@ export default function TicketScanningPage() {
-
-
Ticket Scanning
- router.push('/dashboard')}>Back
+
+
+
+
+
+
Ticket Scanning
+
+ router.push('/dashboard')}>Back
Use the button to start/stop scanning. The back camera will be used when available.
@@ -363,7 +369,7 @@ export default function TicketScanningPage() {
{confirmModal && (
@@ -497,19 +503,19 @@ function EmailAttendeesPageInner() {
{/* Tabs like on Payments page */}
-
diff --git a/frontend/src/app/dashboard/supervisor/event-options/page.tsx b/frontend/src/app/dashboard/supervisor/event-options/page.tsx
index 8cd545c..8bb6f5b 100644
--- a/frontend/src/app/dashboard/supervisor/event-options/page.tsx
+++ b/frontend/src/app/dashboard/supervisor/event-options/page.tsx
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
+import { Ticket } from "lucide-react";
// Format a Date (or date-like input) to the value expected by
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -85,8 +86,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
)}
);
diff --git a/frontend/src/app/dashboard/supervisor/payments/page.tsx b/frontend/src/app/dashboard/supervisor/payments/page.tsx
index 6b4bdd4..b283dd8 100644
--- a/frontend/src/app/dashboard/supervisor/payments/page.tsx
+++ b/frontend/src/app/dashboard/supervisor/payments/page.tsx
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
+import { Wallet } from "lucide-react";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -85,7 +86,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
return (
Your details and payments are handled securely from registration through to the day of the event.
+
+
diff --git a/frontend/src/app/payment/cancel/page.tsx b/frontend/src/app/payment/cancel/page.tsx
index 09226f8..5ddc70d 100644
--- a/frontend/src/app/payment/cancel/page.tsx
+++ b/frontend/src/app/payment/cancel/page.tsx
@@ -3,6 +3,7 @@
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
+import { AlertCircle } from "lucide-react";
import React from "react";
export default function PaymentCancelPage() {
@@ -10,10 +11,17 @@ export default function PaymentCancelPage() {
return (
-
-
Payment cancelled
-
Your checkout was cancelled. You can resume payment from your dashboard later.
- router.push('/dashboard/user')}>Go to Dashboard
+
+
+
+
+
+
+
Payment cancelled
+
+
Your checkout was cancelled. You can resume payment from your dashboard later.
+ router.push('/dashboard/user')}>Go to Dashboard
+
diff --git a/frontend/src/app/payment/failure/page.tsx b/frontend/src/app/payment/failure/page.tsx
index 3c888a9..b8803ec 100644
--- a/frontend/src/app/payment/failure/page.tsx
+++ b/frontend/src/app/payment/failure/page.tsx
@@ -2,6 +2,7 @@
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
+import { XCircle } from "lucide-react";
import React from "react";
export default function PaymentFailurePage() {
@@ -9,10 +10,17 @@ export default function PaymentFailurePage() {
return (
-
-
Payment failed
-
Unfortunately, your payment failed. Please try again or use a different method.
- router.push('/dashboard/user')}>Go to Dashboard
+
+
+
+
+
+
+
Payment failed
+
+
Unfortunately, your payment failed. Please try again or use a different method.
+ router.push('/dashboard/user')}>Go to Dashboard
+
diff --git a/frontend/src/app/payment/success/page.tsx b/frontend/src/app/payment/success/page.tsx
index 8dfa782..ce714f3 100644
--- a/frontend/src/app/payment/success/page.tsx
+++ b/frontend/src/app/payment/success/page.tsx
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
+import { CheckCircle2 } from "lucide-react";
import React from "react";
export default function PaymentSuccessPage() {
@@ -14,25 +15,32 @@ export default function PaymentSuccessPage() {
return (
-
-
Payment successful
-
- Thank you! Your payment was received. Your tickets will be sent to you shortly.
- {contactEmail && (
- <>
- {" "}If you have any questions, contact us at{" "}
-
- {contactEmail}
- .
- >
- )}
-
- router.push('/dashboard/user')}
- >
- Go to Dashboard
-
+
+
+
+
+
+
+
Payment successful
+
+
+ Thank you! Your payment was received. Your tickets will be sent to you shortly.
+ {contactEmail && (
+ <>
+ {" "}If you have any questions, contact us at{" "}
+
+ {contactEmail}
+ .
+ >
+ )}
+
+ router.push('/dashboard/user')}
+ >
+ Go to Dashboard
+
+
{notificationPref === "whatsapp"
? "Payment instructions have been sent to your WhatsApp."
@@ -1197,12 +1197,12 @@ export default function SelfServicePage() {
? "Payment instructions have been sent via email and WhatsApp."
: "Payment instructions have been emailed to you."}
-
Payment can also be made at the door — cash and card accepted.
+
Payment can also be made at the door — cash and card accepted.
)}
-
+
{countdown}
Returning to registration in {countdown}s
@@ -1210,7 +1210,7 @@ export default function SelfServicePage() {
OK — Next Person
diff --git a/frontend/src/app/set-banner/page.tsx b/frontend/src/app/set-banner/page.tsx
index 09a55f4..b7cf5b5 100644
--- a/frontend/src/app/set-banner/page.tsx
+++ b/frontend/src/app/set-banner/page.tsx
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
+import { Megaphone } from "lucide-react";
type BannerType = "info" | "warning" | "success" | "danger";
@@ -107,8 +108,13 @@ export default function SetBannerPage() {
return (
-
Site Banner
-
Set a message that appears at the top of every page for visitors within the scheduled window.
+
+
+
+
+
Site Banner
+
+
Set a message that appears at the top of every page for visitors within the scheduled window.