Compare commits

..

12 Commits

Author SHA1 Message Date
95063d4066 feat: add password reset functionality and email notifications
- Implemented forgot password and reset password routes in the backend.
- Added email sending capabilities using Nodemailer for password reset requests.
- Created ResetPassword page in the frontend for users to reset their passwords.
- Updated user model to include reset token and expiry fields.
- Integrated hiscores API with caching mechanism for improved performance.
- Enhanced authentication modal to include forgot password option.
- Updated environment configuration for SMTP settings.
2026-02-03 23:37:47 +00:00
3cec7abee9 Admin role added, dev side has hot reload again, characters AND groups tied to accounts now. 2026-01-28 23:43:02 +00:00
8e92c28272 Implement authentication features with login, registration, and user management; update service configurations for development and production environments. 2026-01-23 01:50:31 +00:00
de14c646fa Add footer navigation support in SideBar and update PageWrapper with user and character state 2026-01-23 01:50:27 +00:00
29456a07bc Removed dev branch from runner, as this is live updated via WSS after local edits.
Because this runs via NPM RUN DEV, on an adjacent port to 3000, the CICD runner is superfluous here.
2026-01-22 00:49:05 +00:00
7b729c5cb2 Fixed object destructuring issue
All checks were successful
Build & Deploy OS League Tools / build-and-deploy (push) Successful in 59s
2026-01-22 00:44:02 +00:00
7a32d0e874 New DEV changes committed, introduction of planner, added badges akin to what's on the Wiki.
Some checks failed
Build & Deploy OS League Tools / build-and-deploy (push) Failing after 34s
2026-01-22 00:36:47 +00:00
15f054d291 protocol added th env.js
All checks were successful
Build & Deploy OS League Tools / build-and-deploy (push) Successful in 48s
2026-01-19 05:18:58 +00:00
883a07fed5 Used wss instead of ws.
All checks were successful
Build & Deploy OS League Tools / build-and-deploy (push) Successful in 58s
2026-01-19 04:35:23 +00:00
d7297346f2 Fix ESLint errors in dev title prefix
All checks were successful
Build & Deploy OS League Tools / build-and-deploy (push) Successful in 54s
2026-01-17 01:14:24 +00:00
d00cc3225c Add [DEV] prefix to page title on dev environment
Some checks failed
Build & Deploy OS League Tools / build-and-deploy (push) Failing after 32s
2026-01-17 01:12:34 +00:00
ed69fcf299 Enable hot reload for dev environment
Some checks are pending
Build & Deploy OS League Tools / build-and-deploy (push) Has started running
2026-01-17 01:12:05 +00:00
59 changed files with 7718 additions and 202 deletions

5
.gitignore vendored
View File

@@ -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/

142
CLAUDE.md Normal file
View File

