diff --git a/src/App.js b/src/App.js
index 074e4fd..ef8b6a7 100644
--- a/src/App.js
+++ b/src/App.js
@@ -17,6 +17,7 @@ import Logout from "./app/pages/Logout";
import Stats from './app/pages/Stats';
import Profile from './app/pages/Profile';
import Friends from './app/pages/Friends';
+import Admin from './admin/Admin';
// Demo Pages
import DemoNavbar from "./demo/components/DemoNavbar";
import DemoBanner from "./demo/components/DemoBanner";
@@ -30,6 +31,8 @@ import axios from 'axios';
const constants = require('./app/constants');
const theme = require('./styling/theme');
+const ADMIN_EMAILS = (process.env.REACT_APP_ADMIN_EMAILS || '').split(',').map(e => e.trim()).filter(Boolean);
+
const App = () => {
axios.defaults.withCredentials = true;
const [user, setUser] = useState(null);
@@ -135,7 +138,7 @@ function AppContent({ user, setUserChanged, newTypes, selectedTags, setSelectedT
return (
{showNormalNavbar && (
-
+
)}
{showDemoNavbarWithSignIn && (
@@ -148,6 +151,7 @@ function AppContent({ user, setUserChanged, newTypes, selectedTags, setSelectedT
: } />
: } />
: } />
+ : } />
} />
} />
} />
diff --git a/src/admin/Admin.css b/src/admin/Admin.css
new file mode 100644
index 0000000..d81e659
--- /dev/null
+++ b/src/admin/Admin.css
@@ -0,0 +1,234 @@
+.admin-dashboard {
+ min-height: 100vh;
+ background-color: #2c3e50;
+ color: #ffffff;
+ padding: 32px 24px;
+}
+
+.admin-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 16px;
+ margin-bottom: 32px;
+}
+
+.admin-header h1 {
+ font-size: 1.75rem;
+ font-weight: 700;
+ margin: 0;
+ color: #ffc107;
+}
+
+.admin-range-select {
+ background-color: #34495e;
+ color: #ffffff;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 0.9rem;
+ cursor: pointer;
+ outline: none;
+ transition: border-color 0.2s ease;
+}
+
+.admin-range-select:focus {
+ border-color: #ffc107;
+}
+
+.admin-stat-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 16px;
+ margin-bottom: 40px;
+}
+
+.admin-stat-card {
+ background-color: #34495e;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ padding: 20px 24px;
+ text-align: center;
+}
+
+.admin-stat-card .stat-label {
+ font-size: 0.8rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: #9ca3af;
+ margin-bottom: 8px;
+}
+
+.admin-stat-card .stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: #ffc107;
+ line-height: 1.1;
+}
+
+.admin-charts {
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+}
+
+.admin-chart-block {
+ background-color: #34495e;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 10px;
+ padding: 24px;
+}
+
+.admin-chart-block h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #e5e7eb;
+ margin-bottom: 20px;
+}
+
+.admin-chart-wrapper {
+ height: 260px;
+ position: relative;
+}
+
+.admin-users-block {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.admin-users-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.admin-users-header h2 {
+ margin-bottom: 0;
+}
+
+.admin-users-controls {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.admin-users-page-info {
+ font-size: 0.8rem;
+ color: #9ca3af;
+}
+
+.admin-users-loading {
+ display: flex;
+ justify-content: center;
+ padding: 24px 0;
+}
+
+.admin-users-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.admin-users-table th,
+.admin-users-table td {
+ padding: 10px 14px;
+ text-align: left;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.admin-users-table th {
+ color: #9ca3af;
+ font-weight: 600;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ background-color: rgba(0, 0, 0, 0.15);
+ white-space: nowrap;
+}
+
+.admin-users-table td {
+ color: #e5e7eb;
+}
+
+.admin-users-table tbody tr:hover {
+ background-color: rgba(255, 255, 255, 0.04);
+}
+
+.admin-users-table th:first-child,
+.admin-users-table td:first-child {
+ width: 48px;
+ color: #9ca3af;
+}
+
+.admin-sort-col {
+ cursor: pointer;
+ user-select: none;
+}
+
+.admin-sort-col:hover {
+ color: #ffc107;
+}
+
+.admin-sort-icon {
+ margin-left: 4px;
+ font-size: 0.7rem;
+ color: #ffc107;
+}
+
+.admin-sort-icon.inactive {
+ color: #6b7280;
+}
+
+.admin-no-email {
+ color: #6b7280;
+}
+
+.admin-total-records {
+ text-align: right;
+ padding-right: 20px;
+ font-variant-numeric: tabular-nums;
+}
+
+td.admin-total-records {
+ color: #ffc107;
+}
+
+.admin-page-btn {
+ background-color: #34495e;
+ color: #e5e7eb;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 6px;
+ padding: 6px 16px;
+ font-size: 1rem;
+ cursor: pointer;
+ transition: background-color 0.15s ease;
+}
+
+.admin-page-btn:hover:not(:disabled) {
+ background-color: rgba(255, 193, 7, 0.15);
+ border-color: #ffc107;
+ color: #ffc107;
+}
+
+.admin-page-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.admin-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ background-color: #2c3e50;
+}
+
+.admin-loading-text {
+ color: #9ca3af;
+ margin-top: 12px;
+}
diff --git a/src/admin/Admin.jsx b/src/admin/Admin.jsx
new file mode 100644
index 0000000..34dc0ad
--- /dev/null
+++ b/src/admin/Admin.jsx
@@ -0,0 +1,322 @@
+import React, { useState, useEffect } from 'react';
+import axios from 'axios';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+} from 'chart.js';
+import { Line } from 'react-chartjs-2';
+import './Admin.css';
+
+const constants = require('../app/constants');
+
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend
+);
+
+const LINE_COLOR = '#ffc107';
+const GRID_COLOR = 'rgba(229, 231, 235, 0.15)';
+const TICK_COLOR = '#e5e7eb';
+
+function buildLineData(label, entries, xKey, color) {
+ return {
+ labels: entries.map(e => e[xKey]),
+ datasets: [
+ {
+ label,
+ data: entries.map(e => e.count),
+ borderColor: color,
+ backgroundColor: color + '33',
+ tension: 0.3,
+ pointRadius: 4,
+ pointHoverRadius: 6,
+ fill: true,
+ },
+ ],
+ };
+}
+
+function buildLineOptions(yTitle) {
+ return {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ title: { display: false },
+ },
+ scales: {
+ y: {
+ beginAtZero: true,
+ ticks: { color: TICK_COLOR, stepSize: 1 },
+ title: { display: true, text: yTitle, color: TICK_COLOR },
+ grid: { color: GRID_COLOR },
+ },
+ x: {
+ ticks: { color: TICK_COLOR, maxRotation: 45, minRotation: 0 },
+ grid: { color: GRID_COLOR },
+ },
+ },
+ };
+}
+
+function avg(arr) {
+ if (!arr.length) return 0;
+ return Math.round(arr.reduce((s, d) => s + d.count, 0) / arr.length);
+}
+
+function formatDate(iso) {
+ if (!iso) return —;
+ return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
+}
+
+function UsersTable() {
+ const [page, setPage] = useState(1);
+ const [sort, setSort] = useState('lastActiveAt');
+ const [order, setOrder] = useState('desc');
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ setLoading(true);
+ axios.get(
+ `${constants.SERVER_URL}/api/admin/users?page=${page}&sort=${sort}&order=${order}`,
+ { withCredentials: true }
+ )
+ .then(res => { if (res.data.success) setData(res.data); })
+ .catch(() => {})
+ .finally(() => setLoading(false));
+ }, [page, sort, order]);
+
+ const SORT_FIELDS = ['lastActiveAt', 'createdAt', 'totalRecords'];
+
+ const handleSort = (field) => {
+ if (!SORT_FIELDS.includes(field)) return;
+ if (sort === field) {
+ setOrder(o => o === 'desc' ? 'asc' : 'desc');
+ } else {
+ setSort(field);
+ setOrder('desc');
+ }
+ setPage(1);
+ };
+
+ const sortIcon = (field) => {
+ if (sort !== field) return ↕;
+ return {order === 'desc' ? '↓' : '↑'};
+ };
+
+ return (
+
+
+
All Users
+
+ {data && (
+
+ Page {data.page} of {data.totalPages} ({data.total} total)
+
+ )}
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+
+
+ | # |
+ Display Name |
+ Email |
+ handleSort('lastActiveAt')}
+ >
+ Last Active {sortIcon('lastActiveAt')}
+ |
+ handleSort('createdAt')}
+ >
+ Joined {sortIcon('createdAt')}
+ |
+ handleSort('totalRecords')}
+ >
+ Total Records {sortIcon('totalRecords')}
+ |
+
+
+
+ {data?.users.map((u, i) => (
+
+ | {(data.page - 1) * 10 + i + 1} |
+ {u.displayName} |
+ {u.email || —} |
+ {formatDate(u.lastActiveAt)} |
+ {formatDate(u.createdAt)} |
+ {u.totalRecords ?? 0} |
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+const Admin = () => {
+ const [range, setRange] = useState(30);
+ const [stats, setStats] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchStats = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const res = await axios.get(
+ `${constants.SERVER_URL}/api/admin/stats?range=${range}`,
+ { withCredentials: true }
+ );
+ if (res.data.success) {
+ setStats(res.data);
+ } else {
+ setError('Failed to load admin stats.');
+ }
+ } catch (err) {
+ if (err.response?.status === 403) {
+ setError('Access denied.');
+ } else {
+ setError('Error loading admin stats.');
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+ fetchStats();
+ }, [range]);
+
+ if (loading) {
+ return (
+
+
+
+ Loading...
+
+
Loading dashboard...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ const dauData = buildLineData('Daily Active Users', stats.dailyActiveUsers, 'date', LINE_COLOR);
+ const mauData = buildLineData('Monthly Active Users', stats.monthlyActiveUsers, 'month', '#4ECDC4');
+ const signupsData = buildLineData('New Signups', stats.newSignupsPerDay, 'date', '#74b9ff');
+
+ const avgDAU = avg(stats.dailyActiveUsers);
+ const avgMAU = avg(stats.monthlyActiveUsers);
+ const totalSignups = stats.newSignupsPerDay.reduce((s, d) => s + d.count, 0);
+
+ return (
+
+
+
Admin Dashboard
+
+
+
+
+
+
Total Users
+
{stats.totalUsers.toLocaleString()}
+
+
+
Avg Daily Active
+
{avgDAU.toLocaleString()}
+
+
+
Avg Monthly Active
+
{avgMAU.toLocaleString()}
+
+
+
New Signups (range)
+
{totalSignups.toLocaleString()}
+
+
+
+
+
+
+
+
Daily Active Users
+
+
+
+
+
+
+
Monthly Active Users
+
+
+
+
+
+
+
New Signups Per Day
+
+
+
+
+
+
+ );
+};
+
+export default Admin;
diff --git a/src/app/Navbar.jsx b/src/app/Navbar.jsx
index 5d4b2b0..7b4a5d4 100644
--- a/src/app/Navbar.jsx
+++ b/src/app/Navbar.jsx
@@ -7,7 +7,7 @@ import { useClickOutside } from './hooks/useClickOutside';
const constants = require('./constants');
const theme = require('../styling/theme');
-const NavbarFunction = ({user, setUserChanged, newTypes}) => {
+const NavbarFunction = ({user, setUserChanged, newTypes, isAdmin}) => {
const [showModal, setShowModal] = useState(false);
const [showImportModal, setShowImportModal] = useState(false);
const navigate = useNavigate();
@@ -61,10 +61,11 @@ const NavbarFunction = ({user, setUserChanged, newTypes}) => {
// Calculate dynamic width for mobile profile dropdown
const calculateProfileDropdownWidth = useCallback(() => {
const labels = ['Export All Data', 'Import New List', 'View Profile', 'Switch Account', 'Logout'];
+ if (isAdmin) labels.push('Admin Board');
const longestLabel = labels.reduce((a, b) => a.length > b.length ? a : b);
const estimatedWidth = longestLabel.length * 6 + 24;
return Math.max(estimatedWidth, 100);
- }, []);
+ }, [isAdmin]);
// Calculate dynamic width for mobile media dropdown
const calculateMediaDropdownWidth = useCallback(() => {
@@ -98,10 +99,11 @@ const NavbarFunction = ({user, setUserChanged, newTypes}) => {
// Calculate dynamic width for desktop profile dropdown
const calculateDesktopProfileDropdownWidth = useCallback(() => {
const labels = ['Export All Data', 'Import New List', 'View Profile', 'Switch Google Account', 'Logout'];
+ if (isAdmin) labels.push('Admin Board');
const longestLabel = labels.reduce((a, b) => a.length > b.length ? a : b);
const estimatedWidth = longestLabel.length * 7 + 32;
return Math.max(estimatedWidth, 120);
- }, []);
+ }, [isAdmin]);
const showNewTypeModal = () => setShowModal(true);
@@ -456,9 +458,31 @@ const NavbarFunction = ({user, setUserChanged, newTypes}) => {
background: 'none',
border: 'none',
cursor: 'pointer'
- }} className="navbar-dropdown-item" onClick={exportAllData}>
- Export All Data
+ }} className="navbar-dropdown-item" onClick={() => {
+ setIsUserMenuOpen(false);
+ navigate('/profile');
+ }}>
+ View Profile
+ {isAdmin && (
+
+ )}
+
+
-
- About
-
-
{
background: 'none',
border: 'none',
cursor: 'pointer'
- }} className="navbar-dropdown-item" onClick={exportAllData}>
- Export All Data
+ }} className="navbar-dropdown-item" onClick={() => {
+ setIsUserMenuOpen(false);
+ navigate('/profile');
+ }}>
+ View Profile
+ {isAdmin && (
+
+ )}
+
+
-
{
color: theme.components.navbar.colors.dropdownItemText,
textDecoration: 'none'
}} className="navbar-dropdown-item">
- Logout
+ Logout
)}