@@ -0,0 +1,14 @@
|
||||
FROM node:18-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --production=false --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:stable-alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
EXPOSE 80
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,186 @@
|
||||
# Transit Admin Control Center
|
||||
|
||||
Modern React + Vite web application for comprehensive transit system administration.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Stop Management** - Create and manage transit stops with geolocation
|
||||
✅ **Bus Lines** - Define routes with start/end stops and estimated durations
|
||||
✅ **Fleet Management** - Monitor and view fleet vehicles
|
||||
✅ **Stop Linking** - Assign stops to bus lines with order and ETA
|
||||
✅ **JWT Authentication** - Secure authentication with role-based access
|
||||
✅ **Production Ready** - Docker containerized with runtime env configuration
|
||||
|
||||
## Local Development
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd admin_ui
|
||||
npm install
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
Create a `.env` file in the `admin_ui` directory:
|
||||
|
||||
```env
|
||||
VITE_AUTH_API=http://localhost:5000
|
||||
VITE_TRANSIT_API=http://localhost:5001
|
||||
```
|
||||
|
||||
### Run Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:5173`
|
||||
|
||||
### Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This creates a production bundle in the `dist/` directory.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Build Docker Image
|
||||
|
||||
```bash
|
||||
docker build -t xtransit/admin_ui:latest .
|
||||
```
|
||||
|
||||
### Run with Docker
|
||||
|
||||
```bash
|
||||
docker run -p 5174:80 \
|
||||
-e VITE_AUTH_API=http://localhost:5000 \
|
||||
-e VITE_TRANSIT_API=http://localhost:5001 \
|
||||
xtransit/admin_ui:latest
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
The admin UI is integrated into the main `docker-compose.yml` at the project root.
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
# Start all services including admin UI
|
||||
docker-compose up -d
|
||||
|
||||
# Access the admin UI
|
||||
http://localhost:5174
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `VITE_AUTH_API` | Auth API URL (internal: http://auth_api:5000) | http://localhost:5000 |
|
||||
| `VITE_TRANSIT_API` | Transit API URL (internal: http://transit_api:5001) | http://localhost:5001 |
|
||||
|
||||
### Runtime Configuration
|
||||
|
||||
When running in Docker, environment variables are injected at runtime via `docker-entrypoint.sh` which creates `/usr/share/nginx/html/env-config.js`. This allows configuration without rebuilding the image.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Technology Stack
|
||||
- **Frontend**: React 18.2 + Vite 5.0
|
||||
- **HTTP Client**: Axios 1.4
|
||||
- **Server**: Nginx (Alpine Linux)
|
||||
- **Build**: Multi-stage Docker build for optimized image size
|
||||
|
||||
### Component Structure
|
||||
|
||||
- `App.jsx` - Main app router, session management
|
||||
- `Login.jsx` - Authentication with dev token helper
|
||||
- `StopsManager.jsx` - Create/list transit stops
|
||||
- `BusLinesManager.jsx` - Create/list bus routes
|
||||
- `FleetManager.jsx` - View fleet vehicles
|
||||
- `LinkStop.jsx` - Link stops to bus lines
|
||||
|
||||
### API Integration
|
||||
|
||||
The app communicates with:
|
||||
- **Auth API** (/login, /me, /dev/token)
|
||||
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
|
||||
|
||||
All requests include JWT Bearer token for authentication.
|
||||
|
||||
## Authentication
|
||||
|
||||
### Login Flow
|
||||
|
||||
1. User clicks "Get Dev Token" button (development only)
|
||||
2. Client calls POST `/dev/token`
|
||||
3. Receives JWT token with userId and userCode
|
||||
4. JWT is stored in localStorage
|
||||
5. All API requests include `Authorization: Bearer <token>`
|
||||
|
||||
### Admin Authorization
|
||||
|
||||
Users must have a `userCode` in their JWT that matches `ADMIN_USER_CODES` environment variable in the transit API to access admin endpoints.
|
||||
|
||||
### Production Setup
|
||||
|
||||
1. Remove dev token button from Login.jsx
|
||||
2. Implement real authentication system (OAuth/SAML)
|
||||
3. Configure proper JWT generation in auth_api
|
||||
4. Set strong `JWT_SECRET` environment variable
|
||||
5. Configure `ADMIN_USER_CODES` with actual admin user IDs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Admin UI not loading"
|
||||
- Verify container is running: `docker ps`
|
||||
- Check logs: `docker-compose logs admin_ui`
|
||||
- Clear browser cache (Ctrl+Shift+R)
|
||||
|
||||
### Issue: "API connection refused"
|
||||
- Ensure auth_api and transit_api are running
|
||||
- Check environment variables are set correctly
|
||||
- Verify network connectivity between containers
|
||||
|
||||
### Issue: "Permission denied" on admin operations
|
||||
- Verify JWT token includes valid `userCode`
|
||||
- Check `ADMIN_USER_CODES` environment variable
|
||||
- Ensure database user has appropriate permissions
|
||||
|
||||
## Deployment
|
||||
|
||||
See [DEPLOYMENT.md](../DEPLOYMENT.md) for comprehensive deployment guide.
|
||||
|
||||
### Production Checklist
|
||||
|
||||
- [ ] Remove development authentication helpers
|
||||
- [ ] Set strong JWT_SECRET
|
||||
- [ ] Configure proper ADMIN_USER_CODES
|
||||
- [ ] Enable HTTPS/SSL
|
||||
- [ ] Configure CORS for production domain
|
||||
- [ ] Set up monitoring and logging
|
||||
- [ ] Configure database backups
|
||||
- [ ] Document admin procedures
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check service logs: `docker-compose logs admin_ui`
|
||||
2. Verify API connectivity: Visit API URLs directly
|
||||
3. Check browser console for client-side errors
|
||||
4. Review DEPLOYMENT.md for troubleshooting
|
||||
|
||||
## License
|
||||
|
||||
Part of the XTransit project
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
admin-ui:
|
||||
build: .
|
||||
ports:
|
||||
- "5174:80"
|
||||
environment:
|
||||
VITE_AUTH_API: ${VITE_AUTH_API:-http://localhost:5000}
|
||||
VITE_TRANSIT_API: ${VITE_TRANSIT_API:-http://localhost:5001}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Create runtime env-config.js for the static app
|
||||
cat > /usr/share/nginx/html/env-config.js <<'EOF'
|
||||
window.__ENV__ = {
|
||||
VITE_AUTH_API: "${VITE_AUTH_API:-http://localhost:5000}",
|
||||
VITE_TRANSIT_API: "${VITE_TRANSIT_API:-http://localhost:5001}"
|
||||
}
|
||||
EOF
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Transit Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/env-config.js"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1992
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "transit-admin-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"axios": "1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "5.0.0",
|
||||
"@vitejs/plugin-react": "5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
window.__ENV__ = {
|
||||
VITE_AUTH_API: 'http://localhost:5000',
|
||||
VITE_TRANSIT_API: 'http://localhost:5001'
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Login from './components/Login'
|
||||
import StopsManager from './components/StopsManager'
|
||||
import LinkStop from './components/LinkStop'
|
||||
import BusLinesManager from './components/BusLinesManager'
|
||||
import FleetManager from './components/FleetManager'
|
||||
|
||||
export default function App() {
|
||||
const [token, setToken] = useState(localStorage.getItem('token'))
|
||||
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
|
||||
useEffect(() => {
|
||||
if (token) localStorage.setItem('token', token)
|
||||
else localStorage.removeItem('token')
|
||||
}, [token])
|
||||
|
||||
useEffect(() => {
|
||||
if (user) localStorage.setItem('user', JSON.stringify(user))
|
||||
else localStorage.removeItem('user')
|
||||
}, [user])
|
||||
|
||||
if (!token) return <Login onLogin={(t,u) => { setToken(t); setUser(u); }} />
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<p className="eyebrow">Transit operations</p>
|
||||
<h1>Admin control center</h1>
|
||||
</div>
|
||||
<div className="userbox">
|
||||
<strong>{user?.userCode ? `User ${user.userCode}` : 'User'}</strong>
|
||||
<button onClick={() => { setToken(null); setUser(null); }}>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="hero">
|
||||
<div>
|
||||
<h2>Fleet, lijnen en stops</h2>
|
||||
<p>Beheer haltes, buslijnen, gekoppelde stops en voertuigen vanuit één overzicht.</p>
|
||||
</div>
|
||||
</div>
|
||||
<main>
|
||||
<StopsManager token={token} user={user} />
|
||||
<BusLinesManager token={token} user={user} />
|
||||
<FleetManager token={token} user={user} />
|
||||
<LinkStop token={token} user={user} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const RUNTIME = typeof window !== 'undefined' && window.__ENV__ ? window.__ENV__ : {}
|
||||
const AUTH_API = RUNTIME.VITE_AUTH_API || import.meta.env.VITE_AUTH_API || 'http://localhost:5000'
|
||||
const TRANSIT_API = RUNTIME.VITE_TRANSIT_API || import.meta.env.VITE_TRANSIT_API || 'http://localhost:5001'
|
||||
|
||||
export const authClient = axios.create({ baseURL: AUTH_API })
|
||||
export const transitClient = (token) => axios.create({
|
||||
baseURL: TRANSIT_API,
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
|
||||
export default function BusLinesManager({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
const [busLines, setBusLines] = useState([])
|
||||
const [lineNumber, setLineNumber] = useState('')
|
||||
const [startStop, setStartStop] = useState('')
|
||||
const [endStop, setEndStop] = useState('')
|
||||
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||
|
||||
const loadBusLines = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/bus-lines')
|
||||
setBusLines(res.data.busLines || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBusLines()
|
||||
}, [])
|
||||
|
||||
const createBusLine = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await client.post('/admin/bus-lines', {
|
||||
lineNumber: Number(lineNumber),
|
||||
startStop,
|
||||
endStop,
|
||||
estimatedDurationMinutes: Number(estimatedDurationMinutes),
|
||||
description,
|
||||
})
|
||||
setLineNumber('')
|
||||
setStartStop('')
|
||||
setEndStop('')
|
||||
setEstimatedDurationMinutes('')
|
||||
setDescription('')
|
||||
setMessage('Bus line created: #' + (res.data.busLine?.id || 'ok'))
|
||||
await loadBusLines()
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Buslijnen</h2>
|
||||
<p>Maak en bewerk buslijnen.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadBusLines}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div className="list-grid">
|
||||
{busLines.map(line => (
|
||||
<article className="mini-card" key={line.id}>
|
||||
<strong>#{line.id} Lijn {line.lineNumber}</strong>
|
||||
<div>{line.startStop} → {line.endStop}</div>
|
||||
<div>{line.estimatedDurationMinutes} min</div>
|
||||
{line.description ? <div className="muted">{line.description}</div> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAdmin ? (
|
||||
<form onSubmit={createBusLine} className="stack-form">
|
||||
<div className="form-row">
|
||||
<input placeholder="Line number" value={lineNumber} onChange={e => setLineNumber(e.target.value)} />
|
||||
<input placeholder="Start stop" value={startStop} onChange={e => setStartStop(e.target.value)} />
|
||||
<input placeholder="End stop" value={endStop} onChange={e => setEndStop(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<input placeholder="Duration in minutes" value={estimatedDurationMinutes} onChange={e => setEstimatedDurationMinutes(e.target.value)} />
|
||||
<input placeholder="Description" value={description} onChange={e => setDescription(e.target.value)} />
|
||||
<button type="submit">Create bus line</button>
|
||||
</div>
|
||||
</form>
|
||||
) : <div>You must be admin to create bus lines.</div>}
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
|
||||
export default function FleetManager({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
const [buses, setBuses] = useState([])
|
||||
const [name, setName] = useState('')
|
||||
const [seatCapacity, setSeatCapacity] = useState('')
|
||||
const [licensePlate, setLicensePlate] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [canCreateBus, setCanCreateBus] = useState(true)
|
||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||
|
||||
const loadBuses = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/fleet/buses')
|
||||
setBuses(res.data.buses || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBuses()
|
||||
}, [])
|
||||
|
||||
const createBus = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await client.post('/admin/fleet/buses', {
|
||||
name,
|
||||
seatCapacity: Number(seatCapacity),
|
||||
licensePlate,
|
||||
})
|
||||
setName('')
|
||||
setSeatCapacity('')
|
||||
setLicensePlate('')
|
||||
setMessage('Bus created: #' + (res.data.bus?.id || 'ok'))
|
||||
await loadBuses()
|
||||
} catch (err) {
|
||||
const details = err.response?.data?.details || err.response?.data?.error || err.message
|
||||
if (String(details).includes('permission denied') || err.response?.status === 403) {
|
||||
setCanCreateBus(false)
|
||||
setMessage('Fleet create is read-only on this database user.')
|
||||
return
|
||||
}
|
||||
setMessage('Error: ' + details)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Fleet</h2>
|
||||
<p>Beheer voertuigen voor de vloot.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadBuses}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div className="list-grid">
|
||||
{buses.map(bus => (
|
||||
<article className="mini-card" key={bus.id}>
|
||||
<strong>#{bus.id} {bus.name}</strong>
|
||||
<div>Seats: {bus.seatCapacity}</div>
|
||||
<div>Plate: {bus.licensePlate}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isAdmin && canCreateBus ? (
|
||||
<form onSubmit={createBus} className="stack-form">
|
||||
<div className="form-row">
|
||||
<input placeholder="Bus name" value={name} onChange={e => setName(e.target.value)} />
|
||||
<input placeholder="Seat capacity" value={seatCapacity} onChange={e => setSeatCapacity(e.target.value)} />
|
||||
<input placeholder="License plate" value={licensePlate} onChange={e => setLicensePlate(e.target.value)} />
|
||||
<button type="submit">Create bus</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="muted">Fleet create is read-only on this database user.</div>
|
||||
)}
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
|
||||
export default function LinkStop({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
const [busLines, setBusLines] = useState([])
|
||||
const [busLineId, setBusLineId] = useState('')
|
||||
const [stopId, setStopId] = useState('')
|
||||
const [order, setOrder] = useState('')
|
||||
const [eta, setEta] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [stops, setStops] = useState([])
|
||||
|
||||
const isAdmin = Boolean(user?.userCode)
|
||||
|
||||
const loadBusLines = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/bus-lines')
|
||||
setBusLines(res.data.busLines || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
const loadStopsForLine = async (lineId = busLineId) => {
|
||||
if (!lineId) return
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await client.get(`/bus-lines/${lineId}/stops`)
|
||||
setStops(res.data.stops || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBusLines()
|
||||
}, [])
|
||||
|
||||
const link = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await client.post(`/admin/bus-lines/${busLineId}/stops`, {
|
||||
stopId: parseInt(stopId,10),
|
||||
stopOrder: parseInt(order,10),
|
||||
estimatedArrivalMinutes: eta ? parseInt(eta,10) : null
|
||||
})
|
||||
setMessage('Linked: ' + res.data.routeStop.id)
|
||||
await loadStopsForLine()
|
||||
} catch (err) {
|
||||
if (err.response?.status === 403) setMessage('Forbidden: not admin')
|
||||
else setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Stops per lijn</h2>
|
||||
<p>Koppel stops aan lijnen en bekijk de volgorde.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={() => loadStopsForLine()}>Refresh</button>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<input
|
||||
placeholder="Bus line ID"
|
||||
value={busLineId}
|
||||
onChange={e => {
|
||||
setBusLineId(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<button type="button" onClick={() => loadStopsForLine()}>Load Stops</button>
|
||||
</div>
|
||||
|
||||
{busLines.length > 0 && (
|
||||
<div className="muted" style={{ marginBottom: 10 }}>
|
||||
Known lines: {busLines.map(line => `#${line.id} (lijn ${line.lineNumber})`).join(', ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{stops.map(s => (
|
||||
<li key={s.id}>{s.order}. {s.name} ({s.latitude}, {s.longitude})</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h3>Link a Stop to Line</h3>
|
||||
{isAdmin ? (
|
||||
<form onSubmit={link} className="form-row">
|
||||
<input placeholder="Stop ID" value={stopId} onChange={e=>setStopId(e.target.value)} />
|
||||
<input placeholder="Stop Order" value={order} onChange={e=>setOrder(e.target.value)} />
|
||||
<input placeholder="ETA minutes (optional)" value={eta} onChange={e=>setEta(e.target.value)} />
|
||||
<button type="submit">Link Stop</button>
|
||||
</form>
|
||||
) : (
|
||||
<div>You must be admin to link stops.</div>
|
||||
)}
|
||||
{message && <div className="message">{message}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import React, { useState } from 'react'
|
||||
import { authClient } from '../api'
|
||||
|
||||
export default function Login({ onLogin }) {
|
||||
const [userCode, setUserCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await authClient.post('/login', { userCode, password })
|
||||
const token = res.data.token
|
||||
// fetch /me to get payload
|
||||
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||
onLogin(token, me.data.user)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'login_failed')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const devToken = async () => {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await authClient.post('/dev/token', { userId: 1, userCode: 42 })
|
||||
const token = res.data.token
|
||||
const me = await authClient.get('/me', { headers: { Authorization: `Bearer ${token}` } })
|
||||
onLogin(token, me.data.user)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'dev_token_failed')
|
||||
} finally { setLoading(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<h2>Admin Login</h2>
|
||||
<form onSubmit={submit}>
|
||||
<label>
|
||||
User Code
|
||||
<input value={userCode} onChange={e => setUserCode(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} />
|
||||
</label>
|
||||
<button type="submit" disabled={loading}>{loading ? 'Please wait...' : 'Login'}</button>
|
||||
{error && <div className="error">{error}</div>}
|
||||
</form>
|
||||
<p>Be sure `ADMIN_USER_CODES` contains your `userCode` for admin actions.</p>
|
||||
<hr />
|
||||
<div>
|
||||
<button onClick={devToken} disabled={loading}>Get Dev Token (if available)</button>
|
||||
<div style={{fontSize:12, marginTop:6}}>Uses `/dev/token` on the auth API for quick local testing.</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { transitClient } from '../api'
|
||||
|
||||
export default function StopsManager({ token, user }) {
|
||||
const client = transitClient(token)
|
||||
const [stops, setStops] = useState([])
|
||||
const [name, setName] = useState('')
|
||||
const [lat, setLat] = useState('')
|
||||
const [lon, setLon] = useState('')
|
||||
const [message, setMessage] = useState(null)
|
||||
const [isAdmin] = useState(Boolean(user?.userCode))
|
||||
|
||||
const loadStops = async () => {
|
||||
try {
|
||||
const res = await client.get('/admin/stops')
|
||||
setStops(res.data.stops || [])
|
||||
} catch (err) {
|
||||
setMessage('Error: ' + (err.response?.data?.error || err.message))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadStops()
|
||||
}, [])
|
||||
|
||||
const createStop = async (e) => {
|
||||
e.preventDefault()
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await client.post('/admin/stops', {
|
||||
name,
|
||||
latitude: parseFloat(lat),
|
||||
longitude: parseFloat(lon)
|
||||
})
|
||||
setMessage('Stop created: #' + res.data.stop.id)
|
||||
setName(''); setLat(''); setLon('')
|
||||
await loadStops()
|
||||
} catch (err) {
|
||||
const errMsg = err.response?.data?.error || err.message
|
||||
if (err.response?.status === 401) {
|
||||
setMessage('Unauthorized: invalid token')
|
||||
} else if (err.response?.status === 403) {
|
||||
setMessage('Forbidden: you are not an admin')
|
||||
} else {
|
||||
setMessage('Error: ' + errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Stops</h2>
|
||||
<p>Maak herbruikbare haltes aan.</p>
|
||||
</div>
|
||||
<button className="secondary" onClick={loadStops}>Refresh</button>
|
||||
</div>
|
||||
<div className="list-grid">
|
||||
{stops.map(stop => (
|
||||
<article className="mini-card" key={stop.id}>
|
||||
<strong>#{stop.id} {stop.name}</strong>
|
||||
<div>{stop.latitude}, {stop.longitude}</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{isAdmin ? (
|
||||
<form onSubmit={createStop} className="stack-form">
|
||||
<div className="form-row">
|
||||
<input placeholder="Name" value={name} onChange={e=>setName(e.target.value)} />
|
||||
<input placeholder="Latitude" value={lat} onChange={e=>setLat(e.target.value)} />
|
||||
<input placeholder="Longitude" value={lon} onChange={e=>setLon(e.target.value)} />
|
||||
<button type="submit">Create Stop</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div>You must be admin to create stops.</div>
|
||||
)}
|
||||
|
||||
{message && <div className="message">{message}</div>}
|
||||
<p>Note: To view stops per line, use the Bus Line Stops tool below.</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
body {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(99, 102, 241, 0.12), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(14, 165, 233, 0.12), transparent 24%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
|
||||
font-family: Inter, system-ui, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 18px 48px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: #4f46e5;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.userbox {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
box-shadow: 0 8px 30px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(37, 99, 235, 0.88));
|
||||
color: white;
|
||||
border-radius: 24px;
|
||||
padding: 24px 26px;
|
||||
margin-bottom: 22px;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.hero p,
|
||||
.card p {
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.login {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 18px 32px rgba(15, 23, 42, 0.07);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 10px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stack-form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.45);
|
||||
padding: 11px 12px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
background: white;
|
||||
min-width: 180px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #111827;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.16);
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: white;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 10px;
|
||||
color: #047857;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
margin: 10px 0 16px;
|
||||
}
|
||||
|
||||
.mini-card {
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.mini-card strong {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login {
|
||||
max-width: 540px;
|
||||
margin: 60px auto;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user