@@ -0,0 +1,142 @@
# Leagues Tools
OSRS (Old School RuneScape) League tracker and planning application. Allows users to track League tasks, unlocks, planning, and group ironman progress via RuneLite plugin integration.
## Tech Stack
### Frontend (`os-league-tools-master/`)
- **React 18.3** with React Router DOM 6.28
- **Redux Toolkit** for state management
- **TailwindCSS 3.0** for styling
- **Webpack 5** for bundling
### Backend (`server/`)
- **Hono** - lightweight web framework on Node.js
- **TypeScript** (ES2022 target)
- **Prisma 6.2** ORM with **SQLite** database
- **bcrypt** for password hashing
- **Nodemailer** for emails
- **Blake2b** for token hashing
## Project Structure
```
leagues-tools-dev/
├── os-league-tools-master/ # React frontend
│ ├── src/
│ │ ├── App.js # Main entry, routing
│ │ ├── client/ # API client modules
│ │ ├── components/ # React components
│ │ ├── pages/ # Page components
│ │ ├── store/ # Redux slices
│ │ └── hooks/ # Custom hooks
│ └── build/ # Production build output
├── server/ # Hono backend
│ ├── src/
│ │ ├── index.ts # Server entry point
│ │ ├── app.ts # Hono app setup
│ │ ├── db.ts # Prisma client
│ │ ├── routes/ # API route handlers
│ │ ├── middleware/ # Auth middleware
│ │ └── utils/ # Helpers (email, password, blake2)
│ └── prisma/schema.prisma # Database schema
└── ecosystem.config.js # PM2 deployment config
```
## Commands
### Frontend
```bash
cd os-league-tools-master
npm run dev # Start dev server (port 3000)
npm run build # Production build
```
### Backend
```bash
cd server
npm run dev # Start with hot reload (tsx watch)
npm run build # Compile TypeScript
npm run db:push # Push schema to database
npm run db:migrate # Run migrations
npm run db:generate # Generate Prisma client
```
### PM2 (Root)
```bash
npm run start # Start all PM2 apps
npm run stop # Stop all apps
npm run logs # View logs
```
## Database Schema (Key Models)
- **User** - Auth accounts with role (USER/ADMIN), sessions, characters
- **Character** - User's OSRS characters with RSN, stores tasks/unlocks/notes as JSON
- **Session** - Cookie-based sessions (7-day TTL)
- **Group** - Group Ironmen tracking with Blake2b hashed token
- **Member** - Group member data (stats, inventory, equipment, bank, quests)
- **HiscoresCache** - Cached OSRS hiscores (5-min TTL)
## API Routes
All routes prefixed with `/api`:
| Route | Purpose |
|-------|---------|
| `/register`, `/login`, `/logout` | Auth |
| `/me`, `/auth/status` | Current user |
| `/forgot-password`, `/reset-password` | Password reset |
| `/characters` | User character CRUD |
| `/group/:name/*` | Group data (RuneLite plugin) |
| `/hiscores/:rsn` | OSRS hiscores with caching |
| `/admin/*` | Admin user management |
| `/create-group`, `/ge-prices` | Public endpoints |
## Authentication
- **Session-based**: HTTP-only secure cookies, 7-day TTL
- **Group tokens**: Blake2b-256 hashed, used by RuneLite plugin
- **Roles**: USER (default), ADMIN (access to `/api/admin/*`)
Middleware in `server/src/middleware/`:
- `session.ts` - requireAuth, requireAdmin
- `groupAuth.ts` - RuneLite token validation
## Environment Variables
Backend expects (via `.env` or ecosystem.config.js):
- `PORT` - Server port (3001 default)
- `DATABASE_URL` - SQLite path (`file:./data.db`)
- `CORS_ORIGINS` - Allowed origins (comma-separated)
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM` - Email config
- `FRONTEND_BUILD_PATH` - Path to React build
Frontend uses:
- `REACT_APP_RELDO_URL` - Override API endpoint
- `REACT_APP_GA_MID` - Google Analytics ID
## Key Patterns
1. **JSON Storage**: Complex data (tasks, unlocks) stored as JSON strings in SQLite
2. **Graceful Fallbacks**: Hiscores returns stale cache if OSRS API fails
3. **Character Active State**: Only one character active per user
4. **Bulk Sync**: Merges local client data with server on login
5. **SPA Routing**: Backend serves `index.html` for non-API routes
## Ports
| Service | Dev | Prod |
|---------|-----|------|
| Frontend | 3000 | (served by backend) |
| Backend | 3003 | 3002 |
## Important Files
- `server/src/app.ts` - All route mounting, CORS, middleware
- `server/prisma/schema.prisma` - Full database schema
- `os-league-tools-master/src/App.js` - Frontend routing
- `os-league-tools-master/src/client/` - API client functions
- `ecosystem.config.js` - PM2 deployment configuration

63
ecosystem.config.js Normal file
View File

@@ -0,0 +1,63 @@
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',
APP_URL: 'https://leagues.tools',
// SMTP Configuration - fill in your server details
SMTP_HOST: 'yeahnah-net.mail.protection.outlook.com', // e.g., 'smtp.gmail.com' or 'mail.yourserver.com'
SMTP_PORT: '25', // 587 for TLS, 465 for SSL
SMTP_SECURE: 'false', // 'true' for port 465, 'false' for 587
SMTP_USER: 'bailey@yeahnah.net', // SMTP username/email
SMTP_PASS: 'Howaboutno123!', // SMTP password or app password
SMTP_FROM: 'noreply@leagues.tools', // From address for emails
},
},
{
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',
APP_URL: 'https://dev.leagues.tools',
// SMTP Configuration - same as prod or leave empty for console logging
SMTP_HOST: 'yeahnah-net.mail.protection.outlook.com',
SMTP_PORT: '25',
SMTP_SECURE: 'false',
SMTP_USER: 'bailey@yeahnah.net',
SMTP_PASS: 'Howaboutno123!',
SMTP_FROM: 'noreply@leagues.tools',
},
},
{
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
},
},
],
};

View File

@@ -17,6 +17,8 @@ Environment="HOST=0.0.0.0"
Environment="WDS_SOCKET_PROTOCOL=wss"
Environment="WDS_SOCKET_HOST=dev.leagues.tools"
Environment="WDS_SOCKET_PORT=443"
Environment="REACT_APP_RELDO_URL=https://api.leagues.tools"
# Start the dev server with hot reload
ExecStart=/usr/bin/npm run dev

View File

@@ -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=

View File

@@ -0,0 +1,5 @@
# API server URL
REACT_APP_RELDO_URL=http://localhost:3001
# Google Analytics tracking ID (optional)
REACT_APP_GA_MID=

View File

@@ -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=

View File

@@ -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

View File

@@ -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",

View File

@@ -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",

View File

@@ -19,6 +19,8 @@ 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 ResetPassword from './pages/ResetPassword';
import { submitRenderError } from './client/feedback-client';
import { ErrorPage } from './components/common/util/ErrorBoundary';
@@ -45,6 +47,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 +114,8 @@ export default function App() {
<Route path='about' element={<About />} />
<Route path='settings' element={<Settings />} />
<Route path='faq' element={<Faq />} />
<Route path='admin' element={<Admin />} />
<Route path='reset-password' element={<ResetPassword />} />
</Routes>
</ErrorBoundary>
</Auth0Provider>

View File

@@ -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);
}

View File

@@ -0,0 +1,97 @@
// 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 login(username, password) {
return fetch(`${BASE_URL}/api/login`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ username, password }),
})
.then(handleResponse)
.catch(handleError);
}
export function register(username, email, password) {
return fetch(`${BASE_URL}/api/register`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ username, email, password }),
})
.then(handleResponse)
.catch(handleError);
}
export function logout() {
return fetch(`${BASE_URL}/api/logout`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
export function getAuthStatus() {
return fetch(`${BASE_URL}/api/auth/status`, {
method: 'GET',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
export function getCurrentUser() {
return fetch(`${BASE_URL}/api/me`, {
method: 'GET',
headers: DEFAULT_HEADERS,
credentials: 'include',
})
.then(handleResponse)
.catch(handleError);
}
export function forgotPassword(email) {
return fetch(`${BASE_URL}/api/forgot-password`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ email }),
})
.then(handleResponse)
.catch(handleError);
}
export function resetPassword(token, password) {
return fetch(`${BASE_URL}/api/reset-password`, {
method: 'POST',
headers: DEFAULT_HEADERS,
credentials: 'include',
body: JSON.stringify({ token, password }),
})
.then(handleResponse)
.catch(handleError);
}

View File

@@ -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);
}

View File

@@ -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 function submitBug(formData) {
return submitFeedback(formData, '/bug');

View File

@@ -3,7 +3,8 @@
* Communicates with the Java/Spring Boot backend at spring-backend/
*/
const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || 'http://localhost:8080';
// Use relative URLs when REACT_APP_GROUP_IRONMEN_URL is not set (same-origin via nginx)
const BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || '';
const DEFAULT_HEADERS = {
'Content-type': 'application/json',
};

View File

@@ -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) {
@@ -8,7 +9,7 @@ export default async function getHiscores(rsn, handleResultCallback) {
});
}
const url = `${BASE_URL}/hiscores/${rsn}`;
const url = `${BASE_URL}/api/hiscores/${rsn}`;
await fetch(url)
.then(res => res.json())
.then(

View File

@@ -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',
};

View File

@@ -0,0 +1,340 @@
import React, { useState } from 'react';
import Modal from './Modal';
import Spinner from './common/Spinner';
import { login, register, forgotPassword } from '../client/auth-client';
const VIEW = {
LOGIN: 'login',
REGISTER: 'register',
FORGOT_PASSWORD: 'forgot_password',
};
export default function AuthModal({ isOpen, setIsOpen, onAuthSuccess }) {
const [view, setView] = useState(VIEW.LOGIN);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [loginUsername, setLoginUsername] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [registerUsername, setRegisterUsername] = useState('');
const [registerEmail, setRegisterEmail] = useState('');
const [registerPassword, setRegisterPassword] = useState('');
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
const [forgotEmail, setForgotEmail] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const resetForm = () => {
setLoginUsername('');
setLoginPassword('');
setRegisterUsername('');
setRegisterEmail('');
setRegisterPassword('');
setRegisterConfirmPassword('');
setForgotEmail('');
setError('');
setSuccessMessage('');
};
const handleClose = () => {
resetForm();
setView(VIEW.LOGIN);
};
const switchView = newView => {
resetForm();
setView(newView);
};
const handleLogin = async e => {
e.preventDefault();
setError('');
if (!loginUsername || !loginPassword) {
setError('Please fill in all fields');
return;
}
setIsLoading(true);
const result = await login(loginUsername, loginPassword);
setIsLoading(false);
if (result.success) {
resetForm();
setIsOpen(false);
if (onAuthSuccess) {
onAuthSuccess(result.value);
}
} else {
setError(result.error || 'Login failed');
}
};
const handleRegister = async e => {
e.preventDefault();
setError('');
if (!registerUsername || !registerEmail || !registerPassword || !registerConfirmPassword) {
setError('Please fill in all fields');
return;
}
if (registerPassword !== registerConfirmPassword) {
setError('Passwords do not match');
return;
}
if (registerPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setIsLoading(true);
const result = await register(registerUsername, registerEmail, registerPassword);
setIsLoading(false);
if (result.success) {
resetForm();
setIsOpen(false);
if (onAuthSuccess) {
onAuthSuccess(result.value);
}
} else {
setError(result.error || 'Registration failed');
}
};
const handleForgotPassword = async e => {
e.preventDefault();
setError('');
setSuccessMessage('');
if (!forgotEmail) {
setError('Please enter your email address');
return;
}
setIsLoading(true);
const result = await forgotPassword(forgotEmail);
setIsLoading(false);
if (result.success) {
setSuccessMessage(result.value.message);
setForgotEmail('');
} else {
setError(result.error || 'Failed to send reset email');
}
};
return (
<Modal
isOpen={isOpen}
setIsOpen={setIsOpen}
onClose={handleClose}
className='w-96 shadow shadow-primary rounded-md bg-primary-alt'
>
<Modal.Header className='text-center small-caps tracking-wide text-xl text-accent font-semibold'>
{view === VIEW.LOGIN && 'Login'}
{view === VIEW.REGISTER && 'Create Account'}
{view === VIEW.FORGOT_PASSWORD && 'Reset Password'}
</Modal.Header>
<Modal.Body className='text-primary text-sm'>
{view === VIEW.LOGIN && (
<form onSubmit={handleLogin} className='m-4 flex flex-col gap-3'>
<label htmlFor='login-username' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Username</span>
<input
id='login-username'
name='username'
type='text'
className='input-primary text-sm form-input'
placeholder='Enter username'
value={loginUsername}
onChange={e => setLoginUsername(e.target.value)}
disabled={isLoading}
autoComplete='username'
/>
</label>
<label htmlFor='login-password' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Password</span>
<input
id='login-password'
name='password'
type='password'
className='input-primary text-sm form-input'
placeholder='Enter password'
value={loginPassword}
onChange={e => setLoginPassword(e.target.value)}
disabled={isLoading}
autoComplete='current-password'
/>
</label>
{error && <div className='text-error text-xs text-center'>{error}</div>}
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
{isLoading ? (
<span className='flex items-center justify-center gap-2'>
<Spinner size={Spinner.SIZE.sm} /> Logging in...
</span>
) : (
'Login'
)}
</button>
<button
type='button'
className='text-accent text-xs hover:underline'
onClick={() => switchView(VIEW.FORGOT_PASSWORD)}
>
Forgot password?
</button>
</form>
)}
{view === VIEW.REGISTER && (
<form onSubmit={handleRegister} className='m-4 flex flex-col gap-3'>
<label htmlFor='register-username' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Username</span>
<input
id='register-username'
name='username'
type='text'
className='input-primary text-sm form-input'
placeholder='Choose a username'
value={registerUsername}
onChange={e => setRegisterUsername(e.target.value)}
disabled={isLoading}
autoComplete='username'
/>
</label>
<label htmlFor='register-email' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Email</span>
<input
id='register-email'
name='email'
type='email'
className='input-primary text-sm form-input'
placeholder='Enter your email'
value={registerEmail}
onChange={e => setRegisterEmail(e.target.value)}
disabled={isLoading}
autoComplete='email'
/>
</label>
<label htmlFor='register-password' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Password</span>
<input
id='register-password'
name='new-password'
type='password'
className='input-primary text-sm form-input'
placeholder='Create a password'
value={registerPassword}
onChange={e => setRegisterPassword(e.target.value)}
disabled={isLoading}
autoComplete='new-password'
/>
</label>
<label htmlFor='register-confirm-password' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Confirm Password</span>
<input
id='register-confirm-password'
name='confirm-password'
type='password'
className='input-primary text-sm form-input'
placeholder='Confirm your password'
value={registerConfirmPassword}
onChange={e => setRegisterConfirmPassword(e.target.value)}
disabled={isLoading}
autoComplete='new-password'
/>
</label>
{error && <div className='text-error text-xs text-center'>{error}</div>}
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
{isLoading ? (
<span className='flex items-center justify-center gap-2'>
<Spinner size={Spinner.SIZE.sm} /> Creating account...
</span>
) : (
'Create Account'
)}
</button>
</form>
)}
{view === VIEW.FORGOT_PASSWORD && (
<form onSubmit={handleForgotPassword} className='m-4 flex flex-col gap-3'>
<p className='text-secondary text-xs'>
Enter your email address and we&apos;ll send you a link to reset your password.
</p>
<label htmlFor='forgot-email' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Email</span>
<input
id='forgot-email'
name='email'
type='email'
className='input-primary text-sm form-input'
placeholder='Enter your email'
value={forgotEmail}
onChange={e => setForgotEmail(e.target.value)}
disabled={isLoading}
autoComplete='email'
/>
</label>
{error && <div className='text-error text-xs text-center'>{error}</div>}
{successMessage && <div className='text-success text-xs text-center'>{successMessage}</div>}
<button type='submit' className='button-filled py-2 mt-2' disabled={isLoading}>
{isLoading ? (
<span className='flex items-center justify-center gap-2'>
<Spinner size={Spinner.SIZE.sm} /> Sending...
</span>
) : (
'Send Reset Link'
)}
</button>
</form>
)}
</Modal.Body>
<Modal.Footer className='text-center text-sm'>
{view === VIEW.LOGIN && (
<p className='text-secondary py-2'>
Don&apos;t have an account?{' '}
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.REGISTER)}>
Register
</button>
</p>
)}
{view === VIEW.REGISTER && (
<p className='text-secondary py-2'>
Already have an account?{' '}
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.LOGIN)}>
Login
</button>
</p>
)}
{view === VIEW.FORGOT_PASSWORD && (
<p className='text-secondary py-2'>
Remember your password?{' '}
<button type='button' className='text-accent hover:underline' onClick={() => switchView(VIEW.LOGIN)}>
Login
</button>
</p>
)}
</Modal.Footer>
</Modal>
);
}

View File

@@ -7,8 +7,8 @@ import FeedbackModal from './FeedbackModal';
import ManageDataModal from './ManageDataModal';
import images from '../assets/images';
import useQueryString from '../hooks/useQueryString';
import ManageData from './nav/ManageData';
import Feedback from './nav/Feedback';
import AuthButton from './nav/AuthButton';
import Character from './nav/Character';
import ManageCharactersModal from './ManageCharactersModal';
@@ -20,38 +20,18 @@ 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('Character', 'secondary', 1, 0).withCustomRenderFn(
(isCollapsed, onNavigate) => (
<Character.SideBarItem
key='character'
setCharacterModalOpen={setCharacterModalOpen}
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
)
),
new NavItem('Data', 'secondary', 2, 0).withCustomRenderFn(
(isCollapsed, onNavigate) => (
<ManageData.SideBarItem
key='manage'
setManageDataModalType={setManageDataModalType}
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
)
),
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('FAQ', 'overflow', 3, 2).withRouterLink('/faq').withIconFont('help_outline'),
new NavItem('About', 'overflow', 3, 3).withRouterLink('/about').withIconFont('info'),
new NavItem('Discord', 'overflow', 4, 0).withHref('https://discord.gg/GQ5kVyU', '_blank').withIconFont('discord'),
new NavItem('Github', 'overflow', 4, 1)
.withHref('https://github.com/osrs-reldo/os-league-tools', '_blank')
.withIconFont('code'),
new NavItem('WIP-FAQ', 'overflow', 3, 2).withRouterLink('/faq').withIconFont('help_outline'),
new NavItem('WIP-About', 'overflow', 3, 3).withRouterLink('/about').withIconFont('info'),
new NavItem('WIP-Discord', 'overflow', 4, 0).withHref('https://discord.gg/GQ5kVyU', '_blank').withIconFont('discord'),
// new NavItem('Github', 'overflow', 4, 1)
// .withHref('https://github.com/osrs-reldo/os-league-tools', '_blank')
// .withIconFont('code'),
new NavItem('Feedback', 'overflow', 4, 2).withCustomRenderFn(
(isCollapsed, onNavigate) => (
<Feedback.SideBarItem
@@ -62,9 +42,28 @@ export default function PageWrapper({ children }) {
/>
)
),
new NavItem('Tip Jar', 'overflow', 4, 3)
.withHref('https://ko-fi.com/osleaguetools', '_blank')
.withIconFont('savings'),
new NavItem('Account', 'footer', 10, 0).withCustomRenderFn(
(isCollapsed, onNavigate) => (
<AuthButton.SideBarItem
key='auth'
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
)
),
new NavItem('Character', 'footer', 10, 1).withCustomRenderFn(
(isCollapsed, onNavigate) => (
<Character.SideBarItem
key='character'
setCharacterModalOpen={setCharacterModalOpen}
isCollapsed={isCollapsed}
onNavigate={onNavigate}
/>
)
),
// new NavItem('Tip Jar', 'overflow', 4, 3)
// .withHref('https://ko-fi.com/osleaguetools', '_blank')
// .withIconFont('savings'),
];
return (

View File

@@ -33,6 +33,7 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
primary: primaryNavItems,
secondary: secondaryNavItems,
overflow: overflowNavItems,
footer: footerNavItems,
} = groupNavItemsByVariant(navItems);
// Group overflow items by collapseGroup for organization
@@ -55,7 +56,6 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
))}
</SideBarSection>
<SideBarDivider />
{/* Secondary navigation (Character, Manage Data) */}
<SideBarSection>
@@ -78,7 +78,20 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
{i < overflowGroups.length - 1 && <SideBarDivider />}
</React.Fragment>
))}
</div>
{/* footer pinned to bottom */}
{footerNavItems?.length ? (
<div className='sidebar-nav-content-footer'>
<SideBarDivider />
<SideBarSection>
{footerNavItems.map(navItem => (
<SideBarLink key={navItem.id} item={navItem} isCollapsed={isCollapsed} />
))}
</SideBarSection>
</div>
) : null}
</div>
{/* Toggle button */}
<button

View File

@@ -1,9 +1,28 @@
import React from 'react';
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';
import useClickListener from '../../hooks/useClickListener';
import AuthModal from '../AuthModal';
function NavBarItem() {
const { isLoggedIn, isAuthenticating, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin });
const [isExpanded, setExpanded] = useState(false);
const menuRef = useRef(null);
const {
isLoggedIn,
isAuthenticating,
username,
userEmail,
isAdmin,
openAuthModal,
logout,
authModalOpen,
closeAuthModal,
handleAuthSuccess,
} = useAccount();
useClickListener(menuRef, () => setExpanded(false), true);
if (isAuthenticating) {
return (
@@ -14,41 +33,221 @@ function NavBarItem() {
);
}
const { label, icon, action } = getButtonValues(isLoggedIn, login, logout);
if (!isLoggedIn) {
return (
<>
<button
className='text-primary md:inline hidden navbar-link-alt bg-hover px-2 py-1'
onClick={openAuthModal}
type='button'
>
<span className='icon-base mr-1 align-bottom'>login</span>
Login
</button>
<button className='md:hidden inline navbar-icon-link' onClick={openAuthModal} type='button'>
<span className='text-primary-alt icon-lg leading-tight align-middle'>login</span>
</button>
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
</>
);
}
return (
<div className='relative'>
<button
className='text-primary md:inline hidden navbar-link-alt bg-hover py-1 px-2'
type='button'
onClick={() => setExpanded(true)}
>
<span className='text-primary-alt icon-lg inline align-middle mr-1'>account_circle</span>
{username}
</button>
<button className='md:hidden inline navbar-icon-link' onClick={() => setExpanded(true)} type='button'>
<span className='text-primary-alt icon-lg leading-tight align-middle'>account_circle</span>
</button>
<div className='mt-1 absolute right-0 text-center'>
<Dropdown show={isExpanded} innerRef={menuRef}>
<Dropdown.Text isHeading>
<span className='text-accent'>{username}</span>
{userEmail && <p className='text-xs text-secondary font-normal'>{userEmail}</p>}
</Dropdown.Text>
<Dropdown.Separator />
{isAdmin && (
<Link to='/admin' onClick={() => setExpanded(false)}>
<Dropdown.Button className='text-left' icon='admin_panel_settings'>
Admin
</Dropdown.Button>
</Link>
)}
<Dropdown.Button
className='text-left'
icon='logout'
onClick={() => {
logout();
setExpanded(false);
}}
>
Logout
</Dropdown.Button>
</Dropdown>
</div>
</div>
);
}
function CollapsedMenu() {
const {
isLoggedIn,
isAuthenticating,
username,
openAuthModal,
logout,
authModalOpen,
closeAuthModal,
handleAuthSuccess,
} = useAccount();
if (isAuthenticating) {
return (
<div className='text-primary bg-hover py-1 text-left'>
<Spinner size={Spinner.SIZE.sm} />
<p className='h-4 inline pl-1 font-sans-alt'>Loading...</p>
</div>
);
}
if (!isLoggedIn) {
return (
<>
<button className='text-primary bg-hover py-1 text-left' onClick={openAuthModal} type='button'>
<span className='text-primary-alt icon-lg inline align-middle mr-1'>login</span>
<p className='h-4 inline pl-1 font-sans-alt'>Login</p>
</button>
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
</>
);
}
return (
<>
<button
className='text-primary md:inline hidden navbar-link-alt bg-hover px-2 py-1'
onClick={() => action()}
type='button'
>
{label}
</button>
<button className='md:hidden inline navbar-icon-link' onClick={action} type='button'>
<span className='text-primary-alt icon-lg leading-tight align-middle'>{icon}</span>
<div className='text-primary py-1 text-left'>
<span className='text-primary-alt icon-lg inline align-middle mr-1'>account_circle</span>
<p className='h-4 inline pl-1 font-sans-alt text-accent'>{username}</p>
</div>
<button className='text-primary bg-hover py-1 text-left' onClick={logout} type='button'>
<span className='text-primary-alt icon-lg inline align-middle mr-1'>logout</span>
<p className='h-4 inline pl-1 font-sans-alt'>Logout</p>
</button>
</>
);
}
function CollapsedMenu() {
const { isLoggedIn, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin });
const { label, icon, action } = getButtonValues(isLoggedIn, login, logout);
function SideBarItem({ isCollapsed, onNavigate }) {
const [isExpanded, setExpanded] = useState(false);
const menuRef = useRef(null);
const {
isLoggedIn,
isAuthenticating,
username,
userEmail,
isAdmin,
openAuthModal,
logout,
authModalOpen,
closeAuthModal,
handleAuthSuccess,
} = useAccount();
useClickListener(menuRef, () => setExpanded(false), true);
if (isAuthenticating) {
return (
<div className='sidebar-nav-link w-full text-left'>
<Spinner size={Spinner.SIZE.sm} />
{!isCollapsed && <span className='sidebar-nav-link-label'>Loading...</span>}
</div>
);
}
if (!isLoggedIn) {
return (
<>
<button
className='sidebar-nav-link w-full text-left'
type='button'
onClick={() => {
openAuthModal();
if (onNavigate) {
onNavigate();
}
}}
title={isCollapsed ? 'Login' : undefined}
>
<span className='sidebar-nav-link-icon icon-base'>login</span>
{!isCollapsed && <span className='sidebar-nav-link-label'>Login</span>}
</button>
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
</>
);
}
return (
<button className='text-primary bg-hover py-1 text-left' onClick={() => action()} type='button'>
<span className='text-primary-alt icon-lg inline align-middle mr-1'>{icon}</span>
<p className='h-4 inline pl-1 font-sans-alt'>{label}</p>
</button>
<div className='relative' ref={menuRef}>
<button
className='sidebar-nav-link w-full text-left'
type='button'
onClick={() => setExpanded(!isExpanded)}
title={isCollapsed ? username : undefined}
>
<span className='sidebar-nav-link-icon icon-base'>account_circle</span>
{!isCollapsed && (
<>
<span className='sidebar-nav-link-label'>{username}</span>
<span className='ml-auto icon-sm text-primary-alt'>{isExpanded ? 'expand_less' : 'expand_more'}</span>
</>
)}
</button>
{isExpanded && !isCollapsed && (
<div className='bg-secondary-alt pl-4'>
{userEmail && (
<div className='sidebar-nav-link w-full text-left text-sm text-secondary'>
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>mail</span>
<span className='sidebar-nav-link-label text-xs'>{userEmail}</span>
</div>
)}
{isAdmin && (
<Link
to='/admin'
className='sidebar-nav-link w-full text-left text-sm'
onClick={() => {
setExpanded(false);
if (onNavigate) {
onNavigate();
}
}}
>
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>admin_panel_settings</span>
<span className='sidebar-nav-link-label'>Admin</span>
</Link>
)}
<button
className='sidebar-nav-link w-full text-left text-sm'
onClick={() => {
logout();
setExpanded(false);
if (onNavigate) {
onNavigate();
}
}}
type='button'
>
<span className='sidebar-nav-link-icon icon-base text-primary-alt'>logout</span>
<span className='sidebar-nav-link-label'>Logout</span>
</button>
</div>
)}
</div>
);
}
function getButtonValues(isLoggedIn, login, logout) {
return {
label: isLoggedIn ? 'Logout' : 'Login',
icon: isLoggedIn ? 'logout' : 'login',
action: isLoggedIn ? logout : login,
};
}
export default { NavBarItem, CollapsedMenu };
export default { NavBarItem, CollapsedMenu, SideBarItem };

View File

@@ -1,101 +1,119 @@
import { useAuth0 } from '@auth0/auth0-react';
import { useEffect } from 'react';
import { useDispatch, useSelector, batch } from 'react-redux';
import { useEffect, useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { resetState } from '../store/common';
import { load as loadSettingsState, loadState as loadSettingsLocalState } from '../store/settings/settings';
import { load as loadTasksState, loadState as loadTasksLocalState } from '../store/tasks/tasks';
import { load as loadUnlocksState, loadState as loadUnlocksLocalState } from '../store/unlocks/unlocks';
import {
fetchHiscores,
load as loadCharacterState,
loadState as loadCharacterLocalState,
} from '../store/user/character';
import { updateAccountCache } from '../store/user/account';
import { createUserIfNeeded, getUser } from '../client/user-data-client';
import { INITIAL_STATE as INITIAL_TASKS_STATE } from '../store/tasks/constants';
import { INITIAL_STATE as INITIAL_UNLOCKS_STATE } from '../store/unlocks/constants';
import { INITIAL_STATE as INITIAL_CHARACTER_STATE , INITIAL_STATE as INITIAL_SETTINGS_STATE } from '../store/user/constants';
import updateTasksVersion from '../store/tasks/updateTasksVersion';
import updateCharacterVersion from '../store/user/updateCharacterVersion';
import updateUnlocksVersion from '../store/unlocks/updateUnlocksVersion';
import { setLoggedIn, setLoggedOut } from '../store/user/account';
import { syncFromServer } from '../store/user/character';
import { getAuthStatus, getCurrentUser, logout as logoutApi } from '../client/auth-client';
import { syncCharacters, getCharacters } from '../client/character-client';
export default function useAccount({ redirectReturnToUrl }) {
const {
isLoading,
isAuthenticated,
user,
loginWithRedirect,
logout: logoutWithRedirect,
getAccessTokenSilently,
} = useAuth0();
const isLoginCache = useSelector(state => state.account.accountCache.isLoggedIn);
const accessToken = useSelector(state => state.account.accountCache.accessToken);
export default function useAccount() {
const [authModalOpen, setAuthModalOpen] = useState(false);
const hasSyncedRef = useRef(false);
const isLoggedIn = useSelector(state => state.account.accountCache.isLoggedIn);
const isChecking = useSelector(state => state.account.accountCache.isChecking);
const username = useSelector(state => state.account.accountCache.username);
const userEmail = useSelector(state => state.account.accountCache.userEmail);
const userRole = useSelector(state => state.account.accountCache.userRole);
const isAdmin = userRole === 'ADMIN';
const localCharacters = useSelector(state => state.character.characters);
const localActiveCharacter = useSelector(state => state.character.activeCharacter);
const dispatch = useDispatch();
useEffect(() => {
if (isAuthenticated) {
getAccessTokenSilently().then(token => {
dispatch(updateAccountCache({ isAuthenticated, user, accessToken: token }));
});
} else {
updateAccountCache({ isAuthenticated, user, accessToken: undefined });
}
}, [isAuthenticated]);
getAuthStatus().then(result => {
if (result.success && result.value?.authenticated) {
getCurrentUser().then(userResult => {
if (userResult.success) {
dispatch(setLoggedIn({
username: userResult.value.username,
email: userResult.value.email,
role: userResult.value.role,
}));
} else {
dispatch(setLoggedOut());
}
});
} else {
dispatch(setLoggedOut());
}
});
}, [dispatch]);
const isLoggedIn = isLoginCache || isAuthenticated;
// Sync characters once when user logs in
useEffect(() => {
if (isLoginCache && accessToken) {
getUser(user.email, accessToken).then(res => {
if (res.success) {
// user exists already, load their data and overwrite existing
batch(() => {
const settingsState = res.value.settings?.S ? JSON.parse(res.value.settings?.S) : INITIAL_SETTINGS_STATE;
const characterState = updateCharacterVersion(
res.value.character?.S ? JSON.parse(res.value.character?.S) : INITIAL_CHARACTER_STATE
);
const activeCharacter = characterState.characters[characterState.activeCharacter] ?? 'DEFAULT';
const taskState = updateTasksVersion(
res.value[`tasks_${activeCharacter}`]?.S
? JSON.parse(res.value[`tasks_${activeCharacter}`].S)
: INITIAL_TASKS_STATE
);
const unlocksState = updateUnlocksVersion(
res.value[`unlocks_${activeCharacter}`]?.S
? JSON.parse(res.value[`unlocks_${activeCharacter}`].S)
: INITIAL_UNLOCKS_STATE
);
dispatch(loadTasksState({ forceOverwrite: true, newState: taskState, skipDbUpdate: true }));
dispatch(loadSettingsState({ forceOverwrite: true, newState: settingsState, skipDbUpdate: true }));
dispatch(loadUnlocksState({ forceOverwrite: true, newState: unlocksState, skipDbUpdate: true }));
dispatch(loadCharacterState({ forceOverwrite: true, newState: characterState, skipDbUpdate: true }));
dispatch(fetchHiscores(characterState, null, true));
});
if (isLoggedIn && !hasSyncedRef.current) {
hasSyncedRef.current = true;
const doSync = async () => {
if (localCharacters.length > 0) {
const result = await syncCharacters(
localCharacters.map(rsn => ({ rsn })),
localActiveCharacter
);
if (result.success) {
const activeIndex = result.value.characters.findIndex(c => c.isActive);
dispatch(syncFromServer({
characters: result.value.characters,
activeIndex: activeIndex >= 0 ? activeIndex : 0,
}));
}
} else {
// user does not exist, create them and reload the current local state to save it to the DB
createUserIfNeeded(user.email, accessToken).then(result => {
if (result.success) {
batch(() => {
dispatch(loadTasksState({ forceOverwrite: true, newState: loadTasksLocalState() }));
dispatch(loadSettingsState({ forceOverwrite: true, newState: loadSettingsLocalState() }));
dispatch(loadUnlocksState({ forceOverwrite: true, newState: loadUnlocksLocalState() }));
dispatch(loadCharacterState({ forceOverwrite: true, newState: loadCharacterLocalState() }));
});
}
});
const result = await getCharacters();
if (result.success && result.value.characters.length > 0) {
const activeIndex = result.value.characters.findIndex(c => c.isActive);
dispatch(syncFromServer({
characters: result.value.characters,
activeIndex: activeIndex >= 0 ? activeIndex : 0,
}));
}
}
});
};
doSync();
}
}, [isLoginCache, accessToken]);
const isAuthenticating = isLoading;
// Reset sync flag on logout
if (!isLoggedIn) {
hasSyncedRef.current = false;
}
}, [isLoggedIn, localCharacters, localActiveCharacter, dispatch]);
const login = () => {
loginWithRedirect({ redirect_uri: redirectReturnToUrl });
};
const logout = () => {
logoutWithRedirect({ returnTo: redirectReturnToUrl });
resetState(dispatch, true);
const handleAuthSuccess = userData => {
dispatch(setLoggedIn({
username: userData.username,
email: userData.email,
role: userData.role,
}));
// Character sync will be triggered by the isLoggedIn effect
};
return { isLoggedIn, isAuthenticating, login, logout };
const openAuthModal = () => {
setAuthModalOpen(true);
};
const closeAuthModal = () => {
setAuthModalOpen(false);
};
const logout = async () => {
const result = await logoutApi();
if (result.success) {
dispatch(setLoggedOut());
resetState(dispatch, true);
}
};
return {
isLoggedIn,
isAuthenticating: isChecking,
username,
userEmail,
userRole,
isAdmin,
authModalOpen,
openAuthModal,
closeAuthModal,
handleAuthSuccess,
logout,
};
}

View File

@@ -0,0 +1,334 @@
import React, { useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import PageWrapper from '../components/PageWrapper';
import useAccount from '../hooks/useAccount';
import {
getAdminStats,
getUsers,
updateUser,
resetUserPassword,
deleteUser,
invalidateUserSessions,
} from '../client/admin-client';
function StatCard({ label, value, icon }) {
return (
<div className='bg-secondary p-4 rounded-lg'>
<div className='flex items-center gap-3'>
<span className='icon-lg text-primary-alt'>{icon}</span>
<div>
<p className='text-2xl font-bold text-accent'>{value}</p>
<p className='text-sm text-secondary'>{label}</p>
</div>
</div>
</div>
);
}
function UserRow({ user, onUpdate, onDelete }) {
const [isEditing, setIsEditing] = useState(false);
const [editRole, setEditRole] = useState(user.role);
const [showPasswordReset, setShowPasswordReset] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState(null);
const handleRoleChange = async () => {
setLoading(true);
const result = await updateUser(user.id, { role: editRole });
setLoading(false);
if (result.success) {
onUpdate();
setIsEditing(false);
setMessage({ type: 'success', text: 'Role updated' });
} else {
setMessage({ type: 'error', text: result.error });
}
setTimeout(() => setMessage(null), 3000);
};
const handlePasswordReset = async () => {
if (newPassword.length < 8) {
setMessage({ type: 'error', text: 'Password must be at least 8 characters' });
return;
}
setLoading(true);
const result = await resetUserPassword(user.id, newPassword);
setLoading(false);
if (result.success) {
setShowPasswordReset(false);
setNewPassword('');
setMessage({ type: 'success', text: 'Password reset and sessions invalidated' });
} else {
setMessage({ type: 'error', text: result.error });
}
setTimeout(() => setMessage(null), 3000);
};
const handleInvalidateSessions = async () => {
setLoading(true);
const result = await invalidateUserSessions(user.id);
setLoading(false);
if (result.success) {
setMessage({ type: 'success', text: result.value.message });
onUpdate();
} else {
setMessage({ type: 'error', text: result.error });
}
setTimeout(() => setMessage(null), 3000);
};
const handleDelete = async () => {
if (!window.confirm(`Are you sure you want to delete user "${user.username}"?`)) {
return;
}
setLoading(true);
const result = await deleteUser(user.id);
setLoading(false);
if (result.success) {
onDelete();
} else {
setMessage({ type: 'error', text: result.error });
setTimeout(() => setMessage(null), 3000);
}
};
return (
<tr className='border-b border-primary'>
<td className='py-3 px-4'>
<div className='font-medium text-accent'>{user.username}</div>
<div className='text-xs text-secondary'>{user.email}</div>
</td>
<td className='py-3 px-4'>
{isEditing ? (
<select
value={editRole}
onChange={e => setEditRole(e.target.value)}
className='input-primary text-sm'
>
<option value='USER'>USER</option>
<option value='ADMIN'>ADMIN</option>
</select>
) : (
<span
className={`px-2 py-1 rounded text-xs font-medium ${
user.role === 'ADMIN' ? 'bg-accent text-black' : 'bg-secondary text-primary'
}`}
>
{user.role}
</span>
)}
</td>
<td className='py-3 px-4 text-sm text-secondary'>{user.sessionCount}</td>
<td className='py-3 px-4 text-sm text-secondary'>
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className='py-3 px-4'>
<div className='flex gap-2 flex-wrap'>
{isEditing ? (
<>
<button
type='button'
onClick={handleRoleChange}
disabled={loading}
className='btn-primary text-xs px-2 py-1'
>
Save
</button>
<button
type='button'
onClick={() => {
setIsEditing(false);
setEditRole(user.role);
}}
className='btn-secondary text-xs px-2 py-1'
>
Cancel
</button>
</>
) : (
<>
<button
type='button'
onClick={() => setIsEditing(true)}
className='btn-secondary text-xs px-2 py-1'
title='Edit role'
>
<span className='icon-sm'>edit</span>
</button>
<button
type='button'
onClick={() => setShowPasswordReset(!showPasswordReset)}
className='btn-secondary text-xs px-2 py-1'
title='Reset password'
>
<span className='icon-sm'>key</span>
</button>
<button
type='button'
onClick={handleInvalidateSessions}
disabled={loading}
className='btn-secondary text-xs px-2 py-1'
title='Invalidate sessions'
>
<span className='icon-sm'>logout</span>
</button>
<button
type='button'
onClick={handleDelete}
disabled={loading}
className='btn-danger text-xs px-2 py-1'
title='Delete user'
>
<span className='icon-sm'>delete</span>
</button>
</>
)}
</div>
{showPasswordReset && (
<div className='mt-2 flex gap-2'>
<input
type='password'
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder='New password'
className='input-primary text-sm flex-1'
/>
<button
type='button'
onClick={handlePasswordReset}
disabled={loading}
className='btn-primary text-xs px-2 py-1'
>
Reset
</button>
</div>
)}
{message && (
<div
className={`mt-2 text-xs ${message.type === 'error' ? 'text-error' : 'text-success'}`}
>
{message.text}
</div>
)}
</td>
</tr>
);
}
export default function Admin() {
const { isLoggedIn, isAuthenticating, isAdmin } = useAccount();
const [stats, setStats] = useState(null);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchData = async () => {
setLoading(true);
const [statsResult, usersResult] = await Promise.all([getAdminStats(), getUsers()]);
if (statsResult.success) {
setStats(statsResult.value);
} else {
setError(statsResult.error);
}
if (usersResult.success) {
setUsers(usersResult.value.users);
} else {
setError(usersResult.error);
}
setLoading(false);
};
useEffect(() => {
if (isAdmin) {
fetchData();
}
}, [isAdmin]);
// Redirect non-admins
if (!isAuthenticating && (!isLoggedIn || !isAdmin)) {
return <Navigate to='/' replace />;
}
return (
<PageWrapper>
<div className='max-w-6xl mx-auto p-4'>
<h1 className='text-2xl font-bold text-accent mb-6 flex items-center gap-2'>
<span className='icon-lg'>admin_panel_settings</span>
Admin Dashboard
</h1>
{loading && (
<div className='text-center py-8'>
<span className='icon-lg animate-spin'>sync</span>
<p className='text-secondary mt-2'>Loading...</p>
</div>
)}
{error && (
<div className='bg-error/20 border border-error rounded p-4 mb-6'>
<p className='text-error'>{error}</p>
</div>
)}
{!loading && stats && (
<>
{/* Stats Cards */}
<div className='grid grid-cols-2 md:grid-cols-4 gap-4 mb-8'>
<StatCard label='Users' value={stats.users} icon='people' />
<StatCard label='Active Sessions' value={stats.activeSessions} icon='key' />
<StatCard label='Groups' value={stats.groups} icon='groups' />
<StatCard label='Members' value={stats.members} icon='person' />
</div>
{/* Users Table */}
<div className='bg-secondary rounded-lg overflow-hidden'>
<div className='p-4 border-b border-primary'>
<h2 className='text-lg font-semibold text-primary flex items-center gap-2'>
<span className='icon-base'>people</span>
Users ({users.length})
</h2>
</div>
<div className='overflow-x-auto'>
<table className='w-full'>
<thead className='bg-secondary-alt'>
<tr>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
User
</th>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
Role
</th>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
Sessions
</th>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
Created
</th>
<th className='text-left py-3 px-4 text-sm font-medium text-secondary'>
Actions
</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<UserRow
key={user.id}
user={user}
onUpdate={fetchData}
onDelete={fetchData}
/>
))}
</tbody>
</table>
</div>
</div>
</>
)}
</div>
</PageWrapper>
);
}

View File

@@ -0,0 +1,184 @@
import React, { useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import Card from '../components/common/Card';
import PageWrapper from '../components/PageWrapper';
import Spinner from '../components/common/Spinner';
import { resetPassword } from '../client/auth-client';
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setIsLoading(true);
const result = await resetPassword(token, password);
setIsLoading(false);
if (result.success) {
setSuccess(true);
} else {
setError(result.error || 'Failed to reset password');
}
};
// No token provided
if (!token) {
return (
<PageWrapper>
<div className='container lg:max-w-[500px] mx-auto mt-8'>
<Card>
<Card.Header>
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
Invalid Reset Link
</p>
</Card.Header>
<Card.Body>
<p className='text-center text-secondary text-sm p-4'>
This password reset link is invalid or has expired.
Please request a new password reset from the login page.
</p>
<div className='flex justify-center pb-4'>
<button
type='button'
className='button-filled px-6 py-2'
onClick={() => navigate('/')}
>
Go to Homepage
</button>
</div>
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}
// Success state
if (success) {
return (
<PageWrapper>
<div className='container lg:max-w-[500px] mx-auto mt-8'>
<Card>
<Card.Header>
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
Password Reset
</p>
</Card.Header>
<Card.Body>
<div className='p-4 text-center'>
<p className='text-success text-sm mb-4'>
Your password has been reset successfully!
</p>
<p className='text-secondary text-sm mb-4'>
You can now log in with your new password.
</p>
<button
type='button'
className='button-filled px-6 py-2'
onClick={() => navigate('/')}
>
Go to Homepage
</button>
</div>
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}
// Reset form
return (
<PageWrapper>
<div className='container lg:max-w-[500px] mx-auto mt-8'>
<Card>
<Card.Header>
<p className='text-accent font-bold text-center small-caps text-xl tracking-widest'>
Reset Password
</p>
</Card.Header>
<Card.Body>
<form onSubmit={handleSubmit} className='p-4 flex flex-col gap-4'>
<p className='text-secondary text-sm text-center'>
Enter your new password below.
</p>
<label htmlFor='new-password' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>New Password</span>
<input
id='new-password'
name='new-password'
type='password'
className='input-primary text-sm form-input'
placeholder='Enter new password'
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
autoComplete='new-password'
/>
</label>
<label htmlFor='confirm-password' className='flex flex-col gap-1'>
<span className='text-secondary text-xs'>Confirm Password</span>
<input
id='confirm-password'
name='confirm-password'
type='password'
className='input-primary text-sm form-input'
placeholder='Confirm new password'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
autoComplete='new-password'
/>
</label>
{error && (
<div className='text-error text-xs text-center'>{error}</div>
)}
<button
type='submit'
className='button-filled py-2'
disabled={isLoading}
>
{isLoading ? (
<span className='flex items-center justify-center gap-2'>
<Spinner size={Spinner.SIZE.sm} /> Resetting...
</span>
) : (
'Reset Password'
)}
</button>
</form>
</Card.Body>
</Card>
</div>
</PageWrapper>
);
}

View File

@@ -0,0 +1,15 @@
const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function(app) {
// Proxy API requests to the Node.js backend
app.use(
'/api',
createProxyMiddleware({
target: process.env.REACT_APP_RELDO_URL || 'http://localhost:3003',
changeOrigin: true,
secure: false,
// Pass cookies for session auth
cookieDomainRewrite: 'localhost',
})
);
};

View File

@@ -1,25 +1,49 @@
/* eslint-disable no-unused-vars */
import { LOCALSTORAGE_KEYS } from '../client/localstorage-client';
import { updateCharacter } from '../client/character-client';
// Debounce server sync to avoid too many requests
let syncTimeout = null;
const SYNC_DEBOUNCE_MS = 2000;
// eslint-disable-next-line no-unused-vars
export default function updateWithUserDataStorage(wrappedDispatchFn, wrappedFnProps, localstorageKey, stateKey) {
return async (dispatch, getState) => {
await dispatch(wrappedDispatchFn(wrappedFnProps));
// TODO re-enable user login
// const dataKey = (() => {
// switch (localstorageKey) {
// case LOCALSTORAGE_KEYS.UNLOCKS:
// case LOCALSTORAGE_KEYS.TASKS: {
// const state = getState();
// return `${localstorageKey}_${state.character.characters[state.character.activeCharacter] ?? 'DEFAULT'}`;
// }
// default: {
// return localstorageKey;
// }
// }
// })();
const state = getState();
const { isLoggedIn } = state.account.accountCache;
// const { isLoggedIn, userEmail, accessToken } = getState().account.accountCache;
// if (isLoggedIn && !wrappedFnProps.skipDbUpdate) {
// putUserData(userEmail, dataKey, getState()[stateKey], accessToken);
// }
// Sync to server if logged in and not skipping DB update
if (isLoggedIn && !wrappedFnProps?.skipDbUpdate) {
// Only sync tasks and unlocks data
if (localstorageKey === LOCALSTORAGE_KEYS.TASKS || localstorageKey === LOCALSTORAGE_KEYS.UNLOCKS) {
const activeRsn = state.character.characters[state.character.activeCharacter];
const characterId = state.character.characterIds[activeRsn];
if (characterId) {
// Debounce the sync to avoid too many requests
if (syncTimeout) {
clearTimeout(syncTimeout);
}
syncTimeout = setTimeout(async () => {
const currentState = getState();
const updateData = {};
if (localstorageKey === LOCALSTORAGE_KEYS.TASKS) {
updateData.tasksData = currentState.tasks;
} else if (localstorageKey === LOCALSTORAGE_KEYS.UNLOCKS) {
updateData.unlocksData = currentState.unlocks;
}
try {
await updateCharacter(characterId, updateData);
} catch (error) {
console.warn('Failed to sync data to server:', error);
}
}, SYNC_DEBOUNCE_MS);
}
}
}
};
}

View File

@@ -2,14 +2,16 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const CURRENT_VERSION = 3;
export const CURRENT_VERSION = 5;
const INITIAL_STATE = {
version: CURRENT_VERSION,
accountCache: {
isLoggedIn: false,
isChecking: true,
username: undefined,
userEmail: undefined,
accessToken: undefined,
userRole: undefined,
},
};
@@ -17,18 +19,30 @@ export const accountSlice = createSlice({
name: 'account',
initialState: INITIAL_STATE,
reducers: {
updateAccountCache: (state, action) => {
state.accountCache.isLoggedIn = action.payload.isAuthenticated;
state.accountCache.userEmail = action.payload.isAuthenticated ? action.payload.user.email : undefined;
state.accountCache.accessToken = action.payload.isAuthenticated ? action.payload.accessToken : undefined;
setAuthChecking: (state, action) => {
state.accountCache.isChecking = action.payload;
},
setLoggedIn: (state, action) => {
state.accountCache.isLoggedIn = true;
state.accountCache.isChecking = false;
state.accountCache.username = action.payload.username;
state.accountCache.userEmail = action.payload.email;
state.accountCache.userRole = action.payload.role;
},
setLoggedOut: state => {
state.accountCache.isLoggedIn = false;
state.accountCache.isChecking = false;
state.accountCache.username = undefined;
state.accountCache.userEmail = undefined;
state.accountCache.userRole = undefined;
},
reset: () => INITIAL_STATE,
},
});
// Don't cache anything across sessions, let auth0 handle it
// Don't cache anything across sessions, session cookie handles it
export const loadState = () => INITIAL_STATE;
export const { updateAccountCache, reset } = accountSlice.actions;
export const { setAuthChecking, setLoggedIn, setLoggedOut, reset } = accountSlice.actions;
export default accountSlice.reducer;

View File

@@ -16,11 +16,18 @@ export const characterSlice = createSlice({
},
addCharacter: (state, action) => {
state.characters.push(action.payload.rsn);
if (action.payload.id) {
state.characterIds[action.payload.rsn] = action.payload.id;
}
if (action.payload.setActive) {
state.activeCharacter = state.characters.length - 1;
}
},
deleteCharacter: (state, action) => {
const rsn = state.characters[action.payload];
if (rsn && state.characterIds[rsn]) {
delete state.characterIds[rsn];
}
state.characters.splice(action.payload, 1);
if (state.activeCharacter === action.payload) {
state.activeCharacter = 0;
@@ -29,8 +36,28 @@ export const characterSlice = createSlice({
}
},
renameCharacter: (state, action) => {
const oldRsn = state.characters[action.payload.index];
const serverId = state.characterIds[oldRsn];
if (serverId) {
delete state.characterIds[oldRsn];
state.characterIds[action.payload.rsn] = serverId;
}
state.characters[action.payload.index] = action.payload.rsn;
},
// Sync characters from server (replaces local state)
syncFromServer: (state, action) => {
const { characters, activeIndex } = action.payload;
state.characters = characters.map(c => c.rsn);
state.characterIds = {};
characters.forEach(c => {
state.characterIds[c.rsn] = c.id;
});
state.activeCharacter = activeIndex ?? 0;
},
// Set server ID for a character
setCharacterId: (state, action) => {
state.characterIds[action.payload.rsn] = action.payload.id;
},
updateHiscores: (state, action) => {
switch (action.payload.type) {
case 'LOADING':
@@ -128,6 +155,6 @@ export function reset(props) {
return updateWithUserDataStorage(innerReset, props, LOCALSTORAGE_KEYS.CHARACTER, 'character');
}
export const { updateHiscores } = characterSlice.actions;
export const { updateHiscores, syncFromServer, setCharacterId } = characterSlice.actions;
export default characterSlice.reducer;

View File

@@ -1,4 +1,4 @@
export const CURRENT_VERSION = 2;
export const CURRENT_VERSION = 3;
export const HISCORES_TTL = 1800000; // 30 min in ms
@@ -13,5 +13,7 @@ export const INITIAL_STATE = {
version: CURRENT_VERSION,
activeCharacter: 0,
characters: [],
// Map of RSN -> server character ID (for sync)
characterIds: {},
hiscoresCache: INITIAL_HISCORES_STATE,
};

View File

@@ -2,6 +2,7 @@ import { CURRENT_VERSION } from './constants';
const versionUpdaters = {
2: updateToV2,
3: updateToV3,
};
export default function updateCharacterVersion(state) {
@@ -27,3 +28,12 @@ function updateToV2(prevState) {
characters: [prevState.username],
};
}
function updateToV3(prevState) {
// V3 adds characterIds map for server sync
return {
...prevState,
version: 3,
characterIds: {},
};
}

View File

@@ -947,6 +947,16 @@ body:is(.dark *) {
border-color: rgb(113 113 122 / var(--tw-border-opacity, 1));
}
.border-error {
--tw-border-opacity: 1;
border-color: rgb(220 38 38 / var(--tw-border-opacity, 1));
}
.border-error:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
}
.shadow-primary {
--tw-shadow-color: rgb(0 0 0 / 0.1);
--tw-shadow: var(--tw-shadow-colored);
@@ -2742,6 +2752,9 @@ select[multiple]:focus option:checked {
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.mb-auto {
margin-bottom: auto;
}
@@ -2976,6 +2989,9 @@ select[multiple]:focus option:checked {
.max-w-5xl {
max-width: 64rem;
}
.max-w-6xl {
max-width: 72rem;
}
.max-w-\[3\.5rem\] {
max-width: 3.5rem;
}
@@ -3185,6 +3201,9 @@ select[multiple]:focus option:checked {
.overflow-hidden {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-y-auto {
overflow-y: auto;
}
@@ -3528,6 +3547,9 @@ select[multiple]:focus option:checked {
.font-medium {
font-weight: 500;
}
.font-normal {
font-weight: 400;
}
.font-semibold {
font-weight: 600;
}
@@ -3558,6 +3580,10 @@ select[multiple]:focus option:checked {
.tracking-widest {
letter-spacing: 0.1em;
}
.text-black {
--tw-text-opacity: 1;
color: rgb(0 0 0 / var(--tw-text-opacity, 1));
}
.text-blue-400 {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity, 1));

View File

@@ -1,5 +1,5 @@
[Unit]
Description=OS League Tools - OSRS Leagues Hub
Description=OS League Tools - Prod Environment
After=network.target
Wants=network-online.target
@@ -13,7 +13,7 @@ WorkingDirectory=/home/sonder/leagues-tools/os-league-tools-master
Environment="NODE_ENV=production"
Environment="PORT=3000"
# Uncomment and set if you have a backend API:
# Environment="REACT_APP_RELDO_URL=http://localhost:8080"
Environment="REACT_APP_RELDO_URL=https://api.leagues.tools"
# Start the application (serves the pre-built static files)
ExecStart=/usr/bin/serve -s build -l tcp://0.0.0.0:3000

1672
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "leagues-tools-dev",
"private": true,
"scripts": {
"start": "npx pm2 start ecosystem.config.js",
"stop": "npx pm2 stop all",
"restart": "npx pm2 restart all",
"status": "npx pm2 status",
"logs": "npx pm2 logs",
"logs:prod": "npx pm2 logs api-prod",
"logs:dev": "npx pm2 logs api-dev",
"logs:frontend": "npx pm2 logs frontend-dev",
"kill": "npx pm2 kill"
},
"devDependencies": {
"pm2": "^6.0.14"
}
}

23
server/.env Normal file
View File

@@ -0,0 +1,23 @@
# Development environment
NODE_ENV=development
# Database (separate dev database)
DATABASE_URL="file:./data-dev.db"
# Server
PORT=3003
# Security
SESSION_SECRET="K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z"
BACKEND_SECRET="A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f"
# CORS - allow React dev server
CORS_ORIGINS="http://localhost:3000,http://localhost:3001,https://dev.leagues.tools"
# Captcha (disabled for dev)
CAPTCHA_ENABLED=false
CAPTCHA_SITEKEY=""
CAPTCHA_SECRET=""
# Frontend build path (not used in dev - React dev server handles it)
FRONTEND_BUILD_PATH="../os-league-tools-master/build"

23
server/.env.development Normal file
View File

@@ -0,0 +1,23 @@
# Development environment
NODE_ENV=development
# Database (separate dev database)
DATABASE_URL="file:./data-dev.db"
# Server
PORT=3003
# Security
SESSION_SECRET="K7xP2mN9qR4sT6vW8yB1cD3fG5hJ0kL2nO4pQ6rS8tU0wX2z"
BACKEND_SECRET="A1bC3dE5fG7hI9jK1lM3nO5pQ7rS9tU1vW3xY5zA7bC9dE1f"
# CORS - allow React dev server
CORS_ORIGINS="http://localhost:3000,http://localhost:3001,https://dev.leagues.tools"
# Captcha (disabled for dev)
CAPTCHA_ENABLED=false
CAPTCHA_SITEKEY=""
CAPTCHA_SECRET=""
# Frontend build path (not used in dev - React dev server handles it)
FRONTEND_BUILD_PATH="../os-league-tools-master/build"

28
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Build output
dist/
# Database
*.db
*.db-journal
# Environment
.env.local
.env.production
# IDE
.idea/
.vscode/
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Prisma
prisma/migrations/

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Leagues Tools Development Server
After=network.target
[Service]
Type=simple
User=sonder
WorkingDirectory=/home/sonder/leagues-tools-dev/server
ExecStart=/usr/bin/npm run dev
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=leagues-tools-dev
Environment=NODE_ENV=development
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Leagues Tools Production Server
After=network.target
[Service]
Type=simple
User=sonder
WorkingDirectory=/home/sonder/leagues-tools-dev/server
ExecStart=/usr/bin/npm run prod
Restart=on-failure
RestartSec=10
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=leagues-tools-prod
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target

1729
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
server/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "leagues-tools-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "cp .env.development .env && tsx watch src/index.ts",
"prod": "cp .env.production .env && tsx src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start:dev": "cp .env.development .env && node dist/index.js",
"start:prod": "cp .env.production .env && node dist/index.js",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:push:dev": "cp .env.development .env && prisma db push",
"db:push:prod": "cp .env.production .env && prisma db push",
"db:migrate": "prisma migrate dev",
"db:studio": "prisma studio"
},
"dependencies": {
"@hono/node-server": "^1.13.7",
"@prisma/client": "^6.2.1",
"bcrypt": "^5.1.1",
"blakejs": "^1.2.1",
"hono": "^4.6.16",
"nodemailer": "^7.0.13",
"uuid": "^11.0.5"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.10.5",
"@types/nodemailer": "^7.0.9",
"@types/uuid": "^10.0.0",
"prisma": "^6.2.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

230
server/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,230 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// User roles
enum Role {
USER
ADMIN
}
// User authentication
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
passwordHash String
role Role @default(USER)
resetToken String? // Password reset token
resetTokenExpiry DateTime? // Token expiration time
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
characters Character[]
ownedGroups Group[] @relation("GroupOwner")
}
// User's OSRS characters (for task/unlock tracking)
model Character {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rsn String // RuneScape Name
isActive Boolean @default(false)
// Synced data (stored as JSON)
tasksData String? // JSON - task completion status
unlocksData String? // JSON - unlock status
notesData String? // JSON - user notes/planner data
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, rsn])
@@index([userId])
}
model Session {
id String @id @default(uuid())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId])
}
// Group tracking (for RuneLite plugin)
model Group {
id Int @id @default(autoincrement())
name String
tokenHash String // Blake2b-256 hash of token
version Int @default(1)
ownerId Int? // Optional - user who owns/manages this group
owner User? @relation("GroupOwner", fields: [ownerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
members Member[]
@@unique([name, tokenHash])
@@index([tokenHash])
@@index([ownerId])
}
model Member {
id Int @id @default(autoincrement())
groupId Int
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
name String
// Stats (HP, Prayer, Energy, World, etc.) - JSON array of 7 integers
stats String? // JSON
statsLastUpdate DateTime?
// Coordinates (x, y, plane) - JSON array of 3 integers
coordinates String? // JSON
coordinatesLastUpdate DateTime?
// Skills (24 skills) - JSON array of 24 integers
skills String? // JSON
skillsLastUpdate DateTime?
// Quests - binary blob
quests Bytes?
questsLastUpdate DateTime?
// Inventory (56 items) - JSON array of 56 integers
inventory String? // JSON
inventoryLastUpdate DateTime?
// Equipment (28 slots) - JSON array of 28 integers
equipment String? // JSON
equipmentLastUpdate DateTime?
// Rune pouch (8 runes) - JSON array of 8 integers
runePouch String? // JSON
runePouchLastUpdate DateTime?
// Bank - JSON array (variable length)
bank String? // JSON
bankLastUpdate DateTime?
// Seed vault - JSON array (variable length)
seedVault String? // JSON
seedVaultLastUpdate DateTime?
// Interacting NPC
interacting String?
interactingLastUpdate DateTime?
// Diary vars (62 integers) - JSON array
diaryVars String? // JSON
diaryVarsLastUpdate DateTime?
// Overall last update
lastUpdated DateTime?
// Skills aggregation
skillsDay SkillsDay[]
skillsMonth SkillsMonth[]
skillsYear SkillsYear[]
// Collection log
collectionLogs CollectionLog[]
collectionLogsNew CollectionLogNew[]
@@unique([groupId, name])
@@index([groupId])
}
// Skills aggregation tables
model SkillsDay {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
time DateTime
skills String // JSON array of 24 integers
@@id([memberId, time])
}
model SkillsMonth {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
time DateTime
skills String // JSON array of 24 integers
@@id([memberId, time])
}
model SkillsYear {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
time DateTime
skills String // JSON array of 24 integers
@@id([memberId, time])
}
// Aggregation tracking
model AggregationInfo {
type String @id
lastAggregation DateTime @default(dbgenerated("'2000-01-01 00:00:00'"))
}
// Collection log tables
model CollectionTab {
id Int @id @default(autoincrement())
name String
pages CollectionPage[]
}
model CollectionPage {
id Int @id @default(autoincrement())
tabId Int
tab CollectionTab @relation(fields: [tabId], references: [id], onDelete: Cascade)
pageName String
collectionLogs CollectionLog[]
collectionLogsNew CollectionLogNew[]
@@unique([tabId, pageName])
}
model CollectionLog {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
pageId Int
page CollectionPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
items String? // JSON array of item IDs
counts String? // JSON array of completion counts
lastUpdated DateTime?
@@id([memberId, pageId])
}
model CollectionLogNew {
memberId Int
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
pageId Int
page CollectionPage @relation(fields: [pageId], references: [id], onDelete: Cascade)
newItems String? // JSON array of new item IDs
lastUpdated DateTime?
@@id([memberId, pageId])
}
// Hiscores cache - stores fetched hiscores data
model HiscoresCache {
rsn String @id // RuneScape Name (lowercase for lookup)
displayRsn String // Original case RSN for display
skills String // JSON - skill data
clues String // JSON - clue scroll data
activities String // JSON - activities/bosses data
leaguePoints Int @default(0)
fetchedAt DateTime @default(now())
@@index([fetchedAt])
}

62
server/src/app.ts Normal file
View File

@@ -0,0 +1,62 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from '@hono/node-server/serve-static';
import { sessionMiddleware } from './middleware/session';
import authRoutes from './routes/auth';
import publicRoutes from './routes/public';
import groupRoutes from './routes/groups';
import memberRoutes from './routes/members';
import adminRoutes from './routes/admin';
import characterRoutes from './routes/characters';
import hiscoresRoutes from './routes/hiscores';
export function createApp() {
const app = new Hono();
// Middleware
app.use('*', logger());
// CORS - configured via CORS_ORIGINS env var
const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',')
: ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:4000'];
app.use(
'*',
cors({
origin: corsOrigins,
credentials: true,
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
maxAge: 3600,
})
);
// Session middleware for all routes
app.use('*', sessionMiddleware);
// API Routes
app.route('/api', authRoutes);
app.route('/api', publicRoutes);
app.route('/api/group', groupRoutes);
app.route('/api/group', memberRoutes);
app.route('/api/admin', adminRoutes);
app.route('/api/characters', characterRoutes);
// Health check
app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
// Hiscores proxy - under /api so nginx routes correctly
app.route('/api/hiscores', hiscoresRoutes);
// Serve static files from React build
const frontendPath = process.env.FRONTEND_BUILD_PATH || '../os-league-tools-master/build';
app.use('/*', serveStatic({ root: frontendPath }));
// SPA fallback - serve index.html for all non-API routes
app.get('*', serveStatic({ path: `${frontendPath}/index.html` }));
return app;
}

17
server/src/db.ts Normal file
View File

@@ -0,0 +1,17 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
export async function connectDatabase() {
try {
await prisma.$connect();
console.log('Database connected');
} catch (error) {
console.error('Database connection failed:', error);
process.exit(1);
}
}
export async function disconnectDatabase() {
await prisma.$disconnect();
}

39
server/src/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import { serve } from '@hono/node-server';
import { createApp } from './app';
import { connectDatabase, disconnectDatabase } from './db';
const PORT = parseInt(process.env.PORT || '3001', 10);
async function main() {
// Connect to database
await connectDatabase();
// Create app
const app = createApp();
// Start server
console.log(`Server starting on port ${PORT}...`);
serve({
fetch: app.fetch,
port: PORT,
});
console.log(`Server running at http://localhost:${PORT}`);
console.log(`API available at http://localhost:${PORT}/api`);
// Graceful shutdown
const shutdown = async () => {
console.log('\nShutting down...');
await disconnectDatabase();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});

