SHiNE-server/shine-UI/js/pages/login-camera-view.js

263 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { renderHeader } from '../components/header.js';
import {
authService,
authorizeSession,
clearAuthMessages,
clearBrowserClientData,
refreshSessions,
setAuthBusy,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js';
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
import { clearStoredMessages } from '../services/message-store.js';
import {
describeTransferKeys,
parseKeyTransferText,
} from '../services/qr-key-transfer-service.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'login-camera-view', title: 'Войти по QR-коду', showAppChrome: false };
function canUseBarcodeDetector() {
return typeof window.BarcodeDetector === 'function';
}
async function createQrDetector() {
if (!canUseBarcodeDetector()) return null;
try {
const formats = await window.BarcodeDetector.getSupportedFormats?.();
if (Array.isArray(formats) && !formats.includes('qr_code')) return null;
} catch {
// Некоторые браузеры не реализуют getSupportedFormats, но сам detector работает.
}
return new window.BarcodeDetector({ formats: ['qr_code'] });
}
function setStatus(statusEl, message, kind = 'info') {
statusEl.classList.toggle('is-unavailable', kind === 'error');
statusEl.classList.toggle('is-available', kind !== 'error');
statusEl.textContent = message;
statusEl.style.display = message ? '' : 'none';
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderParsedTransfer(resultEl, transfer) {
const keys = describeTransferKeys(transfer.keys);
resultEl.innerHTML = `
<p class="meta-muted">Отсканированный логин: <strong>${escapeHtml(transfer.login)}</strong></p>
<p class="meta-muted">Получены ключи: <strong>${escapeHtml(keys.join(', '))}</strong></p>
<p class="meta-muted">Войти под этим логином и очистить локальную историю старого логина?</p>
`;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
clearAuthMessages();
const frame = document.createElement('div');
frame.className = 'camera-shell';
frame.innerHTML = `
<video class="camera-video" autoplay playsinline muted></video>
<div class="camera-frame"></div>
<div class="camera-hint">Наведите QR-код переноса ключей в рамку</div>
<div class="camera-error" id="login-camera-error" style="display:none;"></div>
`;
const manualCard = document.createElement('details');
manualCard.className = 'card stack';
manualCard.innerHTML = `
<summary>Ввести QR-текст вручную</summary>
<textarea class="input" id="login-qr-manual" rows="4" placeholder="shine-key-transfer-v1:..."></textarea>
<button class="ghost-btn" type="button" id="login-qr-manual-parse">Проверить QR-текст</button>
`;
const resultCard = document.createElement('div');
resultCard.className = 'card stack';
resultCard.style.display = 'none';
resultCard.innerHTML = `
<div id="login-qr-result"></div>
<div class="row">
<button class="ghost-btn" type="button" id="login-qr-cancel">Нет</button>
<button class="primary-btn" type="button" id="login-qr-confirm">Да</button>
</div>
`;
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';
backButton.type = 'button';
backButton.textContent = 'Назад';
const video = frame.querySelector('video');
const cameraError = frame.querySelector('#login-camera-error');
const manualInput = manualCard.querySelector('#login-qr-manual');
const parseManualButton = manualCard.querySelector('#login-qr-manual-parse');
const resultEl = resultCard.querySelector('#login-qr-result');
const cancelButton = resultCard.querySelector('#login-qr-cancel');
const confirmButton = resultCard.querySelector('#login-qr-confirm');
let stream = null;
let detector = null;
let scanTimer = 0;
let scannedTransfer = null;
let stopped = false;
const stopCamera = () => {
stopped = true;
if (scanTimer) {
window.clearTimeout(scanTimer);
scanTimer = 0;
}
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
};
const showTransfer = (transfer) => {
scannedTransfer = transfer;
stopCamera();
renderParsedTransfer(resultEl, transfer);
resultCard.style.display = '';
setStatus(status, '', 'info');
};
const parseTransferText = (text) => {
try {
const transfer = parseKeyTransferText(text);
if (!transfer.keys.deviceKey) {
throw new Error('В QR-коде нет device key для входа');
}
showTransfer(transfer);
} catch (error) {
setStatus(status, error?.message || 'Не удалось прочитать QR-код.', 'error');
}
};
const scanLoop = async () => {
if (stopped || !detector || !video || video.readyState < 2) {
if (!stopped) scanTimer = window.setTimeout(scanLoop, 250);
return;
}
try {
const codes = await detector.detect(video);
const text = String(codes?.[0]?.rawValue || '').trim();
if (text) {
parseTransferText(text);
return;
}
} catch {
// Ошибки отдельных кадров игнорируем, камера продолжит сканирование.
}
if (!stopped) scanTimer = window.setTimeout(scanLoop, 300);
};
const startCamera = async () => {
try {
detector = await createQrDetector();
if (!detector) {
throw new Error('Этот браузер не поддерживает сканирование QR через камеру. Используйте ручной ввод QR-текста.');
}
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Камера не поддерживается в этом браузере.');
}
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
video.srcObject = stream;
await video.play?.();
scanLoop();
} catch (error) {
cameraError.textContent = error?.message || 'Не удалось открыть камеру. Проверьте разрешения браузера.';
cameraError.style.display = '';
setStatus(status, cameraError.textContent, 'error');
}
};
parseManualButton.addEventListener('click', () => parseTransferText(manualInput.value));
cancelButton.addEventListener('click', () => {
scannedTransfer = null;
resultCard.style.display = 'none';
stopped = false;
void startCamera();
});
confirmButton.addEventListener('click', async () => {
if (!scannedTransfer) return;
confirmButton.disabled = true;
cancelButton.disabled = true;
setAuthBusy(true);
setAuthError('');
setStatus(status, 'Входим по QR-коду...', 'info');
try {
await authService.reconnect(state.entrySettings.shineServer);
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
await clearStoredMessages().catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await terminateCurrentSession();
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
const resumed = await authService.resumeSession(session.login, session.sessionId);
authorizeSession({
login: resumed.login || session.login,
sessionId: resumed.sessionId || session.sessionId,
storagePwd: resumed.storagePwd || session.storagePwd,
});
state.loginDraft.login = resumed.login || session.login;
state.loginDraft.password = '';
await refreshSessions();
setAuthInfo(`Вход по QR-коду выполнен для @${resumed.login || session.login}.`);
navigate('profile-view');
} catch (error) {
const message = toUserMessage(error, 'Не удалось войти по QR-коду.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setAuthBusy(false);
confirmButton.disabled = false;
cancelButton.disabled = false;
}
});
backButton.addEventListener('click', () => {
stopCamera();
navigate('login-view');
});
screen.append(
renderHeader({
title: 'Войти по QR-коду',
leftAction: {
label: '←',
onClick: () => {
stopCamera();
navigate('login-view');
},
},
}),
frame,
manualCard,
resultCard,
status,
backButton,
);
void startCamera();
screen.cleanup = stopCamera;
return screen;
}