507 lines
23 KiB
JavaScript
507 lines
23 KiB
JavaScript
import { renderHeader } from '../components/header.js';
|
||
import {
|
||
authService,
|
||
refreshSessions,
|
||
setAuthError,
|
||
setAuthInfo,
|
||
state,
|
||
} from '../state.js';
|
||
import { formatRelativeTime, showToast } from '../services/channels-ux.js';
|
||
import {
|
||
buildSecretsPayload,
|
||
deriveEspPairingPasswordHash,
|
||
encryptPairingPayloadForRequester,
|
||
} from '../services/device-pairing-service.js';
|
||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||
|
||
export const pageMeta = { id: 'device-pairing-view', title: 'Подключить по коду' };
|
||
|
||
const PAIRING_PASSWORD_STATE_PREFIX = 'shine_pairing_password_state_v1';
|
||
|
||
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 normalizeCode(value) {
|
||
return String(value || '').replace(/\D+/g, '').slice(0, 7);
|
||
}
|
||
|
||
function buildTransferKeys(savedKeys, { withExtras = false }) {
|
||
const keys = {
|
||
deviceKey: String(savedKeys?.deviceKey || '').trim(),
|
||
blockchainKey: '',
|
||
rootKey: '',
|
||
};
|
||
if (!keys.deviceKey) {
|
||
throw new Error('На этом устройстве нет сохранённого device key для передачи.');
|
||
}
|
||
if (withExtras) {
|
||
if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) {
|
||
keys.blockchainKey = String(savedKeys.blockchainKey || '').trim();
|
||
}
|
||
if (state.deviceConnect.root && savedKeys?.rootKey) {
|
||
keys.rootKey = String(savedKeys.rootKey || '').trim();
|
||
}
|
||
}
|
||
return keys;
|
||
}
|
||
|
||
function requestCardHtml(request) {
|
||
const shortCode = String(request?.shortCode || '').trim() || '0000000';
|
||
const client = String(request?.requesterClientPlatform || 'unknown');
|
||
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
|
||
return `
|
||
<div class="card stack" data-pairing-id="${String(request?.pairingId || '')}">
|
||
<div class="row" style="align-items:flex-start;">
|
||
<div class="stack" style="gap:4px;">
|
||
<div style="font-size:28px; font-weight:700; letter-spacing:0.14em;">${shortCode}</div>
|
||
<span class="meta-muted">Платформа: ${client}</span>
|
||
<span class="meta-muted">Тип payload: ${Number(request?.payloadType || 0)}</span>
|
||
<span class="meta-muted">Истекает: ${expiresText}</span>
|
||
</div>
|
||
</div>
|
||
<div class="row" style="flex-wrap:wrap;">
|
||
<button class="ghost-btn" type="button" data-action="approve-device">Подключить без доп. ключей</button>
|
||
<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>
|
||
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function makePasswordToggleIcons() {
|
||
return {
|
||
eye: `
|
||
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
||
</svg>
|
||
`,
|
||
eyeOff: `
|
||
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
||
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
|
||
</svg>
|
||
`,
|
||
};
|
||
}
|
||
|
||
function localPairingPasswordStateKey(login, serverUrl) {
|
||
return `${PAIRING_PASSWORD_STATE_PREFIX}:${String(serverUrl || '').trim()}:${String(login || '').trim().toLowerCase()}`;
|
||
}
|
||
|
||
function loadLocalPairingPasswordState(login, serverUrl) {
|
||
try {
|
||
const raw = localStorage.getItem(localPairingPasswordStateKey(login, serverUrl));
|
||
if (!raw) return false;
|
||
const parsed = JSON.parse(raw);
|
||
return !!parsed?.hasPassword;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function saveLocalPairingPasswordState(login, serverUrl, hasPassword) {
|
||
try {
|
||
localStorage.setItem(localPairingPasswordStateKey(login, serverUrl), JSON.stringify({
|
||
hasPassword: !!hasPassword,
|
||
updatedAtMs: Date.now(),
|
||
}));
|
||
} catch {}
|
||
}
|
||
|
||
export function render({ navigate }) {
|
||
const screen = document.createElement('section');
|
||
screen.className = 'stack';
|
||
let savedKeys = null;
|
||
let requests = [];
|
||
let cleanupEvent = () => {};
|
||
let disposed = false;
|
||
let settingsBusy = false;
|
||
let pairingPasswordConfigured = loadLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer);
|
||
let dialogMode = '';
|
||
|
||
screen.append(
|
||
renderHeader({
|
||
title: 'Подключить по коду',
|
||
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
|
||
}),
|
||
);
|
||
|
||
const settingsCard = document.createElement('div');
|
||
settingsCard.className = 'card stack';
|
||
const passwordIcons = makePasswordToggleIcons();
|
||
|
||
const keySummaryCard = document.createElement('div');
|
||
keySummaryCard.className = 'card stack';
|
||
keySummaryCard.innerHTML = `
|
||
<p class="field-label">Что передаётся при расширенном подключении</p>
|
||
<p class="meta-muted" id="pairing-key-summary">Проверяем локальные ключи...</p>
|
||
`;
|
||
|
||
const requestsCard = document.createElement('div');
|
||
requestsCard.className = 'card stack';
|
||
requestsCard.innerHTML = `
|
||
<div class="row" style="align-items:flex-end; gap:10px; flex-wrap:wrap;">
|
||
<label class="stack" style="flex:1 1 180px;">
|
||
<span class="field-label">Код нового устройства</span>
|
||
<input class="input" id="pairing-code-filter" inputmode="numeric" maxlength="7" placeholder="7 цифр" />
|
||
</label>
|
||
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
|
||
</div>
|
||
<p class="meta-muted">Если код не введён, показываются все активные заявки. Для подключения без доп. ключей передаётся только device key.</p>
|
||
<div class="stack" id="pairing-requests-list"></div>
|
||
`;
|
||
|
||
const status = document.createElement('p');
|
||
status.className = 'status-line is-unavailable';
|
||
status.style.display = 'none';
|
||
const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary');
|
||
const codeFilterInput = requestsCard.querySelector('#pairing-code-filter');
|
||
const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests');
|
||
const requestsListEl = requestsCard.querySelector('#pairing-requests-list');
|
||
|
||
const passwordDialog = document.createElement('div');
|
||
passwordDialog.hidden = true;
|
||
passwordDialog.style.position = 'fixed';
|
||
passwordDialog.style.inset = '0';
|
||
passwordDialog.style.zIndex = '30';
|
||
passwordDialog.innerHTML = `
|
||
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.72); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
|
||
<div class="card stack" style="position:absolute; left:16px; right:16px; top:24px; gap:12px; box-shadow:var(--shadow);">
|
||
<div class="row" style="align-items:flex-start;">
|
||
<div class="stack" style="gap:6px; flex:1;">
|
||
<p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p>
|
||
<p class="meta-muted" id="pairing-dialog-text">Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.</p>
|
||
</div>
|
||
<button class="ghost-btn" type="button" data-action="close-dialog">Закрыть</button>
|
||
</div>
|
||
<label class="stack">
|
||
<span class="field-label">Пароль</span>
|
||
<div class="inline-input-row">
|
||
<input class="input key-input" id="pairing-dialog-password" type="password" autocomplete="new-password" placeholder="Введите дополнительный пароль" />
|
||
<button class="icon-btn key-toggle-btn" type="button" id="pairing-dialog-password-toggle" aria-label="Показать пароль" title="Показать пароль">${passwordIcons.eyeOff}</button>
|
||
</div>
|
||
</label>
|
||
<label class="stack">
|
||
<span class="field-label">Подтвердите пароль</span>
|
||
<div class="inline-input-row">
|
||
<input class="input key-input" id="pairing-dialog-password-confirm" type="password" autocomplete="new-password" placeholder="Повторите пароль" />
|
||
<button class="icon-btn key-toggle-btn" type="button" id="pairing-dialog-password-confirm-toggle" aria-label="Показать пароль" title="Показать пароль">${passwordIcons.eyeOff}</button>
|
||
</div>
|
||
</label>
|
||
<div class="row" style="flex-wrap:wrap;">
|
||
<button class="primary-btn" type="button" id="pairing-dialog-save">Сохранить пароль</button>
|
||
<button class="ghost-btn" type="button" data-action="close-dialog">Отмена</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
screen.append(passwordDialog);
|
||
|
||
const dialogTitleEl = passwordDialog.querySelector('#pairing-dialog-title');
|
||
const dialogTextEl = passwordDialog.querySelector('#pairing-dialog-text');
|
||
const dialogPasswordInput = passwordDialog.querySelector('#pairing-dialog-password');
|
||
const dialogPasswordConfirmInput = passwordDialog.querySelector('#pairing-dialog-password-confirm');
|
||
const dialogSaveBtn = passwordDialog.querySelector('#pairing-dialog-save');
|
||
const dialogPasswordToggleBtn = passwordDialog.querySelector('#pairing-dialog-password-toggle');
|
||
const dialogPasswordConfirmToggleBtn = passwordDialog.querySelector('#pairing-dialog-password-confirm-toggle');
|
||
|
||
const bindPasswordToggle = (input, button) => {
|
||
button.addEventListener('click', () => {
|
||
if (input.type === 'password') {
|
||
input.type = 'text';
|
||
button.innerHTML = passwordIcons.eye;
|
||
button.setAttribute('aria-label', 'Скрыть пароль');
|
||
button.title = 'Скрыть пароль';
|
||
} else {
|
||
input.type = 'password';
|
||
button.innerHTML = passwordIcons.eyeOff;
|
||
button.setAttribute('aria-label', 'Показать пароль');
|
||
button.title = 'Показать пароль';
|
||
}
|
||
});
|
||
};
|
||
bindPasswordToggle(dialogPasswordInput, dialogPasswordToggleBtn);
|
||
bindPasswordToggle(dialogPasswordConfirmInput, dialogPasswordConfirmToggleBtn);
|
||
|
||
const openPasswordDialog = (mode) => {
|
||
dialogMode = mode;
|
||
dialogTitleEl.textContent = mode === 'change'
|
||
? 'Изменить дополнительный пароль'
|
||
: 'Задать дополнительный пароль';
|
||
dialogTextEl.textContent =
|
||
'Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.';
|
||
dialogPasswordInput.value = '';
|
||
dialogPasswordConfirmInput.value = '';
|
||
dialogPasswordInput.type = 'password';
|
||
dialogPasswordConfirmInput.type = 'password';
|
||
dialogPasswordToggleBtn.innerHTML = passwordIcons.eyeOff;
|
||
dialogPasswordConfirmToggleBtn.innerHTML = passwordIcons.eyeOff;
|
||
passwordDialog.hidden = false;
|
||
};
|
||
|
||
const closePasswordDialog = () => {
|
||
dialogMode = '';
|
||
passwordDialog.hidden = true;
|
||
};
|
||
|
||
const setSettingsBusy = (flag) => {
|
||
settingsBusy = flag;
|
||
renderSettingsCard();
|
||
};
|
||
|
||
const renderSettingsCard = () => {
|
||
settingsCard.innerHTML = '';
|
||
|
||
const title = document.createElement('p');
|
||
title.className = 'field-label';
|
||
title.textContent = 'Дополнительный пароль';
|
||
|
||
const stateText = document.createElement('p');
|
||
stateText.className = 'meta-muted';
|
||
stateText.textContent = pairingPasswordConfigured
|
||
? 'Установлен дополнительный пароль для подключения через другое устройство.'
|
||
: 'Дополнительный пароль для подключения через другое устройство не задан.';
|
||
|
||
const note = document.createElement('p');
|
||
note.className = 'meta-muted';
|
||
note.textContent = pairingPasswordConfigured
|
||
? 'Этот пароль не даёт права на вход сам по себе. Он только отсекает лишние заявки до того, как пользователь подтвердит подключение на доверённом устройстве.'
|
||
: 'Сейчас подключение работает без дополнительного пароля. Обычно этого достаточно. Если хотите, можно добавить простой пароль только как защиту от лишних заявок.';
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'row';
|
||
actions.style.flexWrap = 'wrap';
|
||
|
||
if (pairingPasswordConfigured) {
|
||
const changeBtn = document.createElement('button');
|
||
changeBtn.className = 'primary-btn';
|
||
changeBtn.type = 'button';
|
||
changeBtn.textContent = 'Изменить пароль';
|
||
changeBtn.disabled = settingsBusy;
|
||
changeBtn.addEventListener('click', () => openPasswordDialog('change'));
|
||
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.className = 'ghost-btn';
|
||
removeBtn.type = 'button';
|
||
removeBtn.textContent = 'Убрать пароль';
|
||
removeBtn.disabled = settingsBusy;
|
||
removeBtn.addEventListener('click', async () => {
|
||
setSettingsBusy(true);
|
||
try {
|
||
const payload = await authService.upsertEspPairingSettings({
|
||
enabled: true,
|
||
passwordHash: '',
|
||
ttlSeconds: 180,
|
||
});
|
||
pairingPasswordConfigured = false;
|
||
saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer, false);
|
||
setAuthInfo(`Подключение по коду без дополнительного пароля включено. TTL: ${payload?.ttlSeconds || 180} сек.`);
|
||
setStatus(status, 'Дополнительный пароль убран. Подключение по коду теперь работает без него.', 'info');
|
||
} catch (error) {
|
||
const message = toUserMessage(error, 'Не удалось убрать дополнительный пароль.');
|
||
setAuthError(message);
|
||
setStatus(status, message, 'error');
|
||
} finally {
|
||
setSettingsBusy(false);
|
||
}
|
||
});
|
||
|
||
actions.append(changeBtn, removeBtn);
|
||
} else {
|
||
const setBtn = document.createElement('button');
|
||
setBtn.className = 'primary-btn';
|
||
setBtn.type = 'button';
|
||
setBtn.textContent = 'Задать дополнительный пароль';
|
||
setBtn.disabled = settingsBusy;
|
||
setBtn.addEventListener('click', () => openPasswordDialog('set'));
|
||
actions.append(setBtn);
|
||
}
|
||
|
||
settingsCard.append(title, stateText, note, actions);
|
||
};
|
||
|
||
const renderRequests = () => {
|
||
const filterCode = normalizeCode(codeFilterInput.value);
|
||
const filtered = filterCode
|
||
? requests.filter((item) => normalizeCode(item?.shortCode) === filterCode)
|
||
: requests;
|
||
requestsListEl.innerHTML = '';
|
||
|
||
if (!filtered.length) {
|
||
const empty = document.createElement('p');
|
||
empty.className = 'meta-muted';
|
||
empty.textContent = filterCode
|
||
? 'Заявка с таким кодом пока не найдена.'
|
||
: 'Активных заявок сейчас нет.';
|
||
requestsListEl.append(empty);
|
||
return;
|
||
}
|
||
|
||
filtered.forEach((request) => {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.innerHTML = requestCardHtml(request);
|
||
requestsListEl.append(wrapper.firstElementChild);
|
||
});
|
||
};
|
||
|
||
const loadSavedKeys = async () => {
|
||
savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory);
|
||
const available = [];
|
||
if (savedKeys?.deviceKey) available.push('device');
|
||
if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain');
|
||
if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root');
|
||
keySummaryEl.textContent = available.length
|
||
? `При расширенном подключении будут переданы: ${available.join(', ')}.`
|
||
: 'На этом устройстве доступен только device key.';
|
||
};
|
||
|
||
const reloadRequests = async ({ silent = false } = {}) => {
|
||
try {
|
||
requests = await authService.listEspPairingRequests();
|
||
renderRequests();
|
||
if (!silent) {
|
||
setStatus(status, 'Список pairing-заявок обновлён.', 'info');
|
||
}
|
||
} catch (error) {
|
||
const message = toUserMessage(error, 'Не удалось загрузить pairing-заявки.');
|
||
setAuthError(message);
|
||
setStatus(status, message, 'error');
|
||
}
|
||
};
|
||
|
||
const setButtonsBusy = (flag) => {
|
||
refreshBtn.disabled = flag;
|
||
};
|
||
|
||
const approveRequest = async (request, mode) => {
|
||
const withExtras = mode === 'with-extras';
|
||
const keys = buildTransferKeys(savedKeys, { withExtras });
|
||
const payload = buildSecretsPayload({
|
||
login: state.session.login,
|
||
keys,
|
||
mode: withExtras ? 'with-extras' : 'device-only',
|
||
});
|
||
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
|
||
await authService.approveEspPairing(request?.pairingId, encryptedPayload);
|
||
showToast(withExtras ? 'Ключи переданы на новое устройство' : 'Новое устройство подключено');
|
||
setAuthInfo(withExtras ? 'Заявка подтверждена, ключи переданы.' : 'Заявка подтверждена без передачи доп. ключей.');
|
||
await refreshSessions().catch(() => {});
|
||
await reloadRequests({ silent: true });
|
||
};
|
||
|
||
passwordDialog.addEventListener('click', (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLElement)) return;
|
||
if (target.dataset.action === 'close-dialog') {
|
||
closePasswordDialog();
|
||
}
|
||
});
|
||
|
||
dialogSaveBtn.addEventListener('click', async () => {
|
||
const password = String(dialogPasswordInput.value || '');
|
||
const confirm = String(dialogPasswordConfirmInput.value || '');
|
||
const currentMode = dialogMode;
|
||
if (!password) {
|
||
setStatus(status, 'Введите дополнительный пароль.', 'error');
|
||
return;
|
||
}
|
||
if (password !== confirm) {
|
||
setStatus(status, 'Пароли не совпадают.', 'error');
|
||
return;
|
||
}
|
||
dialogSaveBtn.disabled = true;
|
||
try {
|
||
const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password);
|
||
const payload = await authService.upsertEspPairingSettings({
|
||
enabled: true,
|
||
passwordHash,
|
||
ttlSeconds: 180,
|
||
});
|
||
pairingPasswordConfigured = true;
|
||
saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer, true);
|
||
closePasswordDialog();
|
||
renderSettingsCard();
|
||
setAuthInfo(`Подключение по коду включено с дополнительным паролем. TTL: ${payload?.ttlSeconds || 180} сек.`);
|
||
setStatus(status, currentMode === 'change'
|
||
? 'Дополнительный пароль изменён.'
|
||
: 'Дополнительный пароль задан.', 'info');
|
||
} catch (error) {
|
||
const message = toUserMessage(error, 'Не удалось сохранить дополнительный пароль.');
|
||
setAuthError(message);
|
||
setStatus(status, message, 'error');
|
||
} finally {
|
||
dialogSaveBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
refreshBtn.addEventListener('click', () => {
|
||
void reloadRequests();
|
||
});
|
||
codeFilterInput.addEventListener('input', () => {
|
||
codeFilterInput.value = normalizeCode(codeFilterInput.value);
|
||
renderRequests();
|
||
});
|
||
|
||
requestsListEl.addEventListener('click', async (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLElement)) return;
|
||
const action = String(target.dataset.action || '');
|
||
if (!action) return;
|
||
const card = target.closest('[data-pairing-id]');
|
||
if (!(card instanceof HTMLElement)) return;
|
||
const pairingId = String(card.dataset.pairingId || '');
|
||
const request = requests.find((item) => String(item?.pairingId || '') === pairingId);
|
||
if (!request) return;
|
||
|
||
const buttons = [...card.querySelectorAll('button')];
|
||
buttons.forEach((btn) => { btn.disabled = true; });
|
||
try {
|
||
if (action === 'approve-device') {
|
||
await approveRequest(request, 'device-only');
|
||
} else if (action === 'approve-full') {
|
||
await approveRequest(request, 'with-extras');
|
||
} else if (action === 'reject') {
|
||
await authService.rejectEspPairing(pairingId, 'rejected_by_user');
|
||
showToast('Заявка отклонена', { kind: 'error' });
|
||
await reloadRequests({ silent: true });
|
||
}
|
||
} catch (error) {
|
||
const message = toUserMessage(error, 'Не удалось обработать pairing-заявку.');
|
||
setAuthError(message);
|
||
setStatus(status, message, 'error');
|
||
buttons.forEach((btn) => { btn.disabled = false; });
|
||
}
|
||
});
|
||
|
||
void (async () => {
|
||
try {
|
||
renderSettingsCard();
|
||
await loadSavedKeys();
|
||
await reloadRequests({ silent: true });
|
||
cleanupEvent = authService.onEvent('IncomingEspPairingRequest', () => {
|
||
if (disposed) return;
|
||
showToast('Пришла новая заявка на подключение устройства');
|
||
void reloadRequests({ silent: true });
|
||
});
|
||
} catch (error) {
|
||
const message = toUserMessage(error, 'Не удалось подготовить экран pairing.');
|
||
setAuthError(message);
|
||
setStatus(status, message, 'error');
|
||
}
|
||
})();
|
||
|
||
screen.cleanup = () => {
|
||
disposed = true;
|
||
cleanupEvent();
|
||
};
|
||
|
||
screen.append(settingsCard, keySummaryCard, requestsCard, status);
|
||
return screen;
|
||
}
|