View File

@@ -0,0 +1,52 @@
import { Context, Next } from 'hono';
import { prisma } from '../db';
import { hashToken } from '../utils/blake2';
// Extend Hono context with group data
declare module 'hono' {
interface ContextVariableMap {
groupId: number | null;
groupName: string | null;
}
}
/**
* Group token authentication middleware.
* Validates the Authorization header token against the group.
*
* Expected header format: Authorization: {token}
* Group name is extracted from the URL path parameter.
*/
export async function groupAuthMiddleware(c: Context, next: Next) {
const groupName = c.req.param('group_name');
const token = c.req.header('Authorization');
if (!groupName) {
return c.json({ error: 'Group name required' }, 400);
}
if (!token) {
return c.json({ error: 'Authorization token required' }, 401);
}
// Hash the token with the group name as salt
const tokenHash = hashToken(token, groupName);
// Find the group with matching name and token hash
const group = await prisma.group.findFirst({
where: {
name: groupName,
tokenHash: tokenHash,
},
});
if (!group) {
return c.json({ error: 'Invalid token or group not found' }, 401);
}
// Set group info in context
c.set('groupId', group.id);
c.set('groupName', group.name);
await next();
}

View File

@@ -0,0 +1,129 @@
import { Context, Next } from 'hono';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { Role } from '@prisma/client';
import { prisma } from '../db';
const SESSION_COOKIE_NAME = 'session_id';
const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
export interface SessionUser {
id: number;
username: string;
email: string;
role: Role;
}
// Extend Hono context with session data
declare module 'hono' {
interface ContextVariableMap {
user: SessionUser | null;
sessionId: string | null;
}
}
/**
* Session middleware - validates session cookie and sets user in context
*/
export async function sessionMiddleware(c: Context, next: Next) {
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
if (sessionId) {
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true },
});
if (session && session.expiresAt > new Date()) {
c.set('user', {
id: session.user.id,
username: session.user.username,
email: session.user.email,
role: session.user.role,
});
c.set('sessionId', sessionId);
} else if (session) {
// Session expired, clean it up
await prisma.session.delete({ where: { id: sessionId } });
deleteCookie(c, SESSION_COOKIE_NAME);
c.set('user', null);
c.set('sessionId', null);
} else {
c.set('user', null);
c.set('sessionId', null);
}
} else {
c.set('user', null);
c.set('sessionId', null);
}
await next();
}
/**
* Create a new session for a user
*/
export async function createSession(c: Context, userId: number): Promise<string> {
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE);
const session = await prisma.session.create({
data: {
userId,
expiresAt,
},
});
setCookie(c, SESSION_COOKIE_NAME, session.id, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
maxAge: SESSION_MAX_AGE / 1000,
});
return session.id;
}
/**
* Destroy the current session
*/
export async function destroySession(c: Context): Promise<void> {
const sessionId = c.get('sessionId');
if (sessionId) {
await prisma.session.delete({ where: { id: sessionId } }).catch(() => {});
}
deleteCookie(c, SESSION_COOKIE_NAME);
c.set('user', null);
c.set('sessionId', null);
}
/**
* Middleware to require authentication
*/
export async function requireAuth(c: Context, next: Next) {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
}
/**
* Middleware to require admin role
*/
export async function requireAdmin(c: Context, next: Next) {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
if (user.role !== 'ADMIN') {
return c.json({ error: 'Forbidden: Admin access required' }, 403);
}
await next();
}

