diff --git a/backend/main.py b/backend/main.py index d064b40..0006868 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,6 +18,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from backend import config, db, push, telegram from backend.middleware import ( + _verify_jwt_token, create_auth_token, rate_limit_auth_login, rate_limit_auth_register, @@ -176,13 +177,36 @@ async def signal( ) -> SignalResponse: if credentials is None: raise HTTPException(status_code=401, detail="Unauthorized") - key_hash = _hash_api_key(credentials.credentials) - stored_hash = await db.get_api_key_hash_by_uuid(body.user_id) - if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash): - raise HTTPException(status_code=401, detail="Unauthorized") - if await db.is_user_blocked(body.user_id): - raise HTTPException(status_code=403, detail="User is blocked") + user_identifier: str = "" + user_name: str = "" + + # Try JWT auth first (new registration flow) + jwt_payload = None + try: + jwt_payload = _verify_jwt_token(credentials.credentials) + except Exception: + pass + + if jwt_payload is not None: + reg_id = int(jwt_payload["sub"]) + reg = await db.get_registration(reg_id) + if reg is None or reg["status"] != "approved": + raise HTTPException(status_code=401, detail="Unauthorized") + user_identifier = reg["login"] + user_name = reg["login"] + else: + # Legacy api_key auth + if not body.user_id: + raise HTTPException(status_code=401, detail="Unauthorized") + key_hash = _hash_api_key(credentials.credentials) + stored_hash = await db.get_api_key_hash_by_uuid(body.user_id) + if stored_hash is None or not secrets.compare_digest(key_hash, stored_hash): + raise HTTPException(status_code=401, detail="Unauthorized") + if await db.is_user_blocked(body.user_id): + raise HTTPException(status_code=403, detail="User is blocked") + user_identifier = body.user_id + user_name = await db.get_user_name(body.user_id) or body.user_id[:8] geo = body.geo lat = geo.lat if geo else None @@ -190,23 +214,21 @@ async def signal( accuracy = geo.accuracy if geo else None signal_id = await db.save_signal( - user_uuid=body.user_id, + user_uuid=user_identifier, timestamp=body.timestamp, lat=lat, lon=lon, accuracy=accuracy, ) - user_name = await db.get_user_name(body.user_id) ts = datetime.fromtimestamp(body.timestamp / 1000, tz=timezone.utc) - name = user_name or body.user_id[:8] geo_info = ( f"📍 {lat}, {lon} (±{accuracy}м)" if geo else "Без геолокации" ) text = ( - f"🚨 Сигнал от {name}\n" + f"🚨 Сигнал от {user_name}\n" f"⏰ {ts.strftime('%H:%M:%S')} UTC\n" f"{geo_info}" ) diff --git a/backend/models.py b/backend/models.py index b3d847a..5137e1a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -22,7 +22,7 @@ class GeoData(BaseModel): class SignalRequest(BaseModel): - user_id: str = Field(..., pattern=r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$') + user_id: Optional[str] = None # UUID for legacy api_key auth; omit for JWT auth timestamp: int = Field(..., gt=0) geo: Optional[GeoData] = None diff --git a/frontend/app.js b/frontend/app.js index de54b6f..309d784 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -39,31 +39,26 @@ function _initStorage() { // ========== User identity ========== -function _getOrCreateUserId() { - let id = _storage.getItem('baton_user_id'); - if (!id) { - id = crypto.randomUUID(); - _storage.setItem('baton_user_id', id); - } - return id; -} - function _isRegistered() { - return _storage.getItem('baton_registered') === '1'; + return !!_storage.getItem('baton_auth_token'); } function _getUserName() { - return _storage.getItem('baton_user_name') || ''; + return _storage.getItem('baton_login') || ''; } -function _getApiKey() { - return _storage.getItem('baton_api_key') || ''; +function _getAuthToken() { + return _storage.getItem('baton_auth_token') || ''; } -function _saveRegistration(name, apiKey) { - _storage.setItem('baton_user_name', name); - _storage.setItem('baton_registered', '1'); - if (apiKey) _storage.setItem('baton_api_key', apiKey); +function _saveAuth(token, login) { + _storage.setItem('baton_auth_token', token); + _storage.setItem('baton_login', login); +} + +function _clearAuth() { + _storage.removeItem('baton_auth_token'); + _storage.removeItem('baton_login'); } function _getInitials(name) { @@ -100,6 +95,14 @@ function _setRegStatus(msg, cls) { el.hidden = !msg; } +function _setLoginStatus(msg, cls) { + const el = document.getElementById('login-status'); + if (!el) return; + el.textContent = msg; + el.className = 'reg-status' + (cls ? ' reg-status--' + cls : ''); + el.hidden = !msg; +} + function _showView(id) { ['view-login', 'view-register'].forEach((vid) => { const el = document.getElementById(vid); @@ -157,23 +160,38 @@ function _getGeo() { // ========== Handlers ========== -async function _handleRegister() { - const input = document.getElementById('name-input'); - const btn = document.getElementById('btn-confirm'); - const name = input.value.trim(); - if (!name) return; +async function _handleLogin() { + const loginInput = document.getElementById('login-input'); + const passInput = document.getElementById('login-password'); + const btn = document.getElementById('btn-login'); + const login = loginInput.value.trim(); + const password = passInput.value; + if (!login || !password) return; btn.disabled = true; - _setStatus('', ''); + _setLoginStatus('', ''); try { - const uuid = _getOrCreateUserId(); - const data = await _apiPost('/api/register', { uuid, name }); - _saveRegistration(name, data.api_key); + const data = await _apiPost('/api/auth/login', { + login_or_email: login, + password: password, + }); + _saveAuth(data.token, data.login); + passInput.value = ''; _updateUserAvatar(); _showMain(); - } catch (_) { - _setStatus('Error. Please try again.', 'error'); + } catch (err) { + let msg = 'Ошибка входа. Попробуйте ещё раз.'; + if (err && err.message) { + const colonIdx = err.message.indexOf(': '); + if (colonIdx !== -1) { + try { + const parsed = JSON.parse(err.message.slice(colonIdx + 2)); + if (parsed.detail) msg = parsed.detail; + } catch (_) {} + } + } + _setLoginStatus(msg, 'error'); btn.disabled = false; } } @@ -186,9 +204,15 @@ function _setSosState(state) { } async function _handleSignal() { - // v1: no offline queue — show error and return (decision #1019) if (!navigator.onLine) { - _setStatus('No connection. Check your network and try again.', 'error'); + _setStatus('Нет соединения. Проверьте сеть и попробуйте снова.', 'error'); + return; + } + + const token = _getAuthToken(); + if (!token) { + _clearAuth(); + _showOnboarding(); return; } @@ -197,16 +221,13 @@ async function _handleSignal() { try { const geo = await _getGeo(); - const uuid = _getOrCreateUserId(); - const body = { user_id: uuid, timestamp: Date.now() }; + const body = { timestamp: Date.now() }; if (geo) body.geo = geo; - const apiKey = _getApiKey(); - const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {}; - await _apiPost('/api/signal', body, authHeaders); + await _apiPost('/api/signal', body, { Authorization: 'Bearer ' + token }); _setSosState('success'); - _setStatus('Signal sent!', 'success'); + _setStatus('Сигнал отправлен!', 'success'); setTimeout(() => { _setSosState('default'); _setStatus('', ''); @@ -214,9 +235,11 @@ async function _handleSignal() { } catch (err) { _setSosState('default'); if (err && err.status === 401) { - _setStatus('Session expired or key is invalid. Please re-register.', 'error'); + _clearAuth(); + _setStatus('Сессия истекла. Войдите заново.', 'error'); + setTimeout(() => _showOnboarding(), 1500); } else { - _setStatus('Error sending. Try again.', 'error'); + _setStatus('Ошибка отправки. Попробуйте ещё раз.', 'error'); } } } @@ -227,28 +250,36 @@ function _showOnboarding() { _showScreen('screen-onboarding'); _showView('view-login'); - const input = document.getElementById('name-input'); - const btn = document.getElementById('btn-confirm'); + const loginInput = document.getElementById('login-input'); + const passInput = document.getElementById('login-password'); + const btnLogin = document.getElementById('btn-login'); - input.addEventListener('input', () => { - btn.disabled = input.value.trim().length === 0; + function _updateLoginBtn() { + btnLogin.disabled = !loginInput.value.trim() || !passInput.value; + } + + loginInput.addEventListener('input', _updateLoginBtn); + passInput.addEventListener('input', _updateLoginBtn); + passInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !btnLogin.disabled) _handleLogin(); }); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !btn.disabled) _handleRegister(); - }); - btn.addEventListener('click', _handleRegister); + btnLogin.addEventListener('click', _handleLogin); const btnToRegister = document.getElementById('btn-switch-to-register'); if (btnToRegister) { btnToRegister.addEventListener('click', () => { _setRegStatus('', ''); + _setLoginStatus('', ''); _showView('view-register'); }); } const btnToLogin = document.getElementById('btn-switch-to-login'); if (btnToLogin) { - btnToLogin.addEventListener('click', () => _showView('view-login')); + btnToLogin.addEventListener('click', () => { + _setLoginStatus('', ''); + _showView('view-login'); + }); } const btnRegister = document.getElementById('btn-register'); @@ -403,11 +434,7 @@ async function _handleSignUp() { function _init() { _initStorage(); - // Pre-generate and persist UUID on first visit (per arch spec flow) - _getOrCreateUserId(); - - // Private mode graceful degradation (decision #1041): - // show inline banner with explicit action guidance when localStorage is unavailable + // Private mode graceful degradation (decision #1041) if (_storageType !== 'local') { const banner = document.getElementById('private-mode-banner'); if (banner) banner.hidden = false; @@ -418,7 +445,7 @@ function _init() { window.addEventListener('online', _updateNetworkIndicator); window.addEventListener('offline', _updateNetworkIndicator); - // Route to correct screen + // Route to correct screen based on JWT token presence if (_isRegistered()) { _showMain(); } else { diff --git a/frontend/index.html b/frontend/index.html index f04d171..7707517 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -37,23 +37,32 @@