diff --git a/src/app/App.tsx b/src/app/App.tsx index 61bc06f..b5b8222 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -10,6 +10,8 @@ import { ToastProvider, useToast, } from '@/lib/hooks/useToast' +import AdminGuard from '@/feature/auth/AdminGuard' +import LoginPage from '@/feature/auth/LoginPage' import { ElementsCreation, ProtocolCreation, @@ -40,16 +42,17 @@ const App: FC = () => { + } /> }> } /> } /> } + element={} /> } + element={} /> = ({ children }) => { + if (!isAuthenticated()) { + return + } + + return <>{children} +} + +export default AdminGuard diff --git a/src/feature/auth/LoginPage.tsx b/src/feature/auth/LoginPage.tsx new file mode 100644 index 0000000..fce475c --- /dev/null +++ b/src/feature/auth/LoginPage.tsx @@ -0,0 +1,67 @@ +import { FC, FormEvent, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { login } from './authService' + +const LoginPage: FC = () => { + const [password, setPassword] = useState('') + const [error, setError] = useState(false) + const [loading, setLoading] = useState(false) + const navigate = useNavigate() + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + setError(false) + setLoading(true) + + const success = await login(password) + setLoading(false) + + if (success) { + navigate('/templates', { replace: true }) + } else { + setError(true) + setPassword('') + } + } + + return ( +
+
+

+ Вход в режим администратора +

+ + setPassword(e.target.value)} + placeholder="Пароль" + className={`mb-4 w-full rounded border px-4 py-2 outline-none focus:ring-2 ${ + error + ? 'border-red-400 focus:ring-red-300' + : 'border-gray-300 focus:ring-blue-300' + }`} + autoFocus + /> + + {error && ( +

Неверный пароль

+ )} + + +
+
+ ) +} + +export default LoginPage diff --git a/src/feature/auth/authService.ts b/src/feature/auth/authService.ts new file mode 100644 index 0000000..6046517 --- /dev/null +++ b/src/feature/auth/authService.ts @@ -0,0 +1,57 @@ +const ADMIN_HASH = 'fa4fec80e90189885dc6a389324bd3beaae9be020641dd37c89f440bf9515e4d' +const SALT = 'protoc_salt_2025' +const STORAGE_KEY = 'admin_auth' +const SESSION_DURATION_MS = 24 * 60 * 60 * 1000 // 24 часа + +interface AuthData { + hash: string + expiresAt: number +} + +async function sha256(message: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') +} + +export async function login(password: string): Promise { + const hash = await sha256(`${SALT}::${password}`) + + if (hash !== ADMIN_HASH) { + return false + } + + const authData: AuthData = { + hash, + expiresAt: Date.now() + SESSION_DURATION_MS, + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(authData)) + return true +} + +export function isAuthenticated(): boolean { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return false + + try { + const authData: AuthData = JSON.parse(raw) + + if (authData.hash !== ADMIN_HASH) return false + if (Date.now() > authData.expiresAt) { + localStorage.removeItem(STORAGE_KEY) + return false + } + + return true + } catch { + localStorage.removeItem(STORAGE_KEY) + return false + } +} + +export function logout(): void { + localStorage.removeItem(STORAGE_KEY) +} diff --git a/src/page/templates/Page.tsx b/src/page/templates/Page.tsx index 10377dd..fc37f91 100644 --- a/src/page/templates/Page.tsx +++ b/src/page/templates/Page.tsx @@ -47,8 +47,10 @@ import { Trash2, Type, X, + LogOut, } from 'lucide-react' import { useCallback, useMemo, useState } from 'react' +import { isAuthenticated, logout } from '@/feature/auth/authService' import { Button } from 'shared/ui/button' import { Card, CardContent, CardHeader, CardTitle } from 'shared/ui/card' import { Checkbox } from 'shared/ui/checkbox' @@ -937,6 +939,20 @@ const TemplatesPageContent = () => {

Карточки протоколов

+ {isAuthenticated() && ( + + )} {/* Кнопка сохранения порядка */}