221
server/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,221 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { requireAdmin } from '../middleware/session';
import { hashPassword } from '../utils/password';
const admin = new Hono();
// All admin routes require admin role
admin.use('/*', requireAdmin);
/**
* GET /api/admin/users
* List all users
*/
admin.get('/users', async (c) => {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
_count: {
select: { sessions: true },
},
},
orderBy: { createdAt: 'desc' },
});
return c.json({
users: users.map((u) => ({
...u,
sessionCount: u._count.sessions,
_count: undefined,
})),
});
});
/**
* GET /api/admin/users/:id
* Get single user details
*/
admin.get('/users/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
sessions: {
select: {
id: true,
createdAt: true,
expiresAt: true,
},
},
},
});
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
return c.json({ user });
});
/**
* PATCH /api/admin/users/:id
* Update user (role, email, etc.)
*/
admin.patch('/users/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const body = await c.req.json();
const { role, email, username } = body;
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const updateData: Record<string, unknown> = {};
if (role && ['USER', 'ADMIN'].includes(role)) {
updateData.role = role;
}
if (email) {
// Check if email is already taken
const existing = await prisma.user.findFirst({
where: { email, NOT: { id } },
});
if (existing) {
return c.json({ error: 'Email already in use' }, 400);
}
updateData.email = email;
}
if (username) {
// Check if username is already taken
const existing = await prisma.user.findFirst({
where: { username, NOT: { id } },
});
if (existing) {
return c.json({ error: 'Username already in use' }, 400);
}
updateData.username = username;
}
const updated = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
username: true,
email: true,
role: true,
updatedAt: true,
},
});
return c.json({ user: updated });
});
/**
* PATCH /api/admin/users/:id/password
* Reset user password
*/
admin.patch('/users/:id/password', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const body = await c.req.json();
const { password } = body;
if (!password || password.length < 8) {
return c.json({ error: 'Password must be at least 8 characters' }, 400);
}
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id },
data: { passwordHash },
});
// Invalidate all sessions for this user
await prisma.session.deleteMany({ where: { userId: id } });
return c.json({ success: true, message: 'Password reset and sessions invalidated' });
});
/**
* DELETE /api/admin/users/:id
* Delete user
*/
admin.delete('/users/:id', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const currentUser = c.get('user')!;
// Prevent self-deletion
if (currentUser.id === id) {
return c.json({ error: 'Cannot delete your own account' }, 400);
}
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
await prisma.user.delete({ where: { id } });
return c.json({ success: true, message: 'User deleted' });
});
/**
* DELETE /api/admin/users/:id/sessions
* Invalidate all sessions for a user
*/
admin.delete('/users/:id/sessions', async (c) => {
const id = parseInt(c.req.param('id'), 10);
const user = await prisma.user.findUnique({ where: { id } });
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const result = await prisma.session.deleteMany({ where: { userId: id } });
return c.json({ success: true, message: `${result.count} sessions invalidated` });
});
/**
* GET /api/admin/stats
* Get admin dashboard stats
*/
admin.get('/stats', async (c) => {
const [userCount, sessionCount, groupCount, memberCount] = await Promise.all([
prisma.user.count(),
prisma.session.count(),
prisma.group.count(),
prisma.member.count(),
]);
return c.json({
users: userCount,
activeSessions: sessionCount,
groups: groupCount,
members: memberCount,
});
});
export default admin;

