Compare commits

12 Commits
main ... dev

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
82 changed files with 9142 additions and 247 deletions

View File

@@ -4,7 +4,6 @@ on:
push:
branches:
- main
- dev
env:
FRONTEND_HOST: psg-leagues-tools-frontend
@@ -60,10 +59,24 @@ jobs:
run: |
echo "Deploying ${{ env.ENVIRONMENT }} to ${{ env.DEPLOY_PATH }}"
ssh sonder@${{ env.FRONTEND_HOST }} "sudo systemctl stop ${{ env.SERVICE_NAME }} || true"
ssh sonder@${{ env.FRONTEND_HOST }} "mkdir -p ${{ env.DEPLOY_PATH }}"
ssh sonder@${{ env.FRONTEND_HOST }} "rm -rf ${{ env.DEPLOY_PATH }}/build"
scp os-league-tools-master/build.tar.gz sonder@${{ env.FRONTEND_HOST }}:/tmp/
ssh sonder@${{ env.FRONTEND_HOST }} "tar -xzf /tmp/build.tar.gz -C ${{ env.DEPLOY_PATH }}/"
ssh sonder@${{ env.FRONTEND_HOST }} "rm /tmp/build.tar.gz"
if [ "${{ env.ENVIRONMENT }}" = "production" ]; then
# Production: deploy only built files
ssh sonder@${{ env.FRONTEND_HOST }} "mkdir -p ${{ env.DEPLOY_PATH }}"
ssh sonder@${{ env.FRONTEND_HOST }} "rm -rf ${{ env.DEPLOY_PATH }}/build"
scp os-league-tools-master/build.tar.gz sonder@${{ env.FRONTEND_HOST }}:/tmp/
ssh sonder@${{ env.FRONTEND_HOST }} "tar -xzf /tmp/build.tar.gz -C ${{ env.DEPLOY_PATH }}/"
ssh sonder@${{ env.FRONTEND_HOST }} "rm /tmp/build.tar.gz"
else
# Dev: deploy full source for hot reload
ssh sonder@${{ env.FRONTEND_HOST }} "mkdir -p /home/sonder/leagues-tools-dev"
tar -czf source.tar.gz os-league-tools-master/
scp source.tar.gz sonder@${{ env.FRONTEND_HOST }}:/tmp/
ssh sonder@${{ env.FRONTEND_HOST }} "rm -rf /home/sonder/leagues-tools-dev/os-league-tools-master"
ssh sonder@${{ env.FRONTEND_HOST }} "tar -xzf /tmp/source.tar.gz -C /home/sonder/leagues-tools-dev/"
ssh sonder@${{ env.FRONTEND_HOST }} "cd /home/sonder/leagues-tools-dev/os-league-tools-master && npm ci"
ssh sonder@${{ env.FRONTEND_HOST }} "rm /tmp/source.tar.gz"
fi
ssh sonder@${{ env.FRONTEND_HOST }} "sudo systemctl start ${{ env.SERVICE_NAME }}"
echo "Deployed ${{ env.ENVIRONMENT }} successfully!"

View File

@@ -1,24 +0,0 @@
name: Build and Deploy to GH Pages
on: [workflow_dispatch]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install and build
run: |
npm install
npm run build
env:
REACT_APP_GA_MID: ${{ secrets.REACT_APP_GA_MID }}
- name: Deploy to GitHub Pages
if: success()
uses: crazy-max/ghaction-github-pages@v2
with:
target_branch: gh-pages
build_dir: build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

6
.gitignore vendored
View File

