Compare commits
9 Commits
dev
...
bbf8072a97
| Author | SHA1 | Date | |
|---|---|---|---|
| bbf8072a97 | |||
| 83f0bc77b9 | |||
| d7d89f8d2c | |||
| 73dde99924 | |||
| 2435cf5f20 | |||
| 2e1bf0c652 | |||
| dac5b4f975 | |||
| 28a2052cd6 | |||
| d50fb655b1 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -61,8 +61,3 @@ 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
142
CLAUDE.md
@@ -1,142 +0,0 @@
|
||||
# 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
|
||||
@@ -1,63 +0,0 @@
|
||||
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
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ Environment="HOST=0.0.0.0"
|
||||
Environment="WDS_SOCKET_PROTOCOL=wss"
|
||||
Environment="WDS_SOCKET_HOST=dev.leagues.tools"
|
||||
Environment="WDS_SOCKET_PORT=443"
|
||||
Environment="REACT_APP_RELDO_URL=https://api.leagues.tools"
|
||||
|
||||
|
||||
# Start the dev server with hot reload
|
||||
ExecStart=/usr/bin/npm run dev
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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=
|
||||
@@ -1,5 +0,0 @@
|
||||
# API server URL
|
||||
REACT_APP_RELDO_URL=http://localhost:3001
|
||||
|
||||
# Google Analytics tracking ID (optional)
|
||||
REACT_APP_GA_MID=
|
||||
@@ -1,7 +0,0 @@
|
||||
# Production environment variables
|
||||
|
||||
# Production API server (same origin - served by Node.js)
|
||||
REACT_APP_RELDO_URL=
|
||||
|
||||
# Google Analytics
|
||||
REACT_APP_GA_MID=
|
||||
@@ -1,19 +0,0 @@
|
||||
[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
|
||||
62
os-league-tools-master/package-lock.json
generated
62
os-league-tools-master/package-lock.json
generated
@@ -96,7 +96,6 @@
|
||||
},
|
||||
"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",
|
||||
@@ -10255,21 +10254,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-middleware": {
|
||||
"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",
|
||||
"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==",
|
||||
"dependencies": {
|
||||
"@types/http-proxy": "^1.17.15",
|
||||
"debug": "^4.3.6",
|
||||
"@types/http-proxy": "^1.17.8",
|
||||
"http-proxy": "^1.18.1",
|
||||
"is-glob": "^4.0.3",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"micromatch": "^4.0.8"
|
||||
"is-glob": "^4.0.1",
|
||||
"is-plain-obj": "^3.0.0",
|
||||
"micromatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/express": "^4.17.13"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/express": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
@@ -10854,16 +10858,6 @@
|
||||
"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",
|
||||
@@ -19837,30 +19831,6 @@
|
||||
"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",
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -19,8 +19,6 @@ 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';
|
||||
|
||||
@@ -47,7 +45,6 @@ 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() {
|
||||
@@ -114,8 +111,6 @@ export default function App() {
|
||||
<Route path='about' element={<About />} />
|
||||
<Route path='settings' element={<Settings />} />
|
||||
<Route path='faq' element={<Faq />} />
|
||||
<Route path='admin' element={<Admin />} />
|
||||
<Route path='reset-password' element={<ResetPassword />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</Auth0Provider>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// 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 BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
|
||||
|
||||
export function submitBug(formData) {
|
||||
return submitFeedback(formData, '/bug');
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* Communicates with the Java/Spring Boot backend at spring-backend/
|
||||
*/
|
||||
|
||||
// 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 BASE_URL = process.env.REACT_APP_GROUP_IRONMEN_URL || 'http://localhost:8080';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// 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 BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
|
||||
|
||||
export default async function getHiscores(rsn, handleResultCallback) {
|
||||
if (!rsn) {
|
||||
@@ -9,7 +8,7 @@ export default async function getHiscores(rsn, handleResultCallback) {
|
||||
});
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/api/hiscores/${rsn}`;
|
||||
const url = `${BASE_URL}/hiscores/${rsn}`;
|
||||
await fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// 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 BASE_URL = process.env.REACT_APP_RELDO_URL || 'http://localhost:8080';
|
||||
const DEFAULT_HEADERS = {
|
||||
'Content-type': 'application/json',
|
||||
};
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
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'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'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>
|
||||
);
|
||||
|
||||
}
|
||||
@@ -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,18 +20,38 @@ export default function PageWrapper({ children }) {
|
||||
const [manageDataModalType, setManageDataModalType] = useQueryString('open');
|
||||
|
||||
const navItems = [
|
||||
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('Stats', 'primary', 0, 0).withRouterLink('/stats').withIconFont('query_stats'),
|
||||
new NavItem('Trackers', 'primary', 0, 1).withRouterLink('/tracker').withIconFont('checklist_rtl'),
|
||||
new NavItem('Calculators', 'primary', 0, 2).withRouterLink('/calculators').withIconFont('calculate'),
|
||||
new NavItem('Groups', 'primary', 0, 3).withRouterLink('/groups').withIconFont('groups'),
|
||||
new NavItem('Planner', 'primary', 0, 4).withRouterLink('/planner').withIconFont('event_note'),
|
||||
new NavItem('Character', 'secondary', 1, 0).withCustomRenderFn(
|
||||
(isCollapsed, onNavigate) => (
|
||||
<Character.SideBarItem
|
||||
key='character'
|
||||
setCharacterModalOpen={setCharacterModalOpen}
|
||||
isCollapsed={isCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
),
|
||||
new NavItem('Data', 'secondary', 2, 0).withCustomRenderFn(
|
||||
(isCollapsed, onNavigate) => (
|
||||
<ManageData.SideBarItem
|
||||
key='manage'
|
||||
setManageDataModalType={setManageDataModalType}
|
||||
isCollapsed={isCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
)
|
||||
),
|
||||
new NavItem('Settings', 'overflow', 3, 1).withRouterLink('/settings').withIconFont('settings'),
|
||||
new NavItem('WIP-FAQ', 'overflow', 3, 2).withRouterLink('/faq').withIconFont('help_outline'),
|
||||
new NavItem('WIP-About', 'overflow', 3, 3).withRouterLink('/about').withIconFont('info'),
|
||||
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('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('Feedback', 'overflow', 4, 2).withCustomRenderFn(
|
||||
(isCollapsed, onNavigate) => (
|
||||
<Feedback.SideBarItem
|
||||
@@ -42,28 +62,9 @@ export default function PageWrapper({ children }) {
|
||||
/>
|
||||
)
|
||||
),
|
||||
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'),
|
||||
new NavItem('Tip Jar', 'overflow', 4, 3)
|
||||
.withHref('https://ko-fi.com/osleaguetools', '_blank')
|
||||
.withIconFont('savings'),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -33,7 +33,6 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
|
||||
primary: primaryNavItems,
|
||||
secondary: secondaryNavItems,
|
||||
overflow: overflowNavItems,
|
||||
footer: footerNavItems,
|
||||
} = groupNavItemsByVariant(navItems);
|
||||
|
||||
// Group overflow items by collapseGroup for organization
|
||||
@@ -56,6 +55,7 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
|
||||
))}
|
||||
</SideBarSection>
|
||||
|
||||
<SideBarDivider />
|
||||
|
||||
{/* Secondary navigation (Character, Manage Data) */}
|
||||
<SideBarSection>
|
||||
@@ -78,20 +78,7 @@ export default function SideBar({ navItems, brandName, brandLogo }) {
|
||||
{i < overflowGroups.length - 1 && <SideBarDivider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
|
||||
{/* 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
|
||||
|
||||
@@ -1,28 +1,9 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
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 [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);
|
||||
const { isLoggedIn, isAuthenticating, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin });
|
||||
|
||||
if (isAuthenticating) {
|
||||
return (
|
||||
@@ -33,221 +14,41 @@ function NavBarItem() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
const { label, icon, action } = getButtonValues(isLoggedIn, login, logout);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='text-primary md:inline hidden navbar-link-alt bg-hover px-2 py-1'
|
||||
onClick={openAuthModal}
|
||||
onClick={() => action()}
|
||||
type='button'
|
||||
>
|
||||
<span className='icon-base mr-1 align-bottom'>login</span>
|
||||
Login
|
||||
{label}
|
||||
</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 className='md:hidden inline navbar-icon-link' onClick={action} type='button'>
|
||||
<span className='text-primary-alt icon-lg leading-tight align-middle'>{icon}</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();
|
||||
const { isLoggedIn, login, logout } = useAccount({ redirectReturnToUrl: window.location.origin });
|
||||
const { label, icon, action } = getButtonValues(isLoggedIn, login, logout);
|
||||
|
||||
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 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>
|
||||
<AuthModal isOpen={authModalOpen} setIsOpen={closeAuthModal} onAuthSuccess={handleAuthSuccess} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 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 (
|
||||
<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, SideBarItem };
|
||||
export default { NavBarItem, CollapsedMenu };
|
||||
|
||||
@@ -1,119 +1,101 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useAuth0 } from '@auth0/auth0-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useDispatch, useSelector, batch } from 'react-redux';
|
||||
import { resetState } from '../store/common';
|
||||
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';
|
||||
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';
|
||||
|
||||
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);
|
||||
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);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
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());
|
||||
}
|
||||
if (isAuthenticated) {
|
||||
getAccessTokenSilently().then(token => {
|
||||
dispatch(updateAccountCache({ isAuthenticated, user, accessToken: token }));
|
||||
});
|
||||
} else {
|
||||
dispatch(setLoggedOut());
|
||||
updateAccountCache({ isAuthenticated, user, accessToken: undefined });
|
||||
}
|
||||
});
|
||||
}, [dispatch]);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Sync characters once when user logs in
|
||||
const isLoggedIn = isLoginCache || isAuthenticated;
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && !hasSyncedRef.current) {
|
||||
hasSyncedRef.current = true;
|
||||
|
||||
const doSync = async () => {
|
||||
if (localCharacters.length > 0) {
|
||||
const result = await syncCharacters(
|
||||
localCharacters.map(rsn => ({ rsn })),
|
||||
localActiveCharacter
|
||||
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
|
||||
);
|
||||
if (result.success) {
|
||||
const activeIndex = result.value.characters.findIndex(c => c.isActive);
|
||||
dispatch(syncFromServer({
|
||||
characters: result.value.characters,
|
||||
activeIndex: activeIndex >= 0 ? activeIndex : 0,
|
||||
}));
|
||||
}
|
||||
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));
|
||||
});
|
||||
} else {
|
||||
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();
|
||||
}
|
||||
|
||||
// Reset sync flag on logout
|
||||
if (!isLoggedIn) {
|
||||
hasSyncedRef.current = false;
|
||||
}
|
||||
}, [isLoggedIn, localCharacters, localActiveCharacter, dispatch]);
|
||||
|
||||
const handleAuthSuccess = userData => {
|
||||
dispatch(setLoggedIn({
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
role: userData.role,
|
||||
}));
|
||||
// Character sync will be triggered by the isLoggedIn effect
|
||||
};
|
||||
|
||||
const openAuthModal = () => {
|
||||
setAuthModalOpen(true);
|
||||
};
|
||||
|
||||
const closeAuthModal = () => {
|
||||
setAuthModalOpen(false);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const result = await logoutApi();
|
||||
// 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) {
|
||||
dispatch(setLoggedOut());
|
||||
resetState(dispatch, true);
|
||||
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() }));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isLoginCache, accessToken]);
|
||||
|
||||
const isAuthenticating = isLoading;
|
||||
|
||||
const login = () => {
|
||||
loginWithRedirect({ redirect_uri: redirectReturnToUrl });
|
||||
};
|
||||
const logout = () => {
|
||||
logoutWithRedirect({ returnTo: redirectReturnToUrl });
|
||||
resetState(dispatch, true);
|
||||
};
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
isAuthenticating: isChecking,
|
||||
username,
|
||||
userEmail,
|
||||
userRole,
|
||||
isAdmin,
|
||||
authModalOpen,
|
||||
openAuthModal,
|
||||
closeAuthModal,
|
||||
handleAuthSuccess,
|
||||
logout,
|
||||
};
|
||||
return { isLoggedIn, isAuthenticating, login, logout };
|
||||
}
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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',
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,25 @@
|
||||
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
|
||||
/* eslint-disable no-unused-vars */
|
||||
export default function updateWithUserDataStorage(wrappedDispatchFn, wrappedFnProps, localstorageKey, stateKey) {
|
||||
return async (dispatch, getState) => {
|
||||
await dispatch(wrappedDispatchFn(wrappedFnProps));
|
||||
|
||||
const state = getState();
|
||||
const { isLoggedIn } = state.account.accountCache;
|
||||
// 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;
|
||||
// }
|
||||
// }
|
||||
// })();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// const { isLoggedIn, userEmail, accessToken } = getState().account.accountCache;
|
||||
// if (isLoggedIn && !wrappedFnProps.skipDbUpdate) {
|
||||
// putUserData(userEmail, dataKey, getState()[stateKey], accessToken);
|
||||
// }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
export const CURRENT_VERSION = 5;
|
||||
export const CURRENT_VERSION = 3;
|
||||
|
||||
const INITIAL_STATE = {
|
||||
version: CURRENT_VERSION,
|
||||
accountCache: {
|
||||
isLoggedIn: false,
|
||||
isChecking: true,
|
||||
username: undefined,
|
||||
userEmail: undefined,
|
||||
userRole: undefined,
|
||||
accessToken: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,30 +17,18 @@ export const accountSlice = createSlice({
|
||||
name: 'account',
|
||||
initialState: INITIAL_STATE,
|
||||
reducers: {
|
||||
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;
|
||||
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;
|
||||
},
|
||||
reset: () => INITIAL_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
// Don't cache anything across sessions, session cookie handles it
|
||||
// Don't cache anything across sessions, let auth0 handle it
|
||||
export const loadState = () => INITIAL_STATE;
|
||||
|
||||
export const { setAuthChecking, setLoggedIn, setLoggedOut, reset } = accountSlice.actions;
|
||||
export const { updateAccountCache, reset } = accountSlice.actions;
|
||||
|
||||
export default accountSlice.reducer;
|
||||
|
||||
@@ -16,18 +16,11 @@ 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;
|
||||
@@ -36,28 +29,8 @@ 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':
|
||||
@@ -155,6 +128,6 @@ export function reset(props) {
|
||||
return updateWithUserDataStorage(innerReset, props, LOCALSTORAGE_KEYS.CHARACTER, 'character');
|
||||
}
|
||||
|
||||
export const { updateHiscores, syncFromServer, setCharacterId } = characterSlice.actions;
|
||||
export const { updateHiscores } = characterSlice.actions;
|
||||
|
||||
export default characterSlice.reducer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const CURRENT_VERSION = 3;
|
||||
export const CURRENT_VERSION = 2;
|
||||
|
||||
export const HISCORES_TTL = 1800000; // 30 min in ms
|
||||
|
||||
@@ -13,7 +13,5 @@ export const INITIAL_STATE = {
|
||||
version: CURRENT_VERSION,
|
||||
activeCharacter: 0,
|
||||
characters: [],
|
||||
// Map of RSN -> server character ID (for sync)
|
||||
characterIds: {},
|
||||
hiscoresCache: INITIAL_HISCORES_STATE,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import { CURRENT_VERSION } from './constants';
|
||||
|
||||
const versionUpdaters = {
|
||||
2: updateToV2,
|
||||
3: updateToV3,
|
||||
};
|
||||
|
||||
export default function updateCharacterVersion(state) {
|
||||
@@ -28,12 +27,3 @@ function updateToV2(prevState) {
|
||||
characters: [prevState.username],
|
||||
};
|
||||
}
|
||||
|
||||
function updateToV3(prevState) {
|
||||
// V3 adds characterIds map for server sync
|
||||
return {
|
||||
...prevState,
|
||||
version: 3,
|
||||
characterIds: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -947,16 +947,6 @@ 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);
|
||||
@@ -2752,9 +2742,6 @@ select[multiple]:focus option:checked {
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.mb-auto {
|
||||
margin-bottom: auto;
|
||||
}
|
||||
@@ -2989,9 +2976,6 @@ 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;
|
||||
}
|
||||
@@ -3201,9 +3185,6 @@ select[multiple]:focus option:checked {
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -3547,9 +3528,6 @@ select[multiple]:focus option:checked {
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
.font-normal {
|
||||
font-weight: 400;
|
||||
}
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -3580,10 +3558,6 @@ 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));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=OS League Tools - Prod Environment
|
||||
Description=OS League Tools - OSRS Leagues Hub
|
||||
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=https://api.leagues.tools"
|
||||
# Environment="REACT_APP_RELDO_URL=http://localhost:8080"
|
||||
|
||||
# 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
1672
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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
23
server/.env
@@ -1,23 +0,0 @@
|
||||
# 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"
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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
28
server/.gitignore
vendored
@@ -1,28 +0,0 @@
|
||||
# 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/
|
||||
@@ -1,18 +0,0 @@
|
||||
[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
|
||||
@@ -1,18 +0,0 @@
|
||||
[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
1729
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
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])
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
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;
|
||||
@@ -1,233 +0,0 @@
|
||||
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;
|
||||
@@ -1,334 +0,0 @@
|
||||
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;
|
||||
@@ -1,124 +0,0 @@
|
||||
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;
|
||||
@@ -1,205 +0,0 @@
|
||||
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;
|
||||
@@ -1,211 +0,0 @@
|
||||
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;
|
||||
@@ -1,121 +0,0 @@
|
||||
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;
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
Reference in New Issue
Block a user