233
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,233 @@
import { Hono } from 'hono';
import { randomBytes } from 'crypto';
import { prisma } from '../db';
import { hashPassword, verifyPassword } from '../utils/password';
import { createSession, destroySession, requireAuth } from '../middleware/session';
import { sendPasswordResetEmail, isEmailConfigured } from '../utils/email';
const auth = new Hono();
/**
* POST /api/register
* Create a new user account
*/
auth.post('/register', async (c) => {
const body = await c.req.json();
const { username, email, password } = body;
// Validation
if (!username || !email || !password) {
return c.json({ error: 'Username, email, and password are required' }, 400);
}
if (username.length < 3 || username.length > 30) {
return c.json({ error: 'Username must be 3-30 characters' }, 400);
}
if (password.length < 8) {
return c.json({ error: 'Password must be at least 8 characters' }, 400);
}
// Check if username or email already exists
const existingUser = await prisma.user.findFirst({
where: {
OR: [{ username }, { email }],
},
});
if (existingUser) {
if (existingUser.username === username) {
return c.json({ error: 'Username already taken' }, 400);
}
return c.json({ error: 'Email already registered' }, 400);
}
// Create user
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: {
username,
email,
passwordHash,
},
});
// Create session
await createSession(c, user.id);
return c.json({
username: user.username,
email: user.email,
role: user.role,
});
});
/**
* POST /api/login
* Authenticate and create session
*/
auth.post('/login', async (c) => {
const body = await c.req.json();
const { username, password } = body;
if (!username || !password) {
return c.json({ error: 'Username and password are required' }, 400);
}
// Find user by username or email
const user = await prisma.user.findFirst({
where: {
OR: [{ username }, { email: username }],
},
});
if (!user) {
return c.json({ error: 'Invalid username or password' }, 401);
}
// Verify password
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return c.json({ error: 'Invalid username or password' }, 401);
}
// Create session
await createSession(c, user.id);
return c.json({
username: user.username,
email: user.email,
role: user.role,
});
});
/**
* POST /api/logout
* Destroy session
*/
auth.post('/logout', async (c) => {
await destroySession(c);
return c.json({ success: true });
});
/**
* GET /api/auth/status
* Check if user is authenticated
*/
auth.get('/auth/status', (c) => {
const user = c.get('user');
return c.json({
authenticated: !!user,
});
});
/**
* GET /api/me
* Get current user info
*/
auth.get('/me', requireAuth, (c) => {
const user = c.get('user');
return c.json({
username: user!.username,
email: user!.email,
role: user!.role,
});
});
/**
* POST /api/forgot-password
* Request a password reset
*/
auth.post('/forgot-password', async (c) => {
const body = await c.req.json();
const { email } = body;
if (!email) {
return c.json({ error: 'Email is required' }, 400);
}
// Find user by email
const user = await prisma.user.findUnique({
where: { email },
});
// Always return success to prevent email enumeration
if (!user) {
return c.json({ message: 'If an account with that email exists, a reset link has been sent.' });
}
// Generate secure token
const resetToken = randomBytes(32).toString('hex');
const resetTokenExpiry = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
// Store token in database
await prisma.user.update({
where: { id: user.id },
data: {
resetToken,
resetTokenExpiry,
},
});
// Send email with reset link
const baseUrl = process.env.APP_URL || 'https://leagues.tools';
if (isEmailConfigured()) {
const emailResult = await sendPasswordResetEmail(email, resetToken, baseUrl);
if (!emailResult.success) {
console.error(`Failed to send password reset email to ${email}:`, emailResult.error);
}
} else {
// Log token in development when email is not configured
console.log(`Password reset requested for ${email}. Token: ${resetToken}`);
console.log(`Reset URL: ${baseUrl}/reset-password?token=${resetToken}`);
}
return c.json({ message: 'If an account with that email exists, a reset link has been sent.' });
});
/**
* POST /api/reset-password
* Reset password using token
*/
auth.post('/reset-password', async (c) => {
const body = await c.req.json();
const { token, password } = body;
if (!token || !password) {
return c.json({ error: 'Token and password are required' }, 400);
}
if (password.length < 8) {
return c.json({ error: 'Password must be at least 8 characters' }, 400);
}
// Find user with this token
const user = await prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: {
gt: new Date(), // Token must not be expired
},
},
});
if (!user) {
return c.json({ error: 'Invalid or expired reset token' }, 400);
}
// Update password and clear token
const passwordHash = await hashPassword(password);
await prisma.user.update({
where: { id: user.id },
data: {
passwordHash,
resetToken: null,
resetTokenExpiry: null,
},
});
return c.json({ message: 'Password has been reset successfully. You can now log in.' });
});
export default auth;

