feat: add PIN-based login with server-side cookie sessions
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
All checks were successful
Deploy HiHala Dashboard / deploy (push) Successful in 6s
- Server: POST /auth/login (verify PIN, set httpOnly cookie) - Server: GET /auth/check, POST /auth/logout - Client: Login page shown when not authenticated - Session persists 7 days via httpOnly cookie - PIN stored server-side only (ADMIN_PIN env var) - Dashboard loads data only after successful auth Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
88
src/App.css
88
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;
|
||||
|
||||
41
src/App.tsx
41
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<boolean | null>(null);
|
||||
const [data, setData] = useState<MuseumRecord[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [refreshing, setRefreshing] = useState<boolean>(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 (
|
||||
<div className="app" dir={dir}>
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated — show login
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="app" dir={dir}>
|
||||
<Login onLogin={handleLogin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="app" dir={dir}>
|
||||
|
||||
74
src/components/Login.tsx
Normal file
74
src/components/Login.tsx
Normal file
@@ -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 (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-brand">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="var(--accent)" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="4" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="11" width="7" height="10" rx="1"/>
|
||||
</svg>
|
||||
<h1>HiHala Data</h1>
|
||||
</div>
|
||||
<p className="login-subtitle">{t('login.subtitle')}</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
value={pin}
|
||||
onChange={e => setPin(e.target.value)}
|
||||
placeholder={t('login.placeholder')}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<button type="submit" disabled={loading || !pin}>
|
||||
{loading ? '...' : t('login.submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
@@ -168,6 +168,13 @@
|
||||
"namePlaceholder": "مثال: رمضان",
|
||||
"add": "إضافة"
|
||||
},
|
||||
"login": {
|
||||
"subtitle": "أدخل رمز PIN للوصول إلى لوحة التحكم",
|
||||
"placeholder": "رمز PIN",
|
||||
"submit": "تسجيل الدخول",
|
||||
"invalid": "رمز PIN غير صحيح",
|
||||
"error": "خطأ في الاتصال. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"errors": {
|
||||
"config": "لم يتم تهيئة لوحة المعلومات. يرجى إعداد اتصال ERP API.",
|
||||
"network": "لا يمكن الوصول إلى خادم قاعدة البيانات. يرجى التحقق من اتصالك بالإنترنت.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user