@@ -60,3 +60,9 @@ blake2tokenhasher-test/*.*
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

@@ -10,11 +10,18 @@ Group=sonder
WorkingDirectory=/home/sonder/leagues-tools-dev/os-league-tools-master
# Environment variables
Environment="NODE_ENV=production"
Environment="NODE_ENV=development"
Environment="PORT=3001"
Environment="HOST=0.0.0.0"
# WebSocket config for hot reload through reverse proxy
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 application (serves the pre-built static files)
ExecStart=/usr/bin/serve -s build -l tcp://0.0.0.0:3001
# Start the dev server with hot reload
ExecStart=/usr/bin/npm run dev
# Restart policy
Restart=on-failure

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=

Binary file not shown.

View File

@@ -83,6 +83,7 @@ function getClientEnvironment(publicUrl) {
WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST,
WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH,
WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT,
WDS_SOCKET_PROTOCOL: process.env.WDS_SOCKET_PROTOCOL,
// Whether or not react-refresh is enabled.
// It is defined here so it is available in the webpackHotDevClient.
FAST_REFRESH: process.env.FAST_REFRESH !== 'false',

View File

@@ -10,6 +10,7 @@ const host = process.env.HOST || '0.0.0.0';
const sockHost = process.env.WDS_SOCKET_HOST;
const sockPath = process.env.WDS_SOCKET_PATH; // default: '/ws'
const sockPort = process.env.WDS_SOCKET_PORT;
const sockProtocol = process.env.WDS_SOCKET_PROTOCOL;
module.exports = function (proxy, allowedHost) {
const disableFirewall = !proxy || process.env.DANGEROUSLY_DISABLE_HOST_CHECK === 'true';
@@ -71,6 +72,7 @@ module.exports = function (proxy, allowedHost) {
// Enable custom sockjs pathname for websocket connection to hot reloading server.
// Enable custom sockjs hostname, pathname and port for websocket connection
// to hot reloading server.
protocol: 'wss',
hostname: sockHost,
pathname: sockPath,
port: sockPort,

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

@@ -18,6 +18,9 @@ import Calculators from './pages/Calculators';
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';
@@ -41,6 +44,12 @@ 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() {
useEffect(() => {
if (trackingId) {
@@ -52,6 +61,30 @@ export default function App() {
}
}, []);
// Prefix title with [DEV] on dev environment
useEffect(() => {
if (!isDevEnvironment()) {
return undefined;
}
const prefixTitle = () => {
if (!document.title.startsWith('[DEV] ')) {
document.title = `[DEV] ${document.title}`;
}
};
prefixTitle();
// Watch for title changes and re-prefix
const observer = new MutationObserver(prefixTitle);
const titleElement = document.querySelector('title');
if (titleElement) {
observer.observe(titleElement, { childList: true, characterData: true, subtree: true });
}
return () => observer.disconnect();
}, []);
return (
<Provider store={store}>
<SidebarProvider>
@@ -61,8 +94,10 @@ export default function App() {
<Auth0Provider
domain='login.osleague.tools'
clientId='yfqwKEhQO8FL7MlxWmWo7ekuGgzSrfmh'
redirectUri={window.location.origin}
audience='https://dev-u4mby-kt.us.auth0.com/api/v2/'
authorizationParams={{
redirect_uri: window.location.origin,
audience: 'https://dev-u4mby-kt.us.auth0.com/api/v2/',
}}
>
<ErrorBoundary FallbackComponent={ErrorPage} onError={submitRenderError}>
<Routes>
@@ -75,9 +110,12 @@ export default function App() {
<Route path=':skill' element={<Calculators />} />
</Route>
<Route path='groups' element={<Groups />} />
<Route path='planner' element={<Planner />} />
<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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 599 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 579 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

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,37 +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('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
@@ -61,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,4 +1,16 @@
[
{
"title": "Demonic Pacts Leagues teaser!!",
"date": "Jan 8 2026",
"thumbnail": "https://i.imgur.com/hkZmhsr.png",
"leadText": "Let's get excited!",
"htmlContent": "<p>Over the coming weeks we will be updating the website with information as we learn about it."
},
{
"type": "separator",
"title": "Legacy Updates",
"subtitle": "From the original OS League Tools project"
},
{
"title": "Raging Echoes Leagues launch!",
"date": "Nov 11 2024",
@@ -51,7 +63,7 @@
{
"title": "Improved mobile support",
"date": "Nov 08 2020",
"thumbnail": "http://cdn.runescape.com/assets/img/external/oldschool/2017/newsposts/2017-06-27/osrsmobilemock.png",
"thumbnail": "https://cdn.runescape.com/assets/img/external/oldschool/2017/newsposts/2017-06-27/osrsmobilemock.png",
"leadText": "OS League Tools is now fully compatible with mobile devices (and screens of all other sizes).",
"htmlContent": "<p>OS League Tools is now fully compatible with mobile devices (and screens of all other sizes).</p> <p>I've been hard at work this weekend improving the site experience on mobile, so you can now track and plan your tasks on the go. While my focus was on mobile, you should see some improvements all across the site for just about any screen size, as many components were revamped completely to be more responsive to the size of your screen.</p> <p>If you come across any parts of the site that still look strange on mobile devices, please make a bug report in the <a href='https://discord.gg/GQ5kVyU'>discord</a>, preferably with a screenshot and information about what device and browser you're using.</p>"
},

View File

@@ -0,0 +1,210 @@
{
"items": [
{
"id": 1,
"name": "Abyssal Whip",
"slot": "Weapon",
"regions": ["Morytania"],
"obtainedFrom": "Abyssal Demons",
"requirements": [
{"skill": "Attack", "level": 70}
],
"relicUnlock": null
},
{
"id": 2,
"name": "Trident of the Seas",
"slot": "Weapon",
"regions": ["Morytania"],
"obtainedFrom": "Kraken",
"requirements": [
{"skill": "Magic", "level": 75}
],
"relicUnlock": null
},
{
"id": 3,
"name": "Blowpipe",
"slot": "Weapon",
"regions": ["Tirannwn"],
"obtainedFrom": "Zulrah",
"requirements": [
{"skill": "Ranged", "level": 75}
],
"relicUnlock": null
},
{
"id": 4,
"name": "Dragon Scimitar",
"slot": "Weapon",
"regions": ["Kandarin"],
"obtainedFrom": "Ape Atoll Shop (after Monkey Madness)",
"requirements": [
{"skill": "Attack", "level": 60}
],
"relicUnlock": null
},
{
"id": 5,
"name": "Fire Cape",
"slot": "Cape",
"regions": ["Karamja"],
"obtainedFrom": "Fight Caves (TzTok-Jad)",
"requirements": [],
"relicUnlock": null
},
{
"id": 6,
"name": "Infernal Cape",
"slot": "Cape",
"regions": ["Karamja"],
"obtainedFrom": "Inferno (TzKal-Zuk)",
"requirements": [],
"relicUnlock": null
},
{
"id": 7,
"name": "Barrows Gloves",
"slot": "Gloves",
"regions": ["Misthalin"],
"obtainedFrom": "Recipe for Disaster completion",
"requirements": [
{"skill": "Defence", "level": 41}
],
"relicUnlock": null
},
{
"id": 8,
"name": "Scythe of Vitur",
"slot": "Weapon",
"regions": ["Morytania"],
"obtainedFrom": "Theatre of Blood",
"requirements": [
{"skill": "Attack", "level": 75},
{"skill": "Strength", "level": 75}
],
"relicUnlock": null
},
{
"id": 9,
"name": "Twisted Bow",
"slot": "Weapon",
"regions": ["Kourend"],
"obtainedFrom": "Chambers of Xeric",
"requirements": [
{"skill": "Ranged", "level": 75}
],
"relicUnlock": null
},
{
"id": 10,
"name": "Blade of Saeldor",
"slot": "Weapon",
"regions": ["Tirannwn"],
"obtainedFrom": "The Gauntlet",
"requirements": [
{"skill": "Attack", "level": 75}
],
"relicUnlock": null
},
{
"id": 11,
"name": "Echo Pickaxe",
"slot": "Tool",
"regions": ["Global"],
"obtainedFrom": "Power Miner Relic",
"requirements": [],
"relicUnlock": "t1-0"
},
{
"id": 12,
"name": "Echo Axe",
"slot": "Tool",
"regions": ["Global"],
"obtainedFrom": "Lumberjack Relic",
"requirements": [],
"relicUnlock": "t1-1"
},
{
"id": 13,
"name": "Echo Harpoon",
"slot": "Tool",
"regions": ["Global"],
"obtainedFrom": "Animal Wrangler Relic",
"requirements": [],
"relicUnlock": "t1-2"
},
{
"id": 14,
"name": "Berserker Ring",
"slot": "Ring",
"regions": ["Fremennik"],
"obtainedFrom": "Dagannoth Rex",
"requirements": [],
"relicUnlock": null
},
{
"id": 15,
"name": "Archers Ring",
"slot": "Ring",
"regions": ["Fremennik"],
"obtainedFrom": "Dagannoth Supreme",
"requirements": [],
"relicUnlock": null
},
{
"id": 16,
"name": "Seers Ring",
"slot": "Ring",
"regions": ["Fremennik"],
"obtainedFrom": "Dagannoth Prime",
"requirements": [],
"relicUnlock": null
},
{
"id": 17,
"name": "Dragon Defender",
"slot": "Shield",
"regions": ["Asgarnia"],
"obtainedFrom": "Warriors Guild",
"requirements": [
{"skill": "Attack", "level": 60},
{"skill": "Defence", "level": 60}
],
"relicUnlock": null
},
{
"id": 18,
"name": "Amulet of Fury",
"slot": "Amulet",
"regions": ["Global"],
"obtainedFrom": "Crafting (onyx)",
"requirements": [
{"skill": "Crafting", "level": 90}
],
"relicUnlock": null
},
{
"id": 19,
"name": "Occult Necklace",
"slot": "Amulet",
"regions": ["Morytania"],
"obtainedFrom": "Smoke Devils",
"requirements": [
{"skill": "Magic", "level": 70}
],
"relicUnlock": null
},
{
"id": 20,
"name": "Necklace of Anguish",
"slot": "Amulet",
"regions": ["Tirannwn"],
"obtainedFrom": "Crafting (zenyte from demonic gorillas)",
"requirements": [
{"skill": "Crafting", "level": 92}
],
"relicUnlock": null
}
]
}

View File

@@ -0,0 +1,92 @@
{
"regions": [
{
"id": 0,
"name": "Misthalin",
"isDefault": true,
"description": "The heart of Gielinor, home to Lumbridge and Varrock",
"keyLocations": ["Lumbridge", "Varrock", "Edgeville", "Barbarian Village"],
"unlocksRegions": []
},
{
"id": 1,
"name": "Karamja",
"isDefault": true,
"description": "A tropical island with jungle dangers and volcanic activity",
"keyLocations": ["Brimhaven", "Tai Bwo Wannai", "Shilo Village"],
"unlocksRegions": []
},
{
"id": 2,
"name": "Asgarnia",
"isDefault": false,
"description": "Home to Falador, the White Knights, and the Dwarven Mines",
"keyLocations": ["Falador", "Port Sarim", "Mudskipper Point", "Ice Mountain"],
"unlocksRegions": []
},
{
"id": 3,
"name": "Desert",
"isDefault": false,
"description": "A harsh desert region with ancient pyramids and hidden cities",
"keyLocations": ["Al Kharid", "Pollnivneach", "Nardah", "Sophanem"],
"unlocksRegions": []
},
{
"id": 4,
"name": "Fremennik",
"isDefault": false,
"description": "Nordic lands of warriors, featuring Rellekka and the Lunar Isle",
"keyLocations": ["Rellekka", "Lunar Isle", "Neitiznot", "Jatizso"],
"unlocksRegions": []
},
{
"id": 5,
"name": "Morytania",
"isDefault": false,
"description": "A dark swampland ruled by vampyres and filled with undead",
"keyLocations": ["Canifis", "Port Phasmatys", "Barrows", "Theatre of Blood"],
"unlocksRegions": []
},
{
"id": 6,
"name": "Kandarin",
"isDefault": false,
"description": "A diverse region featuring Ardougne, Seers Village, and Prifddinas",
"keyLocations": ["Ardougne", "Seers' Village", "Catherby", "Yanille"],
"unlocksRegions": []
},
{
"id": 7,
"name": "Kourend",
"isDefault": false,
"description": "The Great Kourend, a kingdom of five houses with unique cultures",
"keyLocations": ["Hosidius", "Shayzien", "Lovakengj", "Arceuus", "Piscarilius"],
"unlocksRegions": []
},
{
"id": 8,
"name": "Tirannwn",
"isDefault": false,
"description": "The elven lands, featuring dense forests and the crystal city",
"keyLocations": ["Lletya", "Prifddinas", "Zul-Andra"],
"unlocksRegions": []
},
{
"id": 9,
"name": "Wilderness",
"isDefault": false,
"description": "A dangerous PvP zone with powerful bosses and high-risk rewards",
"keyLocations": ["Edgeville Dungeon", "Mage Arena", "Revenant Caves"],
"unlocksRegions": []
},
{
"id": 10,
"name": "Varlamore",
"isDefault": false,
"description": "A newly discovered continent with ancient secrets",
"keyLocations": ["Civitas illa Fortis", "Cam Torum", "Hunter Guild"],
"unlocksRegions": []
}
]
}

View File

@@ -0,0 +1,244 @@
{
"relicTiers": [
{
"tier": 1,
"pointsRequired": 0,
"name": "Gathering Tier"
},
{
"tier": 2,
"pointsRequired": 750,
"name": "Skilling Tier"
},
{
"tier": 3,
"pointsRequired": 1500,
"name": "Utility Tier"
},
{
"tier": 4,
"pointsRequired": 2500,
"name": "Bonus Tier"
},
{
"tier": 5,
"pointsRequired": 5000,
"name": "Mastery Tier"
},
{
"tier": 6,
"pointsRequired": 8000,
"name": "Convenience Tier"
},
{
"tier": 7,
"pointsRequired": 16000,
"name": "Advanced Tier"
},
{
"tier": 8,
"pointsRequired": 25000,
"name": "Combat Tier"
}
],
"relics": [
{
"id": "t1-0",
"tier": 1,
"name": "Power Miner",
"description": "Grants the Echo pickaxe with mining bonuses and auto-smelting",
"effects": [
"50% chance to succeed on failed mining attempts",
"Rock doesn't deplete until 4 ores mined",
"Optional auto-smelt and gem cutting"
],
"unlocksItems": ["Echo Pickaxe"],
"autoCompletes": []
},
{
"id": "t1-1",
"tier": 1,
"name": "Lumberjack",
"description": "Grants the Echo axe with woodcutting bonuses and auto-banking",
"effects": [
"50% chance to succeed on failed chops",
"Items sent directly to bank",
"Optional auto-burn and fletching"
],
"unlocksItems": ["Echo Axe"],
"autoCompletes": []
},
{
"id": "t1-2",
"tier": 1,
"name": "Animal Wrangler",
"description": "Grants the Echo harpoon with fishing and hunter bonuses",
"effects": [
"50% chance to succeed on failed fishing attempts",
"Fish automatically sent to bank",
"50% auto-cook chance",
"Hunter traps never fail"
],
"unlocksItems": ["Echo Harpoon"],
"autoCompletes": []
},
{
"id": "t2-0",
"tier": 2,
"name": "Corner Cutter",
"description": "Agility course bonuses and never fail obstacles",
"effects": [
"Grants Sage's Greaves for passive Agility XP",
"Never fail agility obstacles",
"Double course completion credit"
],
"unlocksItems": ["Sage's Greaves"],
"autoCompletes": []
},
{
"id": "t2-1",
"tier": 2,
"name": "Friendly Forager",
"description": "Herb gathering while skilling and potion bonuses",
"effects": [
"Grants Forager's Pouch that collects herbs",
"90% chance to save secondary ingredients",
"Potions have 4 doses instead of 3"
],
"unlocksItems": ["Forager's Pouch"],
"autoCompletes": []
},
{
"id": "t2-2",
"tier": 2,
"name": "Dodgy Deals",
"description": "Massive thieving upgrades and AoE pickpocketing",
"effects": [
"AoE pickpocketing in 11x11 area",
"100% success rate on thieving",
"Auto re-pickpocket until stopped",
"Noted loot from pickpocketing"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t3-0",
"tier": 3,
"name": "Clue Compass",
"description": "Teleport to STASH units and clue locations",
"effects": [
"Teleport to any STASH unit",
"Teleport to current clue step",
"Ignores wilderness teleport restrictions"
],
"unlocksItems": ["Clue Compass"],
"autoCompletes": []
},
{
"id": "t3-1",
"tier": 3,
"name": "Bank Heist",
"description": "Teleport to any bank or deposit box",
"effects": [
"Teleport to any deposit box, bank, or bank chest",
"Ignores wilderness teleport restrictions"
],
"unlocksItems": ["Banker's Briefcase"],
"autoCompletes": []
},
{
"id": "t3-2",
"tier": 3,
"name": "Fairy's Flight",
"description": "Teleport to fairy rings, spirit trees, and leprechauns",
"effects": [
"Teleport to any fairy ring or spirit tree",
"Teleport to any tool leprechaun",
"Auto-completes Tree Gnome Village quest"
],
"unlocksItems": ["Fairy Mushroom"],
"autoCompletes": []
},
{
"id": "t5-0",
"tier": 5,
"name": "Treasure Arbiter",
"description": "Enhanced clue scroll drops and rewards",
"effects": [
"1/15 clue drop rate from implings",
"10x more clue geodes/nests/bottles",
"Minimum steps for all clue tiers",
"Maximum reward rolls for all caskets"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t5-1",
"tier": 5,
"name": "Production Master",
"description": "Process entire inventories at once",
"effects": [
"All items processed in one action",
"Works for smithing, fletching, crafting, cooking, herblore"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t5-2",
"tier": 5,
"name": "Slayer Master",
"description": "Always on task for all eligible slayer monsters",
"effects": [
"Always on task for all slayer monsters",
"Free slayer perks and skips",
"Bonus XP for killing 100th of each monster"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t8-0",
"tier": 8,
"name": "Specialist",
"description": "All special attacks cost 20% with enhanced accuracy",
"effects": [
"All specs cost 20%",
"+100% spec accuracy",
"10% spec restore on missed hit",
"15% spec restore on kill"
],
"unlocksItems": [],
"autoCompletes": []
},
{
"id": "t8-1",
"tier": 8,
"name": "Guardian",
"description": "Summon a powerful Guardian Thrall to fight for you",
"effects": [
"Grants Guardian Horn to summon thrall",
"Thrall attacks with target's weakness",
"AoE attacks in multi-combat"
],
"unlocksItems": ["Guardian Horn"],
"autoCompletes": []
},
{
"id": "t8-2",
"tier": 8,
"name": "Last Stand",
"description": "Cheat death with a powerful combat buff",
"effects": [
"Survive fatal damage at 1 HP",
"Combat stats boosted to 255",
"16 tick invulnerability",
"Heal based on damage dealt"
],
"unlocksItems": [],
"autoCompletes": []
}
]
}

View File

@@ -0,0 +1,226 @@
{
"tasks": [
{
"id": 1,
"name": "Achieve Your First Level Up",
"description": "Level up any of your skills for the first time",
"points": 10,
"difficulty": "Easy",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 2,
"name": "Achieve Your First Level 5",
"description": "Reach level 5 in any skill",
"points": 10,
"difficulty": "Easy",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [1],
"prerequisites": []
},
{
"id": 3,
"name": "Achieve Your First Level 10",
"description": "Reach level 10 in any skill",
"points": 10,
"difficulty": "Easy",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [1, 2],
"prerequisites": []
},
{
"id": 4,
"name": "Achieve Your First Level 20",
"description": "Reach level 20 in any skill",
"points": 25,
"difficulty": "Easy",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [1, 2, 3],
"prerequisites": []
},
{
"id": 5,
"name": "Achieve Your First Level 30",
"description": "Reach level 30 in any skill",
"points": 25,
"difficulty": "Medium",
"category": "Milestone",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [1, 2, 3, 4],
"prerequisites": []
},
{
"id": 100,
"name": "Complete Dragon Slayer",
"description": "Complete the Dragon Slayer quest",
"points": 50,
"difficulty": "Medium",
"category": "Quest",
"regions": ["Misthalin", "Asgarnia"],
"skillRequirements": [
{"skill": "Quest Points", "level": 32}
],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 101,
"name": "Enter the Chambers of Xeric",
"description": "Enter the Chambers of Xeric raid",
"points": 100,
"difficulty": "Hard",
"category": "Combat",
"regions": ["Kourend"],
"skillRequirements": [],
"autoCompletes": [102],
"prerequisites": []
},
{
"id": 102,
"name": "Complete Any Raid",
"description": "Complete any raid in the game",
"points": 50,
"difficulty": "Medium",
"category": "Combat",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 103,
"name": "Complete Theatre of Blood",
"description": "Complete the Theatre of Blood raid",
"points": 250,
"difficulty": "Elite",
"category": "Combat",
"regions": ["Morytania"],
"skillRequirements": [],
"autoCompletes": [102],
"prerequisites": []
},
{
"id": 200,
"name": "Catch a Shrimp",
"description": "Catch raw shrimp while fishing",
"points": 10,
"difficulty": "Easy",
"category": "Skilling",
"regions": ["Global"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 201,
"name": "Catch a Lobster",
"description": "Catch a raw lobster while fishing",
"points": 25,
"difficulty": "Easy",
"category": "Skilling",
"regions": ["Global"],
"skillRequirements": [
{"skill": "Fishing", "level": 40}
],
"autoCompletes": [200],
"prerequisites": []
},
{
"id": 202,
"name": "Catch a Shark",
"description": "Catch a raw shark while fishing",
"points": 50,
"difficulty": "Medium",
"category": "Skilling",
"regions": ["Global"],
"skillRequirements": [
{"skill": "Fishing", "level": 76}
],
"autoCompletes": [200, 201],
"prerequisites": []
},
{
"id": 300,
"name": "Enter TzHaar City",
"description": "Enter the TzHaar City beneath Karamja",
"points": 25,
"difficulty": "Easy",
"category": "Exploration",
"regions": ["Karamja"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 301,
"name": "Complete the Fight Caves",
"description": "Defeat TzTok-Jad and obtain a Fire Cape",
"points": 250,
"difficulty": "Elite",
"category": "Combat",
"regions": ["Karamja"],
"skillRequirements": [],
"autoCompletes": [300],
"prerequisites": []
},
{
"id": 302,
"name": "Complete the Inferno",
"description": "Defeat TzKal-Zuk and obtain an Infernal Cape",
"points": 500,
"difficulty": "Master",
"category": "Combat",
"regions": ["Karamja"],
"skillRequirements": [],
"autoCompletes": [300, 301],
"prerequisites": [301]
},
{
"id": 400,
"name": "Visit Prifddinas",
"description": "Enter the elven city of Prifddinas",
"points": 100,
"difficulty": "Hard",
"category": "Exploration",
"regions": ["Tirannwn"],
"skillRequirements": [],
"autoCompletes": [],
"prerequisites": []
},
{
"id": 401,
"name": "Complete The Gauntlet",
"description": "Complete The Gauntlet in Prifddinas",
"points": 150,
"difficulty": "Hard",
"category": "Combat",
"regions": ["Tirannwn"],
"skillRequirements": [],
"autoCompletes": [400],
"prerequisites": [400]
},
{
"id": 402,
"name": "Complete The Corrupted Gauntlet",
"description": "Complete The Corrupted Gauntlet in Prifddinas",
"points": 250,
"difficulty": "Elite",
"category": "Combat",
"regions": ["Tirannwn"],
"skillRequirements": [],
"autoCompletes": [400, 401],
"prerequisites": [401]
}
]
}

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

@@ -79,16 +79,31 @@ export default function Homepage() {
<p className='text-3xl small-caps ml-1 mt-6'>Updates</p>
{newsPosts.map(newsPost => (
<NewsCard
key={newsPost.title}
title={newsPost.title}
date={newsPost.date}
coverImg={newsPost.thumbnail}
leadText={newsPost.leadText}
htmlContent={newsPost.htmlContent}
/>
))}
{newsPosts.map((newsPost, index) =>
newsPost.type === 'separator' ? (
<div key={`separator-${index}`} className='my-10 mx-auto max-w-5xl'>
<div className='flex items-center gap-4'>
<div className='flex-1 h-px bg-gray-600' />
<div className='text-center'>
<p className='text-xl font-semibold text-gray-400'>{newsPost.title}</p>
{newsPost.subtitle && (
<p className='text-sm text-gray-500 italic'>{newsPost.subtitle}</p>
)}
</div>
<div className='flex-1 h-px bg-gray-600' />
</div>
</div>
) : (
<NewsCard
key={newsPost.title}
title={newsPost.title}
date={newsPost.date}
coverImg={newsPost.thumbnail}
leadText={newsPost.leadText}
htmlContent={newsPost.htmlContent}
/>
)
)}
</PageWrapper>
);
}

View File

@@ -0,0 +1,412 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import PageWrapper from '../components/PageWrapper';
import Card from '../components/common/Card';
import images from '../assets/images';
import plannerRegions from '../data/planner/plannerRegions.json';
import plannerTasks from '../data/planner/plannerTasks.json';
import plannerItems from '../data/planner/plannerItems.json';
import plannerRelics from '../data/planner/plannerRelics.json';
// Constants
const MAX_REGIONS = 5;
// Map region names to their badge image keys
const getRegionBadge = regionName => {
const badgeKey = `${regionName}_Area_Badge.png`;
return images[badgeKey];
};
// Get default region IDs
const DEFAULT_REGION_IDS = new Set(plannerRegions.regions.filter(r => r.isDefault).map(r => r.id));
export default function Planner() {
// Region selection state - default regions are pre-selected
const [selectedRegions, setSelectedRegions] = useState(() => {
const defaults = plannerRegions.regions
.filter(r => r.isDefault)
.map(r => r.id);
return new Set(defaults);
});
// Task completion state
const [completedTasks, setCompletedTasks] = useState(new Set());
// Toast notification state
const [toast, setToast] = useState(null);
// Auto-hide toast after 3 seconds
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
return undefined;
}, [toast]);
// Show toast notification
const showToast = useCallback((message, type = 'error') => {
setToast({ message, type, key: Date.now() });
}, []);
// Toggle region selection
const toggleRegion = regionId => {
// Prevent removing default regions
if (DEFAULT_REGION_IDS.has(regionId) && selectedRegions.has(regionId)) {
return;
}
// Check if trying to add beyond max
if (!selectedRegions.has(regionId) && selectedRegions.size >= MAX_REGIONS) {
showToast(`You cannot select more than ${MAX_REGIONS} regions!`);
return;
}
setSelectedRegions(prev => {
const next = new Set(prev);
if (next.has(regionId)) {
next.delete(regionId);
} else {
next.add(regionId);
}
return next;
});
};
// Get region names for filtering
const selectedRegionNames = useMemo(() => {
const names = new Set(['Global']);
plannerRegions.regions.forEach(region => {
if (selectedRegions.has(region.id)) {
names.add(region.name);
}
});
return names;
}, [selectedRegions]);
// Filter tasks based on selected regions
const availableTasks = useMemo(
() => plannerTasks.tasks.filter(task => task.regions.some(region => selectedRegionNames.has(region))),
[selectedRegionNames]
);
// Filter items based on selected regions
const availableItems = useMemo(
() => plannerItems.items.filter(item => item.regions.some(region => selectedRegionNames.has(region))),
[selectedRegionNames]
);
// Handle task completion with cascading logic
const toggleTaskCompletion = useCallback(taskId => {
setCompletedTasks(prev => {
const next = new Set(prev);
const task = plannerTasks.tasks.find(t => t.id === taskId);
if (!task) {
return prev;
}
if (next.has(taskId)) {
// Uncompleting a task - just remove it
next.delete(taskId);
} else {
// Completing a task - also complete any tasks in autoCompletes
next.add(taskId);
if (task.autoCompletes && task.autoCompletes.length > 0) {
task.autoCompletes.forEach(autoId => {
next.add(autoId);
});
}
}
return next;
});
}, []);
// Check if a task is locked (prerequisites not met)
const isTaskLocked = useCallback(task => {
if (!task.prerequisites || task.prerequisites.length === 0) {
return false;
}
return task.prerequisites.some(prereqId => !completedTasks.has(prereqId));
}, [completedTasks]);
// Calculate total points from completed tasks
const totalPoints = useMemo(
() => availableTasks.filter(task => completedTasks.has(task.id)).reduce((sum, task) => sum + task.points, 0),
[availableTasks, completedTasks]
);
// Get difficulty color class
const getDifficultyColor = difficulty => {
const colors = {
Easy: 'text-green-400',
Medium: 'text-yellow-400',
Hard: 'text-orange-400',
Elite: 'text-red-400',
Master: 'text-purple-400',
};
return colors[difficulty] || 'text-gray-400';
};
return (
<PageWrapper>
{/* Toast Notification */}
{toast && (
<div
key={toast.key}
className='fixed top-4 right-4 z-50 animate-jiggle'
style={{
animation: 'jiggle 0.5s ease-in-out',
}}
>
<div className='bg-red-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-2'>
<span className='text-lg'></span>
<span>{toast.message}</span>
</div>
</div>
)}
{/* Jiggle animation styles */}
<style>{`
@keyframes jiggle {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
`}</style>
<Card>
<Card.Body>
<div className='p-4'>
<div className='mb-6'>
<h1 className='text-2xl font-bold mb-2'>League Planner</h1>
<p className='text-sm text-secondary'>
Select regions to see available tasks and items. Completing certain tasks will automatically mark related tasks as complete.
</p>
</div>
{/* Points Display */}
<div className='mb-6 p-4 bg-secondary rounded-lg'>
<div className='text-lg font-semibold'>
Total Points: <span className='text-accent'>{totalPoints}</span>
</div>
<div className='text-sm text-secondary'>
Tasks Completed: {completedTasks.size} / {availableTasks.length}
</div>
</div>
{/* Region Selector - Right anchored card with badges, 1 per row */}
<div className='w-full flex justify-end mb-6'>
<div className='bg-secondary rounded-lg p-3 border border-gray-700'>
<div className='text-xs text-secondary mb-2 text-right'>Select Regions</div>
<div className='text-xs text-secondary mb-1 text-right'>
{selectedRegions.size} / {MAX_REGIONS} selected
</div>
<div className='flex flex-col gap-1'>
{plannerRegions.regions.map(region => {
const isSelected = selectedRegions.has(region.id);
const { isDefault } = region;
const badge = getRegionBadge(region.name);
return (
<button
key={region.id}
type='button'
onClick={() => toggleRegion(region.id)}
className={`flex items-center justify-end gap-2 px-2 py-1 rounded transition-all duration-200 ${
isDefault ? 'cursor-default' : 'hover:bg-gray-700 cursor-pointer'
} ${isSelected ? 'opacity-100' : 'opacity-40'}`}
title={isDefault ? 'Default region (cannot be removed)' : region.name}
>
<span className={`text-sm ${isSelected ? 'text-white' : 'text-gray-400'}`}>
{region.name}
{isDefault && <span className='ml-1 text-yellow-400' title='Default region'>🔒</span>}
</span>
<img
src={badge}
alt={region.name}
width='20'
height='30'
decoding='async'
loading='lazy'
className={`${isSelected ? '' : 'grayscale'}`}
/>
</button>
);
})}
</div>
</div>
</div>
<div className='grid grid-cols-1 lg:grid-cols-2 gap-6'>
{/* Available Tasks */}
<div>
<h2 className='text-lg font-semibold mb-3'>
Available Tasks ({availableTasks.length})
</h2>
<div className='space-y-2 max-h-96 overflow-y-auto pr-2'>
{availableTasks.map(task => {
const isCompleted = completedTasks.has(task.id);
const isLocked = isTaskLocked(task);
const hasAutoCompletes = task.autoCompletes && task.autoCompletes.length > 0;
return (
<div
key={task.id}
className={`p-3 rounded-lg border transition-all ${
isCompleted
? 'bg-green-900/30 border-green-700'
: isLocked
? 'bg-gray-800/50 border-gray-700 opacity-50'
: 'bg-gray-800 border-gray-700 hover:border-gray-500'
}`}
>
<div className='flex items-start gap-3'>
<input
type='checkbox'
checked={isCompleted}
disabled={isLocked}
onChange={() => toggleTaskCompletion(task.id)}
className='mt-1 h-4 w-4 rounded'
/>
<div className='flex-1 min-w-0'>
<div className='flex items-center gap-2 flex-wrap'>
<span className={`font-medium ${isCompleted ? 'line-through opacity-70' : ''}`}>
{task.name}
</span>
<span className={`text-xs ${getDifficultyColor(task.difficulty)}`}>
{task.difficulty}
</span>
<span className='text-xs text-accent'>
{task.points} pts
</span>
</div>
<p className='text-xs text-secondary mt-1'>{task.description}</p>
{hasAutoCompletes && (
<p className='text-xs text-blue-400 mt-1'>
Auto-completes {task.autoCompletes.length} other task(s)
</p>
)}
{isLocked && (
<p className='text-xs text-red-400 mt-1'>
🔒 Requires prerequisite tasks
</p>
)}
<div className='flex gap-1 mt-1 flex-wrap'>
{task.regions.map(region => (
<span
key={region}
className='text-xs px-1.5 py-0.5 bg-gray-700 rounded'
>
{region}
</span>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
{/* Available Items */}
<div>
<h2 className='text-lg font-semibold mb-3'>
Available Items ({availableItems.length})
</h2>
<div className='space-y-2 max-h-96 overflow-y-auto pr-2'>
{availableItems.map(item => (
<div
key={item.id}
className='p-3 rounded-lg bg-gray-800 border border-gray-700'
>
<div className='flex items-center justify-between'>
<span className='font-medium'>{item.name}</span>
<span className='text-xs px-2 py-0.5 bg-gray-700 rounded'>
{item.slot}
</span>
</div>
<p className='text-xs text-secondary mt-1'>
{item.obtainedFrom}
</p>
{item.requirements.length > 0 && (
<div className='flex gap-1 mt-1 flex-wrap'>
{item.requirements.map((req, idx) => (
<span
key={idx}
className='text-xs px-1.5 py-0.5 bg-blue-900/50 rounded'
>
{req.skill} {req.level}
</span>
))}
</div>
)}
{item.relicUnlock && (
<p className='text-xs text-purple-400 mt-1'>
🔮 Requires relic unlock
</p>
)}
<div className='flex gap-1 mt-1'>
{item.regions.map(region => (
<span
key={region}
className='text-xs px-1.5 py-0.5 bg-gray-700 rounded'
>
{region}
</span>
))}
</div>
</div>
))}
</div>
</div>
</div>
{/* Relics Section */}
<div className='mt-6'>
<h2 className='text-lg font-semibold mb-3'>Relic Tiers</h2>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3'>
{plannerRelics.relicTiers.map(tier => {
const relicsInTier = plannerRelics.relics.filter(r => r.tier === tier.tier);
const isUnlocked = totalPoints >= tier.pointsRequired;
return (
<div
key={tier.tier}
className={`p-3 rounded-lg border ${
isUnlocked
? 'bg-purple-900/30 border-purple-700'
: 'bg-gray-800/50 border-gray-700 opacity-60'
}`}
>
<div className='flex items-center justify-between mb-2'>
<span className='font-medium'>Tier {tier.tier}</span>
<span className='text-xs text-accent'>
{tier.pointsRequired} pts
</span>
</div>
<p className='text-xs text-secondary mb-2'>{tier.name}</p>
<div className='space-y-1'>
{relicsInTier.slice(0, 3).map(relic => (
<div key={relic.id} className='text-xs text-gray-400'>
{relic.name}
</div>
))}
</div>
{!isUnlocked && (
<p className='text-xs text-yellow-500 mt-2'>
Need {tier.pointsRequired - totalPoints} more points
</p>
)}
</div>
);
})}
</div>
</div>
</div>
</Card.Body>
</Card>
</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);
@@ -2660,9 +2670,15 @@ select[multiple]:focus option:checked {
.right-1 {
right: 0.25rem;
}
.right-4 {
right: 1rem;
}
.top-0 {
top: 0px;
}
.top-4 {
top: 1rem;
}
.z-10 {
z-index: 10;
}
@@ -2709,6 +2725,10 @@ select[multiple]:focus option:checked {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.my-10 {
margin-top: 2.5rem;
margin-bottom: 2.5rem;
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
@@ -2732,6 +2752,9 @@ select[multiple]:focus option:checked {
.mb-6 {
margin-bottom: 1.5rem;
}
.mb-8 {
margin-bottom: 2rem;
}
.mb-auto {
margin-bottom: auto;
}
@@ -2882,6 +2905,9 @@ select[multiple]:focus option:checked {
.max-h-6 {
max-height: 1.5rem;
}
.max-h-96 {
max-height: 24rem;
}
.max-h-\[200px\] {
max-height: 200px;
}
@@ -2951,6 +2977,9 @@ select[multiple]:focus option:checked {
.w-px {
width: 1px;
}
.min-w-0 {
min-width: 0px;
}
.min-w-\[100px\] {
min-width: 100px;
}
@@ -2960,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;
}
@@ -3036,6 +3068,9 @@ select[multiple]:focus option:checked {
.animate-spin {
animation: spin 1s linear infinite;
}
.cursor-default {
cursor: default;
}
.cursor-pointer {
cursor: pointer;
}
@@ -3098,6 +3133,9 @@ select[multiple]:focus option:checked {
.items-stretch {
align-items: stretch;
}
.justify-end {
justify-content: flex-end;
}
.justify-center {
justify-content: center;
}
@@ -3128,6 +3166,9 @@ select[multiple]:focus option:checked {
.gap-5 {
gap: 1.25rem;
}
.gap-6 {
gap: 1.5rem;
}
.gap-px {
gap: 1px;
}
@@ -3160,6 +3201,12 @@ select[multiple]:focus option:checked {
.overflow-hidden {
overflow: hidden;
}
.overflow-x-auto {
overflow-x: auto;
}
.overflow-y-auto {
overflow-y: auto;
}
.overflow-y-scroll {
overflow-y: scroll;
}
@@ -3267,6 +3314,14 @@ select[multiple]:focus option:checked {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity, 1));
}
.border-green-700 {
--tw-border-opacity: 1;
border-color: rgb(21 128 61 / var(--tw-border-opacity, 1));
}
.border-purple-700 {
--tw-border-opacity: 1;
border-color: rgb(126 34 206 / var(--tw-border-opacity, 1));
}
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity, 1));
@@ -3275,6 +3330,13 @@ select[multiple]:focus option:checked {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1));
}
.bg-blue-900\/50 {
background-color: rgb(30 58 138 / 0.5);
}
.bg-gray-600 {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
}
.bg-gray-700 {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
@@ -3283,6 +3345,9 @@ select[multiple]:focus option:checked {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
}
.bg-gray-800\/50 {
background-color: rgb(31 41 55 / 0.5);
}
.bg-gray-900 {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity, 1));
@@ -3291,10 +3356,20 @@ select[multiple]:focus option:checked {
--tw-bg-opacity: 1;
background-color: rgb(34 197 94 / var(--tw-bg-opacity, 1));
}
.bg-green-900\/30 {
background-color: rgb(20 83 45 / 0.3);
}
.bg-purple-900\/30 {
background-color: rgb(88 28 135 / 0.3);
}
.bg-red-500 {
--tw-bg-opacity: 1;
background-color: rgb(239 68 68 / var(--tw-bg-opacity, 1));
}
.bg-red-600 {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
}
.bg-red-900\/90 {
background-color: rgb(127 29 29 / 0.9);
}
@@ -3332,6 +3407,10 @@ select[multiple]:focus option:checked {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.px-1\.5 {
padding-left: 0.375rem;
padding-right: 0.375rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@@ -3348,6 +3427,10 @@ select[multiple]:focus option:checked {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.py-0\.5 {
padding-top: 0.125rem;
padding-bottom: 0.125rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
@@ -3405,6 +3488,9 @@ select[multiple]:focus option:checked {
.text-center {
text-align: center;
}
.text-right {
text-align: right;
}
.indent-8 {
text-indent: 2rem;
}
@@ -3461,6 +3547,9 @@ select[multiple]:focus option:checked {
.font-medium {
font-weight: 500;
}
.font-normal {
font-weight: 400;
}
.font-semibold {
font-weight: 600;
}
@@ -3491,6 +3580,14 @@ 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));
}
.text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
@@ -3499,6 +3596,10 @@ select[multiple]:focus option:checked {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
}
.text-gray-500 {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}
.text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
@@ -3507,6 +3608,14 @@ select[multiple]:focus option:checked {
--tw-text-opacity: 1;
color: rgb(74 222 128 / var(--tw-text-opacity, 1));
}
.text-orange-400 {
--tw-text-opacity: 1;
color: rgb(251 146 60 / var(--tw-text-opacity, 1));
}
.text-purple-400 {
--tw-text-opacity: 1;
color: rgb(192 132 252 / var(--tw-text-opacity, 1));
}
.text-red-100 {
--tw-text-opacity: 1;
color: rgb(254 226 226 / var(--tw-text-opacity, 1));
@@ -3523,16 +3632,42 @@ select[multiple]:focus option:checked {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
}
.text-yellow-400 {
--tw-text-opacity: 1;
color: rgb(250 204 21 / var(--tw-text-opacity, 1));
}
.text-yellow-500 {
--tw-text-opacity: 1;
color: rgb(234 179 8 / var(--tw-text-opacity, 1));
}
.underline {
text-decoration-line: underline;
}
.line-through {
text-decoration-line: line-through;
}
.placeholder-gray-400::placeholder {
--tw-placeholder-opacity: 1;
color: rgb(156 163 175 / var(--tw-placeholder-opacity, 1));
}
.opacity-100 {
opacity: 1;
}
.opacity-25 {
opacity: 0.25;
}
.opacity-40 {
opacity: 0.4;
}
.opacity-50 {
opacity: 0.5;
}
.opacity-60 {
opacity: 0.6;
}
.opacity-70 {
opacity: 0.7;
}
.opacity-75 {
opacity: 0.75;
}
@@ -3556,6 +3691,10 @@ select[multiple]:focus option:checked {
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.grayscale {
--tw-grayscale: grayscale(100%);
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
@@ -3564,11 +3703,22 @@ select[multiple]:focus option:checked {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.transition-colors {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.duration-200 {
transition-duration: 200ms;
}
.ease-in-out {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.odd\:bg-primary:nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
@@ -3634,6 +3784,10 @@ select[multiple]:focus option:checked {
border-bottom-right-radius: 0.25rem;
border-bottom-left-radius: 0.25rem;
}
.hover\:border-gray-500:hover {
--tw-border-opacity: 1;
border-color: rgb(107 114 128 / var(--tw-border-opacity, 1));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity, 1));
@@ -3642,6 +3796,10 @@ select[multiple]:focus option:checked {
--tw-bg-opacity: 1;
background-color: rgb(75 85 99 / var(--tw-bg-opacity, 1));
}
.hover\:bg-gray-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
}
.hover\:bg-gray-800:hover {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity, 1));
@@ -3697,6 +3855,8 @@ select[multiple]:focus option:checked {
max-width: 75%;
}.md\:flex-shrink-0 {
flex-shrink: 0;
}.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}.md\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}.md\:flex-row {
@@ -3733,6 +3893,8 @@ select[multiple]:focus option:checked {
flex-basis: 50%;
}.lg\:basis-1\/4 {
flex-basis: 25%;
}.lg\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}.lg\:grid-cols-4 {

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"]
}