View File

@@ -0,0 +1,334 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { requireAuth } from '../middleware/session';
const characters = new Hono();
// All character routes require authentication
characters.use('/*', requireAuth);
/**
* GET /api/characters
* Get all characters for the authenticated user
*/
characters.get('/', async (c) => {
const user = c.get('user')!;
const userCharacters = await prisma.character.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'asc' },
});
return c.json({
characters: userCharacters.map((char) => ({
id: char.id,
rsn: char.rsn,
isActive: char.isActive,
tasksData: char.tasksData ? JSON.parse(char.tasksData) : null,
unlocksData: char.unlocksData ? JSON.parse(char.unlocksData) : null,
notesData: char.notesData ? JSON.parse(char.notesData) : null,
createdAt: char.createdAt,
updatedAt: char.updatedAt,
})),
});
});
/**
* POST /api/characters
* Create a new character
*/
characters.post('/', async (c) => {
const user = c.get('user')!;
const body = await c.req.json();
const { rsn, setActive } = body;
if (!rsn || typeof rsn !== 'string') {
return c.json({ error: 'RSN is required' }, 400);
}
const trimmedRsn = rsn.trim();
if (trimmedRsn.length < 1 || trimmedRsn.length > 12) {
return c.json({ error: 'RSN must be 1-12 characters' }, 400);
}
// Check if character already exists for this user
const existing = await prisma.character.findUnique({
where: { userId_rsn: { userId: user.id, rsn: trimmedRsn } },
});
if (existing) {
return c.json({ error: 'Character already exists' }, 400);
}
// If setActive, deactivate all other characters first
if (setActive) {
await prisma.character.updateMany({
where: { userId: user.id },
data: { isActive: false },
});
}
const character = await prisma.character.create({
data: {
userId: user.id,
rsn: trimmedRsn,
isActive: setActive || false,
},
});
return c.json({
character: {
id: character.id,
rsn: character.rsn,
isActive: character.isActive,
tasksData: null,
unlocksData: null,
notesData: null,
createdAt: character.createdAt,
updatedAt: character.updatedAt,
},
});
});
/**
* PATCH /api/characters/:id
* Update a character (rename, set active, sync data)
*/
characters.patch('/:id', async (c) => {
const user = c.get('user')!;
const id = parseInt(c.req.param('id'), 10);
const body = await c.req.json();
const character = await prisma.character.findFirst({
where: { id, userId: user.id },
});
if (!character) {
return c.json({ error: 'Character not found' }, 404);
}
const updateData: Record<string, unknown> = {};
// Rename
if (body.rsn !== undefined) {
const trimmedRsn = body.rsn.trim();
if (trimmedRsn.length < 1 || trimmedRsn.length > 12) {
return c.json({ error: 'RSN must be 1-12 characters' }, 400);
}
// Check if new name already exists
const existing = await prisma.character.findFirst({
where: { userId: user.id, rsn: trimmedRsn, NOT: { id } },
});
if (existing) {
return c.json({ error: 'Character with that name already exists' }, 400);
}
updateData.rsn = trimmedRsn;
}
// Set active
if (body.isActive !== undefined) {
if (body.isActive) {
// Deactivate all other characters
await prisma.character.updateMany({
where: { userId: user.id, NOT: { id } },
data: { isActive: false },
});
}
updateData.isActive = body.isActive;
}
// Sync tasks data
if (body.tasksData !== undefined) {
updateData.tasksData = body.tasksData ? JSON.stringify(body.tasksData) : null;
}
// Sync unlocks data
if (body.unlocksData !== undefined) {
updateData.unlocksData = body.unlocksData ? JSON.stringify(body.unlocksData) : null;
}
// Sync notes data
if (body.notesData !== undefined) {
updateData.notesData = body.notesData ? JSON.stringify(body.notesData) : null;
}
const updated = await prisma.character.update({
where: { id },
data: updateData,
});
return c.json({
character: {
id: updated.id,
rsn: updated.rsn,
isActive: updated.isActive,
tasksData: updated.tasksData ? JSON.parse(updated.tasksData) : null,
unlocksData: updated.unlocksData ? JSON.parse(updated.unlocksData) : null,
notesData: updated.notesData ? JSON.parse(updated.notesData) : null,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
},
});
});
/**
* DELETE /api/characters/:id
* Delete a character
*/
characters.delete('/:id', async (c) => {
const user = c.get('user')!;
const id = parseInt(c.req.param('id'), 10);
const character = await prisma.character.findFirst({
where: { id, userId: user.id },
});
if (!character) {
return c.json({ error: 'Character not found' }, 404);
}
await prisma.character.delete({ where: { id } });
// If deleted character was active, activate the first remaining character
if (character.isActive) {
const firstChar = await prisma.character.findFirst({
where: { userId: user.id },
orderBy: { createdAt: 'asc' },
});
if (firstChar) {
await prisma.character.update({
where: { id: firstChar.id },
data: { isActive: true },
});
}
}
return c.json({ success: true });
});
/**
* POST /api/characters/sync
* Bulk sync all characters (used on login to merge local data)
*/
characters.post('/sync', async (c) => {
const user = c.get('user')!;
const body = await c.req.json();
const { characters: localCharacters, activeIndex } = body;
if (!Array.isArray(localCharacters)) {
return c.json({ error: 'characters must be an array' }, 400);
}
// Get existing server characters
const serverCharacters = await prisma.character.findMany({
where: { userId: user.id },
});
const serverRsnMap = new Map(serverCharacters.map((c) => [c.rsn.toLowerCase(), c]));
const result: Array<{
id: number;
rsn: string;
isActive: boolean;
tasksData: unknown;
unlocksData: unknown;
notesData: unknown;
createdAt: Date;
updatedAt: Date;
}> = [];
// Process each local character
for (let i = 0; i < localCharacters.length; i++) {
const local = localCharacters[i];
const rsn = typeof local === 'string' ? local : local.rsn;
const tasksData = typeof local === 'object' ? local.tasksData : null;
const unlocksData = typeof local === 'object' ? local.unlocksData : null;
const notesData = typeof local === 'object' ? local.notesData : null;
const isActive = i === activeIndex;
const existing = serverRsnMap.get(rsn.toLowerCase());
if (existing) {
// Update existing character if local data is newer/present
const updateData: Record<string, unknown> = { isActive };
// Merge data - prefer local if it exists and server doesn't have it
if (tasksData && !existing.tasksData) {
updateData.tasksData = JSON.stringify(tasksData);
}
if (unlocksData && !existing.unlocksData) {
updateData.unlocksData = JSON.stringify(unlocksData);
}
if (notesData && !existing.notesData) {
updateData.notesData = JSON.stringify(notesData);
}
const updated = await prisma.character.update({
where: { id: existing.id },
data: updateData,
});
result.push({
id: updated.id,
rsn: updated.rsn,
isActive: updated.isActive,
tasksData: updated.tasksData ? JSON.parse(updated.tasksData) : null,
unlocksData: updated.unlocksData ? JSON.parse(updated.unlocksData) : null,
notesData: updated.notesData ? JSON.parse(updated.notesData) : null,
createdAt: updated.createdAt,
updatedAt: updated.updatedAt,
});
serverRsnMap.delete(rsn.toLowerCase());
} else {
// Create new character
const created = await prisma.character.create({
data: {
userId: user.id,
rsn,
isActive,
tasksData: tasksData ? JSON.stringify(tasksData) : null,
unlocksData: unlocksData ? JSON.stringify(unlocksData) : null,
notesData: notesData ? JSON.stringify(notesData) : null,
},
});
result.push({
id: created.id,
rsn: created.rsn,
isActive: created.isActive,
tasksData: tasksData,
unlocksData: unlocksData,
notesData: notesData,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
});
}
}
// Add remaining server characters that weren't in local
for (const serverChar of serverRsnMap.values()) {
// Deactivate if there was an active local character
if (serverChar.isActive && activeIndex !== undefined) {
await prisma.character.update({
where: { id: serverChar.id },
data: { isActive: false },
});
serverChar.isActive = false;
}
result.push({
id: serverChar.id,
rsn: serverChar.rsn,
isActive: serverChar.isActive,
tasksData: serverChar.tasksData ? JSON.parse(serverChar.tasksData) : null,
unlocksData: serverChar.unlocksData ? JSON.parse(serverChar.unlocksData) : null,
notesData: serverChar.notesData ? JSON.parse(serverChar.notesData) : null,
createdAt: serverChar.createdAt,
updatedAt: serverChar.updatedAt,
});
}
return c.json({ characters: result });
});
export default characters;

