diff --git a/server/.env.example b/server/.env.example index 4745518..6672985 100644 --- a/server/.env.example +++ b/server/.env.example @@ -14,3 +14,7 @@ NOCODB_BASE_ID=your-base-id # ETL sync secret (for cron auth) ETL_SECRET=your-secret-here + +# Auth +ADMIN_PIN=your-pin-code +SESSION_SECRET=your-random-session-secret diff --git a/server/package-lock.json b/server/package-lock.json index 700fd1d..8919429 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,11 +9,13 @@ "version": "1.0.0", "dependencies": { "axios": "^1.6.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "tsx": "^4.19.0", @@ -483,6 +485,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -730,6 +742,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", diff --git a/server/package.json b/server/package.json index a2f0673..ee4ef89 100644 --- a/server/package.json +++ b/server/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "axios": "^1.6.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2" }, "devDependencies": { + "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", "tsx": "^4.19.0", diff --git a/server/src/config.ts b/server/src/config.ts index cb48ef4..d8b5885 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -33,3 +33,8 @@ export const nocodb = { export const etl = { secret: process.env.ETL_SECRET || '', }; + +export const auth = { + adminPin: process.env.ADMIN_PIN || '', + sessionSecret: process.env.SESSION_SECRET || 'hihala-dev-session-secret', +}; diff --git a/server/src/index.ts b/server/src/index.ts index 66e398f..f78c821 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,15 +1,19 @@ import express from 'express'; import cors from 'cors'; +import cookieParser from 'cookie-parser'; import { server, erp, nocodb } from './config'; +import authRoutes from './routes/auth'; import erpRoutes from './routes/erp'; import etlRoutes from './routes/etl'; import seasonsRoutes from './routes/seasons'; const app = express(); -app.use(cors()); +app.use(cors({ origin: true, credentials: true })); +app.use(cookieParser()); app.use(express.json()); // Mount routes +app.use('/auth', authRoutes); app.use('/api/erp', erpRoutes); app.use('/api/etl', etlRoutes); app.use('/api/seasons', seasonsRoutes); diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..d65f56c --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,66 @@ +import { Router, Request, Response } from 'express'; +import crypto from 'crypto'; +import { auth } from '../config'; + +const router = Router(); + +// In-memory session store (simple — works for single server) +const sessions = new Map(); +const SESSION_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days + +function generateSessionId(): string { + return crypto.randomBytes(32).toString('hex'); +} + +function isValidSession(sessionId: string): boolean { + const session = sessions.get(sessionId); + if (!session) return false; + if (Date.now() - session.createdAt > SESSION_MAX_AGE) { + sessions.delete(sessionId); + return false; + } + return true; +} + +// POST /auth/login — verify PIN, set session cookie +router.post('/login', (req: Request, res: Response) => { + const { pin } = req.body; + + if (!auth.adminPin) { + res.status(503).json({ error: 'Auth not configured' }); + return; + } + + if (pin !== auth.adminPin) { + res.status(401).json({ error: 'Invalid PIN' }); + return; + } + + const sessionId = generateSessionId(); + sessions.set(sessionId, { createdAt: Date.now() }); + + res.cookie('hihala_session', sessionId, { + httpOnly: true, + sameSite: 'lax', + maxAge: SESSION_MAX_AGE, + path: '/', + }); + + res.json({ ok: true }); +}); + +// GET /auth/check — check if session is valid +router.get('/check', (req: Request, res: Response) => { + const sessionId = req.cookies?.hihala_session; + res.json({ authenticated: !!sessionId && isValidSession(sessionId) }); +}); + +// POST /auth/logout — destroy session +router.post('/logout', (req: Request, res: Response) => { + const sessionId = req.cookies?.hihala_session; + if (sessionId) sessions.delete(sessionId); + res.clearCookie('hihala_session', { path: '/' }); + res.json({ ok: true }); +}); + +export default router; diff --git a/src/App.css b/src/App.css index 66b502b..167d3a5 100644 --- a/src/App.css +++ b/src/App.css @@ -851,6 +851,94 @@ table tbody tr:hover { accent-color: var(--accent); } +/* Login page */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + background: var(--bg); +} + +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 48px 40px; + width: 100%; + max-width: 380px; + text-align: center; +} + +.login-brand { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 8px; +} + +.login-brand h1 { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); +} + +.login-subtitle { + color: var(--text-secondary); + font-size: 0.875rem; + margin-bottom: 32px; +} + +.login-card form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.login-card input { + padding: 14px 16px; + border: 1px solid var(--border); + border-radius: 10px; + font-size: 1.125rem; + text-align: center; + letter-spacing: 0.15em; + background: var(--bg); + color: var(--text-primary); +} + +.login-card input:focus { + outline: 2px solid var(--accent); + outline-offset: -1px; + border-color: var(--accent); +} + +.login-card button { + padding: 14px; + border: none; + border-radius: 10px; + font-size: 1rem; + font-weight: 600; + background: var(--accent); + color: white; + cursor: pointer; + transition: opacity 150ms ease; +} + +.login-card button:hover:not(:disabled) { + opacity: 0.9; +} + +.login-card button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.login-error { + color: var(--danger, #dc2626); + font-size: 0.8125rem; +} + .settings-link { text-align: center; padding: 32px 0 16px; diff --git a/src/App.tsx b/src/App.tsx index 647d3ea..dd73d3a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react const Dashboard = lazy(() => import('./components/Dashboard')); const Comparison = lazy(() => import('./components/Comparison')); const Settings = lazy(() => import('./components/Settings')); +import Login from './components/Login'; import LoadingSkeleton from './components/shared/LoadingSkeleton'; import { fetchData, getCacheStatus, refreshData } from './services/dataService'; import { fetchSeasons } from './services/seasonsService'; @@ -36,6 +37,7 @@ interface DataSource { function App() { const { t, dir, switchLanguage } = useLanguage(); + const [authenticated, setAuthenticated] = useState(null); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -105,16 +107,49 @@ function App() { setSeasons(s); }, []); + // Check auth on mount useEffect(() => { - loadData(); - loadSeasons(); + fetch('/auth/check', { credentials: 'include' }) + .then(r => r.json()) + .then(d => { + setAuthenticated(d.authenticated); + if (d.authenticated) { + loadData(); + loadSeasons(); + } + }) + .catch(() => setAuthenticated(false)); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - + + const handleLogin = () => { + setAuthenticated(true); + loadData(); + loadSeasons(); + }; + const handleRefresh = () => { loadData(true); }; + // Auth check loading + if (authenticated === null) { + return ( +
+ +
+ ); + } + + // Not authenticated — show login + if (!authenticated) { + return ( +
+ +
+ ); + } + if (loading) { return (
diff --git a/src/components/Login.tsx b/src/components/Login.tsx new file mode 100644 index 0000000..a3b5c30 --- /dev/null +++ b/src/components/Login.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; +import { useLanguage } from '../contexts/LanguageContext'; + +interface LoginProps { + onLogin: () => void; +} + +function Login({ onLogin }: LoginProps) { + const { t } = useLanguage(); + const [pin, setPin] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ pin }), + }); + + if (!res.ok) { + setError(t('login.invalid')); + setLoading(false); + return; + } + + onLogin(); + } catch { + setError(t('login.error')); + setLoading(false); + } + }; + + return ( +
+
+
+ +

HiHala Data

+
+

{t('login.subtitle')}

+ +
+ setPin(e.target.value)} + placeholder={t('login.placeholder')} + autoFocus + disabled={loading} + /> + {error &&

{error}

} + +
+
+
+ ); +} + +export default Login; diff --git a/src/locales/ar.json b/src/locales/ar.json index 56aaa2e..3a73bfa 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -168,6 +168,13 @@ "namePlaceholder": "مثال: رمضان", "add": "إضافة" }, + "login": { + "subtitle": "أدخل رمز PIN للوصول إلى لوحة التحكم", + "placeholder": "رمز PIN", + "submit": "تسجيل الدخول", + "invalid": "رمز PIN غير صحيح", + "error": "خطأ في الاتصال. يرجى المحاولة مرة أخرى." + }, "errors": { "config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.", "network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.", diff --git a/src/locales/en.json b/src/locales/en.json index b9a5608..74e3bad 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -168,6 +168,13 @@ "namePlaceholder": "e.g. Ramadan", "add": "Add" }, + "login": { + "subtitle": "Enter your PIN to access the dashboard", + "placeholder": "PIN code", + "submit": "Login", + "invalid": "Invalid PIN code", + "error": "Connection error. Please try again." + }, "errors": { "config": "The dashboard is not configured. Please set up the ERP API connection.", "network": "Cannot reach the database server. Please check your internet connection.", diff --git a/vite.config.ts b/vite.config.ts index 36744a6..abb772f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,10 @@ export default defineConfig({ server: { port: 3000, proxy: { + '/auth': { + target: 'http://localhost:3002', + changeOrigin: true, + }, '/api/erp': { target: 'http://localhost:3002', changeOrigin: true,