auth: replace UUID-based login with JWT credential verification

Login now requires login/email + password verified against DB via
/api/auth/login. Only approved registrations can access the app.
Signal endpoint accepts JWT Bearer tokens alongside legacy api_key auth.
Old UUID-only registration flow removed from frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-21 14:14:12 +02:00
parent 1adcabf3a6
commit 04f7bd79e2
8 changed files with 173 additions and 128 deletions

View file

@ -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 {

View file

@ -37,23 +37,32 @@
<!-- Onboarding screen: shown on first visit (no UUID registered yet) -->
<div id="screen-onboarding" class="screen" role="main" hidden>
<!-- View: name entry (existing onboarding) -->
<!-- View: login with credentials -->
<div class="screen-content" id="view-login">
<input
type="text"
id="name-input"
id="login-input"
class="name-input"
placeholder="Your name"
maxlength="100"
autocomplete="name"
placeholder="Логин или email"
maxlength="255"
autocomplete="username"
autocorrect="off"
autocapitalize="words"
autocapitalize="none"
spellcheck="false"
aria-label="Your name"
aria-label="Логин или email"
>
<button type="button" id="btn-confirm" class="btn-confirm" disabled>
Confirm
<input
type="password"
id="login-password"
class="name-input"
placeholder="Пароль"
autocomplete="current-password"
aria-label="Пароль"
>
<button type="button" id="btn-login" class="btn-confirm" disabled>
Войти
</button>
<div id="login-status" class="reg-status" hidden></div>
<button type="button" id="btn-switch-to-register" class="btn-link">
Зарегистрироваться
</button>