124
server/src/routes/groups.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { groupAuthMiddleware } from '../middleware/groupAuth';
const groups = new Hono();
// Apply group token auth to all routes
groups.use('/*', groupAuthMiddleware);
/**
* GET /api/group/:group_name/get-group-data
* Get all members with optional delta updates
*/
groups.get('/:group_name/get-group-data', async (c) => {
const groupId = c.get('groupId')!;
const fromTimeParam = c.req.query('from_time');
let fromTimestamp: Date | undefined;
if (fromTimeParam) {
// Try parsing as epoch milliseconds first, then ISO string
const epochMs = parseInt(fromTimeParam, 10);
if (!isNaN(epochMs)) {
fromTimestamp = new Date(epochMs);
} else {
fromTimestamp = new Date(fromTimeParam);
}
}
const members = await prisma.member.findMany({
where: {
groupId,
...(fromTimestamp && {
lastUpdated: { gt: fromTimestamp },
}),
},
});
// Transform to API response format
const response = members.map((member) => ({
name: member.name,
stats: member.stats ? JSON.parse(member.stats) : null,
statsLastUpdate: member.statsLastUpdate?.getTime() || null,
coordinates: member.coordinates ? JSON.parse(member.coordinates) : null,
coordinatesLastUpdate: member.coordinatesLastUpdate?.getTime() || null,
skills: member.skills ? JSON.parse(member.skills) : null,
skillsLastUpdate: member.skillsLastUpdate?.getTime() || null,
quests: member.quests ? Array.from(member.quests) : null,
questsLastUpdate: member.questsLastUpdate?.getTime() || null,
inventory: member.inventory ? JSON.parse(member.inventory) : null,
inventoryLastUpdate: member.inventoryLastUpdate?.getTime() || null,
equipment: member.equipment ? JSON.parse(member.equipment) : null,
equipmentLastUpdate: member.equipmentLastUpdate?.getTime() || null,
runePouch: member.runePouch ? JSON.parse(member.runePouch) : null,
runePouchLastUpdate: member.runePouchLastUpdate?.getTime() || null,
bank: member.bank ? JSON.parse(member.bank) : null,
bankLastUpdate: member.bankLastUpdate?.getTime() || null,
seedVault: member.seedVault ? JSON.parse(member.seedVault) : null,
seedVaultLastUpdate: member.seedVaultLastUpdate?.getTime() || null,
interacting: member.interacting,
interactingLastUpdate: member.interactingLastUpdate?.getTime() || null,
diaryVars: member.diaryVars ? JSON.parse(member.diaryVars) : null,
diaryVarsLastUpdate: member.diaryVarsLastUpdate?.getTime() || null,
lastUpdated: member.lastUpdated?.getTime() || null,
}));
return c.json(response);
});
/**
* GET /api/group/:group_name/am-i-logged-in
* Check if authenticated (if this is reached, auth succeeded)
*/
groups.get('/:group_name/am-i-logged-in', (c) => {
return c.json({ authenticated: true });
});
/**
* GET /api/group/:group_name/am-i-in-group
* Check if a member exists in the group
*/
groups.get('/:group_name/am-i-in-group', async (c) => {
const groupId = c.get('groupId')!;
const memberName = c.req.query('member_name');
if (!memberName) {
return c.json({ error: 'member_name is required' }, 400);
}
const member = await prisma.member.findFirst({
where: {
groupId,
name: memberName,
},
});
return c.json({ in_group: !!member });
});
/**
* GET /api/group/:group_name/get-skill-data
* Get skill aggregation data
* TODO: Implement skill aggregation
*/
groups.get('/:group_name/get-skill-data', async (c) => {
const groupId = c.get('groupId')!;
const period = c.req.query('period'); // day, month, year
// TODO: Implement skill aggregation service
return c.json([]);
});
/**
* GET /api/group/:group_name/collection-log
* Get collection log data
* TODO: Implement collection log
*/
groups.get('/:group_name/collection-log', async (c) => {
const groupId = c.get('groupId')!;
// TODO: Implement collection log service
return c.json([]);
});
export default groups;

View File

@@ -0,0 +1,205 @@
import { Hono } from 'hono';
import { prisma } from '../db';
const hiscores = new Hono();
// Cache TTL in milliseconds (5 minutes)
const CACHE_TTL_MS = 5 * 60 * 1000;
// Skill names in order returned by the hiscores API
const SKILL_NAMES = [
'overall',
'attack',
'defence',
'strength',
'hitpoints',
'ranged',
'prayer',
'magic',
'cooking',
'woodcutting',
'fletching',
'fishing',
'firemaking',
'crafting',
'smithing',
'mining',
'herblore',
'agility',
'thieving',
'slayer',
'farming',
'runecraft',
'hunter',
'construction',
];
// Activity/minigame names (after skills in the CSV)
const ACTIVITY_NAMES = [
'league_points',
'deadman_points',
'bounty_hunter_hunter',
'bounty_hunter_rogue',
'bounty_hunter_hunter_legacy',
'bounty_hunter_rogue_legacy',
'clue_scrolls_all',
'clue_scrolls_beginner',
'clue_scrolls_easy',
'clue_scrolls_medium',
'clue_scrolls_hard',
'clue_scrolls_elite',
'clue_scrolls_master',
'lms_rank',
'pvp_arena_rank',
'soul_wars_zeal',
'rifts_closed',
'colosseum_glory',
];
/**
* GET /api/hiscores/:rsn
* Fetch hiscores from cache or Jagex seasonal (Leagues) hiscores
*/
hiscores.get('/:rsn', async (c) => {
const rsn = c.req.param('rsn');
if (!rsn) {
return c.json({ error: 'RSN is required' }, 400);
}
const rsnLower = rsn.toLowerCase();
// Check cache first
const cached = await prisma.hiscoresCache.findUnique({
where: { rsn: rsnLower },
});
const now = new Date();
if (cached && (now.getTime() - cached.fetchedAt.getTime()) < CACHE_TTL_MS) {
// Return cached data
return c.json({
rsn: cached.displayRsn,
skills: JSON.parse(cached.skills),
clues: JSON.parse(cached.clues),
activities: JSON.parse(cached.activities),
leaguePoints: cached.leaguePoints,
cached: true,
cachedAt: cached.fetchedAt,
});
}
// Fetch from Jagex API
const url = `https://secure.runescape.com/m=hiscore_oldschool_seasonal/index_lite.ws?player=${encodeURIComponent(rsn)}`;
try {
const response = await fetch(url, {
headers: {
'User-Agent': 'LeaguesTools/1.0',
},
});
if (response.status === 404) {
return c.json({ status: 404, error: 'Player not found' }, 404);
}
if (!response.ok) {
// If fetch fails but we have stale cache, return it
if (cached) {
return c.json({
rsn: cached.displayRsn,
skills: JSON.parse(cached.skills),
clues: JSON.parse(cached.clues),
activities: JSON.parse(cached.activities),
leaguePoints: cached.leaguePoints,
cached: true,
stale: true,
cachedAt: cached.fetchedAt,
});
}
console.error(`Hiscores API error: ${response.status}`);
return c.json({ error: 'Failed to fetch hiscores' }, 502);
}
const text = await response.text();
const lines = text.trim().split('\n');
// Parse skills (first 24 lines)
const skills: Record<string, { rank: number; level: number; xp: number }> = {};
for (let i = 0; i < SKILL_NAMES.length && i < lines.length; i++) {
const [rank, level, xp] = lines[i].split(',').map(Number);
skills[SKILL_NAMES[i]] = { rank, level, xp };
}
// Parse activities/minigames (remaining lines)
const activities: Record<string, { rank: number; score: number }> = {};
for (let i = SKILL_NAMES.length; i < lines.length; i++) {
const activityIndex = i - SKILL_NAMES.length;
if (activityIndex < ACTIVITY_NAMES.length) {
const [rank, score] = lines[i].split(',').map(Number);
activities[ACTIVITY_NAMES[activityIndex]] = { rank, score };
}
}
// Build clues object for frontend compatibility
const clues = {
all: { rank: activities.clue_scrolls_all?.rank ?? -1, score: activities.clue_scrolls_all?.score ?? -1 },
beginner: { rank: activities.clue_scrolls_beginner?.rank ?? -1, score: activities.clue_scrolls_beginner?.score ?? -1 },
easy: { rank: activities.clue_scrolls_easy?.rank ?? -1, score: activities.clue_scrolls_easy?.score ?? -1 },
medium: { rank: activities.clue_scrolls_medium?.rank ?? -1, score: activities.clue_scrolls_medium?.score ?? -1 },
hard: { rank: activities.clue_scrolls_hard?.rank ?? -1, score: activities.clue_scrolls_hard?.score ?? -1 },
elite: { rank: activities.clue_scrolls_elite?.rank ?? -1, score: activities.clue_scrolls_elite?.score ?? -1 },
master: { rank: activities.clue_scrolls_master?.rank ?? -1, score: activities.clue_scrolls_master?.score ?? -1 },
};
const leaguePoints = activities.league_points?.score || 0;
// Cache the result
await prisma.hiscoresCache.upsert({
where: { rsn: rsnLower },
update: {
displayRsn: rsn,
skills: JSON.stringify(skills),
clues: JSON.stringify(clues),
activities: JSON.stringify(activities),
leaguePoints,
fetchedAt: now,
},
create: {
rsn: rsnLower,
displayRsn: rsn,
skills: JSON.stringify(skills),
clues: JSON.stringify(clues),
activities: JSON.stringify(activities),
leaguePoints,
fetchedAt: now,
},
});
return c.json({
rsn,
skills,
clues,
activities,
leaguePoints,
cached: false,
});
} catch (error) {
// If fetch fails but we have stale cache, return it
if (cached) {
return c.json({
rsn: cached.displayRsn,
skills: JSON.parse(cached.skills),
clues: JSON.parse(cached.clues),
activities: JSON.parse(cached.activities),
leaguePoints: cached.leaguePoints,
cached: true,
stale: true,
cachedAt: cached.fetchedAt,
});
}
console.error('Hiscores fetch error:', error);
return c.json({ error: 'Failed to fetch hiscores' }, 500);
}
});
export default hiscores;

