diff --git a/.gitignore b/.gitignore
index 98d39036..caeb3fa1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -61,3 +61,8 @@ group-ironmen-master/*.*
group-ironmen-tracker-master/*.*
tasks-tracker-plugin-master/*.*
os-league-tools-master/build.tar.gz
+
+node_modules/
+.vscode/
+.gitea/
+.claude/
diff --git a/ecosystem.config.js b/ecosystem.config.js
new file mode 100644
index 00000000..d9adf309
--- /dev/null
+++ b/ecosystem.config.js
@@ -0,0 +1,45 @@
+module.exports = {
+ apps: [
+ {
+ name: 'api-prod',
+ cwd: './server',
+ script: 'npx',
+ args: 'tsx src/index.ts',
+ env: {
+ NODE_ENV: 'production',
+ PORT: 3002,
+ DATABASE_URL: 'file:./data.db',
+ SESSION_SECRET: 'K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z',
+ BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f',
+ CORS_ORIGINS: 'https://leagues.tools,https://www.leagues.tools,http://localhost:3002',
+ FRONTEND_BUILD_PATH: '../os-league-tools-master/build',
+ },
+ },
+ {
+ name: 'api-dev',
+ cwd: './server',
+ script: 'npx',
+ args: 'tsx watch src/index.ts',
+ env: {
+ NODE_ENV: 'development',
+ PORT: 3003,
+ DATABASE_URL: 'file:./data-dev.db',
+ SESSION_SECRET: 'K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z',
+ BACKEND_SECRET: 'A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f',
+ CORS_ORIGINS: 'http://localhost:3000,http://localhost:3001,https://dev.leagues.tools',
+ FRONTEND_BUILD_PATH: '../os-league-tools-master/build',
+ },
+ },
+ {
+ name: 'frontend-dev',
+ cwd: './os-league-tools-master',
+ script: 'npm',
+ args: 'run dev',
+ env: {
+ BROWSER: 'none',
+ PORT: 3000,
+ WDS_SOCKET_PORT: 443, // Use nginx's HTTPS port for WebSocket
+ },
+ },
+ ],
+};
diff --git a/os-league-tools-master/.env.development b/os-league-tools-master/.env.development
new file mode 100644
index 00000000..04bd9b69
--- /dev/null
+++ b/os-league-tools-master/.env.development
@@ -0,0 +1,7 @@
+# Development environment variables
+
+# API server - use same domain, nginx proxies /api to backend
+REACT_APP_RELDO_URL=
+
+# Google Analytics (optional - leave empty for dev)
+REACT_APP_GA_MID=
diff --git a/os-league-tools-master/.env.example b/os-league-tools-master/.env.example
new file mode 100644
index 00000000..b2868a62
--- /dev/null
+++ b/os-league-tools-master/.env.example
@@ -0,0 +1,5 @@
+# API server URL
+REACT_APP_RELDO_URL=http://localhost:3001
+
+# Google Analytics tracking ID (optional)
+REACT_APP_GA_MID=
diff --git a/os-league-tools-master/.env.production b/os-league-tools-master/.env.production
new file mode 100644
index 00000000..c6b7809d
--- /dev/null
+++ b/os-league-tools-master/.env.production
@@ -0,0 +1,7 @@
+# Production environment variables
+
+# Production API server (same origin - served by Node.js)
+REACT_APP_RELDO_URL=
+
+# Google Analytics
+REACT_APP_GA_MID=
diff --git a/os-league-tools-master/leagues-tools-frontend-dev.service b/os-league-tools-master/leagues-tools-frontend-dev.service
new file mode 100644
index 00000000..c7153463
--- /dev/null
+++ b/os-league-tools-master/leagues-tools-frontend-dev.service
@@ -0,0 +1,19 @@
+[Unit]
+Description=Leagues Tools Frontend Dev Server (Hot Reload)
+After=network.target leagues-tools-dev.service
+Wants=leagues-tools-dev.service
+
+[Service]
+Type=simple
+User=sonder
+WorkingDirectory=/home/sonder/leagues-tools-dev/os-league-tools-master
+ExecStart=/usr/bin/npm run dev
+Restart=on-failure
+RestartSec=10
+StandardOutput=syslog
+StandardError=syslog
+SyslogIdentifier=leagues-tools-frontend-dev
+Environment=NODE_ENV=development
+
+[Install]
+WantedBy=multi-user.target
diff --git a/os-league-tools-master/package-lock.json b/os-league-tools-master/package-lock.json
index 53759971..e3252593 100644
--- a/os-league-tools-master/package-lock.json
+++ b/os-league-tools-master/package-lock.json
@@ -96,6 +96,7 @@
},
"devDependencies": {
"gh-pages": "^6.2.0",
+ "http-proxy-middleware": "^3.0.5",
"husky": "^7.0.4",
"jest": "^27.4.7",
"lint-staged": "^12.1.5",
@@ -10254,26 +10255,21 @@
}
},
"node_modules/http-proxy-middleware": {
- "version": "2.0.9",
- "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
- "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
+ "integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
+ "dev": true,
+ "license": "MIT",
"dependencies": {
- "@types/http-proxy": "^1.17.8",
+ "@types/http-proxy": "^1.17.15",
+ "debug": "^4.3.6",
"http-proxy": "^1.18.1",
- "is-glob": "^4.0.1",
- "is-plain-obj": "^3.0.0",
- "micromatch": "^4.0.2"
+ "is-glob": "^4.0.3",
+ "is-plain-object": "^5.0.0",
+ "micromatch": "^4.0.8"
},
"engines": {
- "node": ">=12.0.0"
- },
- "peerDependencies": {
- "@types/express": "^4.17.13"
- },
- "peerDependenciesMeta": {
- "@types/express": {
- "optional": true
- }
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/https-proxy-agent": {
@@ -10858,6 +10854,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-plain-object": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-port-reachable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz",
@@ -19831,6 +19837,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": {
+ "version": "2.0.9",
+ "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
+ "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/http-proxy": "^1.17.8",
+ "http-proxy": "^1.18.1",
+ "is-glob": "^4.0.1",
+ "is-plain-obj": "^3.0.0",
+ "micromatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "@types/express": "^4.17.13"
+ },
+ "peerDependenciesMeta": {
+ "@types/express": {
+ "optional": true
+ }
+ }
+ },
"node_modules/webpack-dev-server/node_modules/open": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
diff --git a/os-league-tools-master/package.json b/os-league-tools-master/package.json
index 6d2a1fe2..d9763aff 100644
--- a/os-league-tools-master/package.json
+++ b/os-league-tools-master/package.json
@@ -108,6 +108,7 @@
},
"devDependencies": {
"gh-pages": "^6.2.0",
+ "http-proxy-middleware": "^3.0.5",
"husky": "^7.0.4",
"jest": "^27.4.7",
"lint-staged": "^12.1.5",
diff --git a/os-league-tools-master/src/App.js b/os-league-tools-master/src/App.js
index 59c9b9f9..39c7f08c 100644
--- a/os-league-tools-master/src/App.js
+++ b/os-league-tools-master/src/App.js
@@ -19,6 +19,7 @@ import Faq from './pages/Faq';
import ViewCharacter from './pages/ViewCharacter';
import Groups from './pages/Groups';
import Planner from './pages/Planner';
+import Admin from './pages/Admin';
import { submitRenderError } from './client/feedback-client';
import { ErrorPage } from './components/common/util/ErrorBoundary';
@@ -45,6 +46,7 @@ history.listen(() => {
const isDevEnvironment = () =>
window.location.hostname.startsWith('dev.') ||
window.location.hostname === 'localhost' ||
+ window.location.port === '3000' ||
window.location.port === '3001';
export default function App() {
@@ -111,6 +113,7 @@ export default function App() {
} />
} />
} />
+ } />
diff --git a/os-league-tools-master/src/client/admin-client.js b/os-league-tools-master/src/client/admin-client.js
new file mode 100644
index 00000000..fc3cc9fb
--- /dev/null
+++ b/os-league-tools-master/src/client/admin-client.js
@@ -0,0 +1,95 @@
+// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
+const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
+const DEFAULT_HEADERS = {
+ 'Content-type': 'application/json',
+};
+
+function handleResponse(response) {
+ if (!response.ok) {
+ return response.json().then(data => ({
+ success: false,
+ error: data.message || data.error || 'An error occurred',
+ }));
+ }
+ return response.json().then(data => ({
+ success: true,
+ value: data,
+ }));
+}
+
+function handleError(error) {
+ console.warn(error);
+ return { success: false, error: error.message || 'Network error' };
+}
+
+export function getAdminStats() {
+ return fetch(`${BASE_URL}/api/admin/stats`, {
+ method: 'GET',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+export function getUsers() {
+ return fetch(`${BASE_URL}/api/admin/users`, {
+ method: 'GET',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+export function getUser(id) {
+ return fetch(`${BASE_URL}/api/admin/users/${id}`, {
+ method: 'GET',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+export function updateUser(id, data) {
+ return fetch(`${BASE_URL}/api/admin/users/${id}`, {
+ method: 'PATCH',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ body: JSON.stringify(data),
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+export function resetUserPassword(id, password) {
+ return fetch(`${BASE_URL}/api/admin/users/${id}/password`, {
+ method: 'PATCH',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ body: JSON.stringify({ password }),
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+export function deleteUser(id) {
+ return fetch(`${BASE_URL}/api/admin/users/${id}`, {
+ method: 'DELETE',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+export function invalidateUserSessions(id) {
+ return fetch(`${BASE_URL}/api/admin/users/${id}/sessions`, {
+ method: 'DELETE',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
diff --git a/os-league-tools-master/src/client/auth-client.js b/os-league-tools-master/src/client/auth-client.js
index 659dcb7d..b92f8367 100644
--- a/os-league-tools-master/src/client/auth-client.js
+++ b/os-league-tools-master/src/client/auth-client.js
@@ -1,4 +1,5 @@
-const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
+// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
+const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
const DEFAULT_HEADERS = {
'Content-type': 'application/json',
};
diff --git a/os-league-tools-master/src/client/character-client.js b/os-league-tools-master/src/client/character-client.js
new file mode 100644
index 00000000..f0a03ad3
--- /dev/null
+++ b/os-league-tools-master/src/client/character-client.js
@@ -0,0 +1,98 @@
+// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
+const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
+const DEFAULT_HEADERS = {
+ 'Content-type': 'application/json',
+};
+
+function handleResponse(response) {
+ if (!response.ok) {
+ return response.json().then(data => ({
+ success: false,
+ error: data.message || data.error || 'An error occurred',
+ }));
+ }
+ return response.json().then(data => ({
+ success: true,
+ value: data,
+ }));
+}
+
+function handleError(error) {
+ console.warn(error);
+ return { success: false, error: error.message || 'Network error' };
+}
+
+/**
+ * Get all characters for the authenticated user
+ */
+export function getCharacters() {
+ return fetch(`${BASE_URL}/api/characters`, {
+ method: 'GET',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+/**
+ * Create a new character
+ * @param {string} rsn - RuneScape Name
+ * @param {boolean} setActive - Whether to set this character as active
+ */
+export function createCharacter(rsn, setActive = false) {
+ return fetch(`${BASE_URL}/api/characters`, {
+ method: 'POST',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ body: JSON.stringify({ rsn, setActive }),
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+/**
+ * Update a character (rename, set active, or sync data)
+ * @param {number} id - Character ID
+ * @param {object} updates - Fields to update (rsn, isActive, tasksData, unlocksData, notesData)
+ */
+export function updateCharacter(id, updates) {
+ return fetch(`${BASE_URL}/api/characters/${id}`, {
+ method: 'PATCH',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ body: JSON.stringify(updates),
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+/**
+ * Delete a character
+ * @param {number} id - Character ID
+ */
+export function deleteCharacter(id) {
+ return fetch(`${BASE_URL}/api/characters/${id}`, {
+ method: 'DELETE',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
+
+/**
+ * Bulk sync characters (used on login to merge local data with server)
+ * @param {Array} characters - Array of character objects or RSN strings
+ * @param {number} activeIndex - Index of the active character
+ */
+export function syncCharacters(characters, activeIndex) {
+ return fetch(`${BASE_URL}/api/characters/sync`, {
+ method: 'POST',
+ headers: DEFAULT_HEADERS,
+ credentials: 'include',
+ body: JSON.stringify({ characters, activeIndex }),
+ })
+ .then(handleResponse)
+ .catch(handleError);
+}
diff --git a/os-league-tools-master/src/client/hiscores-client.js b/os-league-tools-master/src/client/hiscores-client.js
index 9116fe54..93c56236 100644
--- a/os-league-tools-master/src/client/hiscores-client.js
+++ b/os-league-tools-master/src/client/hiscores-client.js
@@ -1,4 +1,5 @@
-const BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
+// Use relative URLs when REACT_APP_RELDO_URL is not set (same-origin via nginx)
+const BASE_URL = process.env.REACT_APP_RELDO_URL || '';
export default async function getHiscores(rsn, handleResultCallback) {
if (!rsn) {
diff --git a/os-league-tools-master/src/components/PageWrapper.js b/os-league-tools-master/src/components/PageWrapper.js
index 5e6d6788..77a9861d 100644
--- a/os-league-tools-master/src/components/PageWrapper.js
+++ b/os-league-tools-master/src/components/PageWrapper.js
@@ -20,11 +20,11 @@ export default function PageWrapper({ children }) {
const [manageDataModalType, setManageDataModalType] = useQueryString('open');
const navItems = [
- new NavItem('Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
- new NavItem('Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
- new NavItem('Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
- new NavItem('Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
- new NavItem('Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
+ new NavItem('WIP-Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
+ new NavItem('WIP-Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
+ new NavItem('WIP-Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
+ new NavItem('WIP-Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
+ new NavItem('WIP-Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
new NavItem('Settings', 'overflow', 3, 1).withRouterLink('/settings').withIconFont('settings'),
new NavItem('WIP-FAQ', 'overflow', 3, 2).withRouterLink('/faq').withIconFont('help_outline'),
new NavItem('WIP-About', 'overflow', 3, 3).withRouterLink('/about').withIconFont('info'),
diff --git a/os-league-tools-master/src/components/nav/AuthButton.js b/os-league-tools-master/src/components/nav/AuthButton.js
index 34759737..ce533a30 100644
--- a/os-league-tools-master/src/components/nav/AuthButton.js
+++ b/os-league-tools-master/src/components/nav/AuthButton.js
@@ -1,4 +1,5 @@
import React, { useRef, useState } from 'react';
+import { Link } from 'react-router-dom';
import Spinner from '../common/Spinner';
import Dropdown from '../common/Dropdown';
import useAccount from '../../hooks/useAccount';
@@ -13,6 +14,7 @@ function NavBarItem() {
isAuthenticating,
username,
userEmail,
+ isAdmin,
openAuthModal,
logout,
authModalOpen,
@@ -70,6 +72,13 @@ function NavBarItem() {
{userEmail &&
{userEmail}
}
+ {isAdmin && (
+ setExpanded(false)}>
+
+ Admin
+
+
+ )}
{userEmail}
)}
+ {isAdmin && (
+ {
+ setExpanded(false);
+ if (onNavigate) {
+ onNavigate();
+ }
+ }}
+ >
+ admin_panel_settings
+ Admin
+
+ )}