View File

@@ -0,0 +1,211 @@
import { Hono } from 'hono';
import { prisma } from '../db';
import { groupAuthMiddleware } from '../middleware/groupAuth';
const members = new Hono();
// Apply group token auth to all routes
members.use('/*', groupAuthMiddleware);
/**
* POST /api/group/:group_name/update-group-member
* Update member data (main RuneLite plugin endpoint)
* Only non-null fields are updated
*/
members.post('/:group_name/update-group-member', async (c) => {
const groupId = c.get('groupId')!;
const body = await c.req.json();
const { name, ...data } = body;
if (!name) {
return c.json({ error: 'Member name is required' }, 400);
}
// Find or create member
let member = await prisma.member.findFirst({
where: { groupId, name },
});
if (!member) {
// Auto-create member if doesn't exist
member = await prisma.member.create({
data: { groupId, name },
});
}
// Build update object with only provided fields
const now = new Date();
const updateData: Record<string, unknown> = {
lastUpdated: now,
};
if (data.stats !== undefined) {
updateData.stats = JSON.stringify(data.stats);
updateData.statsLastUpdate = now;
}
if (data.coordinates !== undefined) {
updateData.coordinates = JSON.stringify(data.coordinates);
updateData.coordinatesLastUpdate = now;
}
if (data.skills !== undefined) {
updateData.skills = JSON.stringify(data.skills);
updateData.skillsLastUpdate = now;
}
if (data.quests !== undefined) {
updateData.quests = Buffer.from(data.quests);
updateData.questsLastUpdate = now;
}
if (data.inventory !== undefined) {
updateData.inventory = JSON.stringify(data.inventory);
updateData.inventoryLastUpdate = now;
}
if (data.equipment !== undefined) {
updateData.equipment = JSON.stringify(data.equipment);
updateData.equipmentLastUpdate = now;
}
if (data.runePouch !== undefined || data.rune_pouch !== undefined) {
updateData.runePouch = JSON.stringify(data.runePouch || data.rune_pouch);
updateData.runePouchLastUpdate = now;
}
if (data.bank !== undefined) {
updateData.bank = JSON.stringify(data.bank);
updateData.bankLastUpdate = now;
}
if (data.seedVault !== undefined || data.seed_vault !== undefined) {
updateData.seedVault = JSON.stringify(data.seedVault || data.seed_vault);
updateData.seedVaultLastUpdate = now;
}
if (data.interacting !== undefined) {
updateData.interacting = data.interacting;
updateData.interactingLastUpdate = now;
}
if (data.diaryVars !== undefined || data.diary_vars !== undefined) {
updateData.diaryVars = JSON.stringify(data.diaryVars || data.diary_vars);
updateData.diaryVarsLastUpdate = now;
}
// Update member
await prisma.member.update({
where: { id: member.id },
data: updateData,
});
return c.json({ status: 'success' });
});
/**
* POST /api/group/:group_name/add-group-member
* Add a new member to the group
*/
members.post('/:group_name/add-group-member', async (c) => {
const groupId = c.get('groupId')!;
const body = await c.req.json();
const { name } = body;
if (!name) {
return c.json({ error: 'Member name is required' }, 400);
}
// Check if member already exists
const existing = await prisma.member.findFirst({
where: { groupId, name },
});
if (existing) {
return c.json({ error: 'Member already exists in group' }, 400);
}
// Create member
await prisma.member.create({
data: { groupId, name },
});
console.log(`Added member '${name}' to group_id: ${groupId}`);
return c.json({ status: 'success' });
});
/**
* DELETE /api/group/:group_name/delete-group-member
* Delete a member from the group
*/
members.delete('/:group_name/delete-group-member', async (c) => {
const groupId = c.get('groupId')!;
const body = await c.req.json();
const { name } = body;
if (!name) {
return c.json({ error: 'Member name is required' }, 400);
}
const member = await prisma.member.findFirst({
where: { groupId, name },
});
if (!member) {
return c.json({ error: 'Member not found' }, 404);
}
await prisma.member.delete({
where: { id: member.id },
});
console.log(`Deleted member '${name}' from group_id: ${groupId}`);
return c.json({ status: 'success' });
});
/**
* PUT /api/group/:group_name/rename-group-member
* Rename a group member
*/
members.put('/:group_name/rename-group-member', async (c) => {
const groupId = c.get('groupId')!;
const body = await c.req.json();
const { originalName, newName, original_name, new_name } = body;
const oldName = originalName || original_name;
const targetName = newName || new_name;
if (!oldName || !targetName) {
return c.json({ error: 'originalName and newName are required' }, 400);
}
const member = await prisma.member.findFirst({
where: { groupId, name: oldName },
});
if (!member) {
return c.json({ error: 'Member not found' }, 404);
}
// Check if new name already exists
const existing = await prisma.member.findFirst({
where: { groupId, name: targetName },
});
if (existing) {
return c.json({ error: 'A member with that name already exists' }, 400);
}
await prisma.member.update({
where: { id: member.id },
data: { name: targetName },
});
console.log(`Renamed member '${oldName}' -> '${targetName}' in group_id: ${groupId}`);
return c.json({ status: 'success' });
});
export default members;

121
server/src/routes/public.ts Normal file
View File

@@ -0,0 +1,121 @@
import { Hono } from 'hono';
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../db';
import { hashToken } from '../utils/blake2';
const publicRoutes = new Hono();
// In-memory cache for GE prices
let gePricesCache: Record<string, number> = {};
let gePricesCacheTime = 0;
const GE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const CAPTCHA_ENABLED = process.env.CAPTCHA_ENABLED === 'true';
const CAPTCHA_SITEKEY = process.env.CAPTCHA_SITEKEY || '';
/**
* POST /api/create-group
* Create a new group with a token
*/
publicRoutes.post('/create-group', async (c) => {
const body = await c.req.json();
const { name, captchaResponse } = body;
if (!name || typeof name !== 'string') {
return c.json({ error: 'Group name is required' }, 400);
}
const trimmedName = name.trim();
if (trimmedName.length < 1 || trimmedName.length > 100) {
return c.json({ error: 'Group name must be 1-100 characters' }, 400);
}
// TODO: Implement captcha validation if enabled
// if (CAPTCHA_ENABLED && !verifyCaptcha(captchaResponse)) {
// return c.json({ error: 'Invalid captcha' }, 400);
// }
// Generate token
const token = uuidv4();
const tokenHash = hashToken(token, trimmedName);
// Check if group name + token combination already exists
const existingGroup = await prisma.group.findFirst({
where: {
name: trimmedName,
tokenHash: tokenHash,
},
});
if (existingGroup) {
return c.json({ error: 'Group already exists' }, 400);
}
// Create group
const group = await prisma.group.create({
data: {
name: trimmedName,
tokenHash: tokenHash,
},
});
console.log(`Group created: ${group.name} with token (first 8 chars): ${token.substring(0, 8)}...`);
return c.json({
name: group.name,
token: token, // Return unhashed token to user
});
});
/**
* GET /api/ge-prices
* Get cached Grand Exchange prices
*/
publicRoutes.get('/ge-prices', async (c) => {
const now = Date.now();
// Refresh cache if expired
if (now - gePricesCacheTime > GE_CACHE_TTL) {
try {
const response = await fetch('https://prices.runescape.wiki/api/v1/osrs/latest');
if (response.ok) {
const data = await response.json() as { data: Record<string, { high?: number; low?: number }> };
// Transform to simple id -> price mapping
gePricesCache = {};
for (const [id, item] of Object.entries(data.data)) {
// Use high price if available, otherwise low
const price = item.high ?? item.low ?? 0;
gePricesCache[id] = price;
}
gePricesCacheTime = now;
}
} catch (error) {
console.error('Failed to fetch GE prices:', error);
}
}
return c.json({ prices: gePricesCache });
});
/**
* GET /api/captcha-enabled
* Get captcha configuration
*/
publicRoutes.get('/captcha-enabled', (c) => {
return c.json({
enabled: CAPTCHA_ENABLED,
sitekey: CAPTCHA_SITEKEY,
});
});
/**
* GET /api/collection-log-info
* Get collection log metadata
* TODO: Load from JSON file
*/
publicRoutes.get('/collection-log-info', (c) => {
// TODO: Load from collection_log_info.json
return c.json({});
});
export default publicRoutes;

View File

@@ -0,0 +1,35 @@
import blake2b from 'blakejs';
const BACKEND_SECRET = process.env.BACKEND_SECRET || 'changeme_secret_key_for_production';
/**
* Hash a token with Blake2b-256 (2 iterations).
* This must match the Spring/Rust implementation exactly:
* - Uses Blake2b-256 (32 bytes output)
* - 2 iterations of hashing
* - Combines token + secret + salt (group name)
* - Returns hex-encoded hash (64 characters)
*/
export function hashToken(token: string, salt: string): string {
// First iteration: hash(token + secret + salt)
const input1 = token + BACKEND_SECRET + salt;
const hash1 = blake2b.blake2b(input1, undefined, 32);
// Second iteration: hash(hash1)
const hash2 = blake2b.blake2b(hash1, undefined, 32);
// Return hex-encoded (lowercase)
return Buffer.from(hash2).toString('hex').toLowerCase();
}
/**
* Verify if a token matches the stored hash.
*/
export function verifyToken(token: string, salt: string, storedHash: string): boolean {
try {
const computedHash = hashToken(token, salt);
return computedHash === storedHash.toLowerCase();
} catch {
return false;
}
}

123
server/src/utils/email.ts Normal file
View File

@@ -0,0 +1,123 @@
import nodemailer from 'nodemailer';
// SMTP configuration from environment variables
const smtpConfig = {
host: process.env.SMTP_HOST || 'localhost',
port: parseInt(process.env.SMTP_PORT || '587', 10),
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER || '',
pass: process.env.SMTP_PASS || '',
},
};
// Default sender
const defaultFrom = process.env.SMTP_FROM || 'noreply@leagues.tools';
// Create reusable transporter
let transporter: nodemailer.Transporter | null = null;
function getTransporter(): nodemailer.Transporter {
if (!transporter) {
transporter = nodemailer.createTransport(smtpConfig);
}
return transporter;
}
// Check if email is configured
export function isEmailConfigured(): boolean {
return !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS);
}
// Send email
export async function sendEmail(options: {
to: string;
subject: string;
text?: string;
html?: string;
}): Promise<{ success: boolean; messageId?: string; error?: string }> {
if (!isEmailConfigured()) {
console.warn('Email not configured. Set SMTP_HOST, SMTP_USER, and SMTP_PASS environment variables.');
return { success: false, error: 'Email service not configured' };
}
try {
const info = await getTransporter().sendMail({
from: defaultFrom,
to: options.to,
subject: options.subject,
text: options.text,
html: options.html,
});
console.log('Email sent:', info.messageId);
return { success: true, messageId: info.messageId };
} catch (error) {
console.error('Failed to send email:', error);
return { success: false, error: error instanceof Error ? error.message : 'Failed to send email' };
}
}
// Send password reset email
export async function sendPasswordResetEmail(
email: string,
resetToken: string,
baseUrl: string
): Promise<{ success: boolean; error?: string }> {
const resetUrl = `${baseUrl}/reset-password?token=${resetToken}`;
const result = await sendEmail({
to: email,
subject: 'Password Reset Request - Leagues Tools',
text: `
You requested a password reset for your Leagues Tools account.
Click the link below to reset your password:
${resetUrl}
This link will expire in 1 hour.
If you didn't request this, you can safely ignore this email.
`.trim(),
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #1a1a2e; color: #e94560; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
.content { background: #f5f5f5; padding: 30px; border-radius: 0 0 8px 8px; }
.button { display: inline-block; background: #e94560; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0; }
.footer { text-align: center; margin-top: 20px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Leagues Tools</h1>
</div>
<div class="content">
<h2>Password Reset Request</h2>
<p>You requested a password reset for your Leagues Tools account.</p>
<p>Click the button below to reset your password:</p>
<p style="text-align: center;">
<a href="${resetUrl}" class="button">Reset Password</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; font-size: 12px; color: #666;">${resetUrl}</p>
<p><strong>This link will expire in 1 hour.</strong></p>
<p>If you didn't request this, you can safely ignore this email.</p>
</div>
<div class="footer">
<p>Leagues Tools - OSRS League Tracker</p>
</div>
</div>
</body>
</html>
`.trim(),
});
return result;
}

View File

@@ -0,0 +1,11 @@
import bcrypt from 'bcrypt';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}

18
server/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}