feat: add dedicated account settings page

This commit is contained in:
2026-06-28 00:36:05 +02:00
parent 715cb448b7
commit a2c5ca7f2b
6 changed files with 677 additions and 9 deletions
+11 -8
View File
@@ -875,14 +875,17 @@ async function main() {
// ── Settings dialog ──
const settingsDialog = document.getElementById('settings-dialog');
document.getElementById('open-settings-btn').addEventListener('click', () => {
// Update auth section for active account
if (state.activeAccount) {
updateAuthSection(state.activeAccount);
}
renderSettingsAccounts();
settingsDialog.showModal();
});
const openSettingsBtn = document.getElementById('open-settings-btn');
if (!openSettingsBtn.matches('a[href]')) {
openSettingsBtn.addEventListener('click', () => {
// Update auth section for active account
if (state.activeAccount) {
updateAuthSection(state.activeAccount);
}
renderSettingsAccounts();
settingsDialog.showModal();
});
}
document.getElementById('close-settings-btn').addEventListener('click', () => {
settingsDialog.close();
});
+2 -1
View File
@@ -17,6 +17,7 @@
<nav class="nav-links">
<a class="nav-link active" href="/">Dashboard</a>
<a class="nav-link" href="/settings">Settings</a>
<a class="nav-link" href="/viewer">Message Viewer</a>
<a class="nav-link" href="/swagger">API Docs</a>
<a class="nav-link" href="/health">Health</a>
@@ -33,7 +34,7 @@
<button class="button primary" id="scrape-all-btn">Scrape all channels</button>
<button class="button" id="export-all-btn">Export all data</button>
<button class="button" id="refresh-dialogs-btn">Refresh dialogs</button>
<button class="button" id="open-settings-btn" type="button">Settings</button>
<a class="button" id="open-settings-btn" href="/settings">Settings</a>
</section>
</aside>
+156
View File
@@ -0,0 +1,156 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Scraper Settings</title>
<link rel="stylesheet" href="/static/style.css?v=5" />
</head>
<body>
<div class="app-shell settings-page-shell">
<aside class="sidebar">
<div class="brand-block">
<div class="eyebrow">Telegram Scraper</div>
<h1>Settings</h1>
<p class="muted">Account credentials, import/export, and runtime safety controls.</p>
</div>
<nav class="nav-links">
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link active" href="/settings">Settings</a>
<a class="nav-link" href="/viewer">Message Viewer</a>
<a class="nav-link" href="/swagger">API Docs</a>
<a class="nav-link" href="/health">Health</a>
</nav>
<section class="status-card">
<div class="section-title">Accounts</div>
<div id="settings-account-list" class="settings-account-list"></div>
</section>
</aside>
<main class="content settings-page">
<section class="panel settings-hero">
<div>
<div class="eyebrow">Account Settings</div>
<h2 id="settings-title">Loading accounts</h2>
<p id="settings-subtitle" class="muted">Select an account to manage credentials and scraper options.</p>
</div>
<div class="action-row">
<button class="button" id="settings-export-btn" type="button" disabled>Export</button>
<button class="button danger" id="settings-delete-btn" type="button" disabled>Delete</button>
</div>
</section>
<section id="settings-empty" class="panel hidden">
<div class="empty-state">
<h2>Add first account</h2>
<p class="muted">Create an account below or import an exported account JSON.</p>
</div>
</section>
<section class="panel-grid settings-health-grid">
<div class="panel stat-panel">
<div class="section-title">Credentials</div>
<div class="stat-value stat-compact" id="health-credentials">-</div>
</div>
<div class="panel stat-panel">
<div class="section-title">Session</div>
<div class="stat-value stat-compact" id="health-session">-</div>
</div>
<div class="panel stat-panel">
<div class="section-title">Continuous</div>
<div class="stat-value stat-compact" id="health-continuous">-</div>
</div>
<div class="panel stat-panel">
<div class="section-title">Last scrape</div>
<div class="stat-value stat-compact" id="health-last-scrape">-</div>
</div>
</section>
<section class="settings-layout">
<div class="panel">
<div class="panel-header">
<div>
<h2>Create or Import</h2>
<p class="muted">New accounts start without continuous scraping enabled.</p>
</div>
</div>
<form id="settings-add-account-form" class="stack-form">
<input id="settings-add-account-id" name="account_id" placeholder="Account ID (e.g. work)" required />
<input id="settings-add-account-label" name="label" placeholder="Display name" />
<input id="settings-add-account-api-id" name="api_id" placeholder="API ID" />
<input id="settings-add-account-api-hash" name="api_hash" placeholder="API Hash" />
<button class="button primary" type="submit">Add Account</button>
</form>
<label class="import-account-row">
<span class="muted small">Import account settings JSON</span>
<input id="settings-import-account-file" type="file" accept="application/json,.json" />
</label>
</div>
<div class="panel">
<div class="panel-header">
<div>
<h2>Credentials & Auth</h2>
<p class="muted" id="settings-auth-label">No account selected.</p>
</div>
</div>
<form id="settings-credentials-form" class="stack-form">
<input id="settings-api-id-input" name="api_id" placeholder="API ID" />
<input id="settings-api-hash-input" name="api_hash" placeholder="API Hash" />
<button class="button" type="submit">Save credentials</button>
</form>
<div class="auth-actions">
<button class="button primary" id="settings-start-qr-btn" type="button">Start QR login</button>
</div>
<div id="settings-qr-wrap" class="qr-wrap hidden">
<img id="settings-qr-image" alt="Telegram QR login" />
<p class="muted small">Open Telegram -> Settings -> Devices -> Scan QR.</p>
</div>
<form id="settings-phone-form" class="stack-form">
<input id="settings-phone-input" name="phone" placeholder="+1234567890" />
<button class="button" type="submit">Send code</button>
</form>
<form id="settings-code-form" class="stack-form hidden">
<input id="settings-code-input" name="code" placeholder="Telegram code" />
<button class="button" type="submit">Confirm code</button>
</form>
<form id="settings-password-form" class="stack-form hidden">
<input id="settings-password-input" name="password" type="password" placeholder="2FA password" />
<button class="button" type="submit">Confirm password</button>
</form>
</div>
<div class="panel">
<div class="panel-header">
<div>
<h2>Scraping</h2>
<p class="muted">Account-level parser settings.</p>
</div>
</div>
<label class="toggle-row">
<span>Download media</span>
<span class="switch">
<input id="settings-scrape-media-toggle" type="checkbox" />
<span class="switch-slider"></span>
</span>
</label>
<div class="settings-meta-list">
<div>Tracked channels: <span id="settings-channel-count">0</span></div>
<div>Messages: <span id="settings-message-count">0</span></div>
<div>Media: <span id="settings-media-count">0</span></div>
</div>
</div>
</section>
</main>
</div>
<script src="/static/settings.js?v=1"></script>
</body>
</html>
+424
View File
@@ -0,0 +1,424 @@
const settingsState = {
accounts: [],
activeAccount: null,
dashboard: null,
auth: null,
continuous: null,
};
const ACTIVE_ACCOUNT_STORAGE_KEY = 'telegramScraper.activeAccount';
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Request failed');
}
return data;
}
function showToast(message, type = 'info') {
let root = document.getElementById('toast-root');
if (!root) {
root = document.createElement('div');
root.id = 'toast-root';
root.className = 'toast-root';
document.body.appendChild(root);
}
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
root.appendChild(toast);
window.setTimeout(() => {
toast.classList.add('toast-hide');
window.setTimeout(() => toast.remove(), 250);
}, 3600);
}
function downloadJson(filename, payload) {
const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function displayTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value).replace('T', ' ').replace('+00:00', ' UTC');
return date.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function isAccountAuthorized(auth) {
const status = auth?.auth_status || auth || {};
return (
auth?.phase === 'authorized' ||
auth?.status === 'authorized' ||
status.status === 'ready' ||
status.status === 'authorized'
);
}
function accountLabel(account) {
return account?.label || account?.id || 'No account';
}
function setActiveAccount(accountId) {
settingsState.activeAccount = accountId || null;
if (accountId) {
localStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, accountId);
} else {
localStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY);
}
}
function chooseInitialAccount() {
const requested = new URLSearchParams(window.location.search).get('account');
if (requested && settingsState.accounts.some((account) => account.id === requested)) {
return requested;
}
const saved = localStorage.getItem(ACTIVE_ACCOUNT_STORAGE_KEY);
if (saved && settingsState.accounts.some((account) => account.id === saved)) {
return saved;
}
return settingsState.accounts[0]?.id || null;
}
function renderAccountList() {
const root = document.getElementById('settings-account-list');
root.innerHTML = '';
if (!settingsState.accounts.length) {
root.innerHTML = '<div class="empty-state">No accounts yet.</div>';
return;
}
settingsState.accounts.forEach((account) => {
const button = document.createElement('button');
button.className = 'settings-account-button';
button.type = 'button';
button.classList.toggle('active', account.id === settingsState.activeAccount);
const name = document.createElement('span');
name.className = 'settings-account-name';
name.textContent = accountLabel(account);
const status = document.createElement('span');
status.className = isAccountAuthorized(account.auth) ? 'settings-account-status ok' : 'settings-account-status';
status.textContent = isAccountAuthorized(account.auth) ? 'Authorized' : 'Needs auth';
button.append(name, status);
button.addEventListener('click', () => loadAccount(account.id));
root.appendChild(button);
});
}
function renderEmptyState() {
const hasAccount = Boolean(settingsState.activeAccount);
document.getElementById('settings-empty').classList.toggle('hidden', hasAccount);
document.getElementById('settings-export-btn').disabled = !hasAccount;
document.getElementById('settings-delete-btn').disabled = !hasAccount;
document.getElementById('settings-credentials-form').querySelectorAll('input, button').forEach((node) => {
node.disabled = !hasAccount;
});
document.getElementById('settings-start-qr-btn').disabled = !hasAccount;
document.getElementById('settings-phone-form').querySelectorAll('input, button').forEach((node) => {
node.disabled = !hasAccount;
});
document.getElementById('settings-scrape-media-toggle').disabled = !hasAccount;
}
function renderAccountData() {
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount);
const dashboard = settingsState.dashboard || {};
const health = dashboard.health || {};
const auth = settingsState.auth || account?.auth || {};
const continuous = settingsState.continuous || {};
const continuousStatus = continuous.status || {};
const continuousConfig = continuous.config || {};
document.getElementById('settings-title').textContent = account ? accountLabel(account) : 'No account selected';
document.getElementById('settings-subtitle').textContent = account
? `Account ID: ${account.id}`
: 'Create or import an account to manage settings.';
document.getElementById('settings-auth-label').textContent = account
? `${accountLabel(account)} auth status`
: 'No account selected.';
document.getElementById('health-credentials').textContent = health.api_credentials ? 'Saved' : 'Missing';
document.getElementById('health-credentials').style.color = health.api_credentials ? 'var(--ok)' : 'var(--warn)';
document.getElementById('health-session').textContent = health.session_ready || isAccountAuthorized(auth) ? 'Ready' : 'Missing';
document.getElementById('health-session').style.color =
health.session_ready || isAccountAuthorized(auth) ? 'var(--ok)' : 'var(--warn)';
document.getElementById('health-continuous').textContent = continuousStatus.running
? 'Running'
: continuousConfig.enabled
? 'Enabled'
: 'Stopped';
document.getElementById('health-continuous').style.color = continuousStatus.running ? 'var(--ok)' : 'var(--dim)';
document.getElementById('health-last-scrape').textContent = displayTime(
health.last_scrape || continuousStatus.last_iteration_at,
);
document.getElementById('settings-scrape-media-toggle').checked = Boolean(dashboard.dashboard?.scrape_media);
document.getElementById('settings-channel-count').textContent = String(health.channel_count || 0);
document.getElementById('settings-message-count').textContent = String(health.message_count || 0);
document.getElementById('settings-media-count').textContent = String(health.media_count || 0);
renderEmptyState();
renderAccountList();
}
async function loadAccounts() {
const payload = await api('/api/accounts');
settingsState.accounts = payload.accounts || [];
setActiveAccount(chooseInitialAccount());
renderAccountList();
if (settingsState.activeAccount) {
await loadAccount(settingsState.activeAccount, { preserveUrl: true });
} else {
settingsState.dashboard = null;
settingsState.auth = null;
settingsState.continuous = null;
renderAccountData();
}
}
async function loadAccount(accountId, options = {}) {
setActiveAccount(accountId);
const [dashboard, auth, continuous] = await Promise.all([
api(`/api/accounts/${encodeURIComponent(accountId)}`),
api(`/api/accounts/${encodeURIComponent(accountId)}/auth`).catch(() => ({})),
api(`/api/accounts/${encodeURIComponent(accountId)}/continuous`).catch(() => ({})),
]);
settingsState.dashboard = dashboard;
settingsState.auth = auth;
settingsState.continuous = continuous;
if (!options.preserveUrl) {
history.replaceState(null, '', `/settings?account=${encodeURIComponent(accountId)}`);
}
renderAccountData();
}
async function createAccount(event) {
event.preventDefault();
const accountId = document.getElementById('settings-add-account-id').value.trim();
const label = document.getElementById('settings-add-account-label').value.trim();
const apiId = document.getElementById('settings-add-account-api-id').value.trim();
const apiHash = document.getElementById('settings-add-account-api-hash').value.trim();
if (!accountId) return;
await api('/api/accounts', {
method: 'POST',
body: JSON.stringify({
account_id: accountId,
label: label || accountId,
api_id: apiId ? parseInt(apiId) : undefined,
api_hash: apiHash || undefined,
}),
});
event.currentTarget.reset();
await loadAccounts();
await loadAccount(accountId);
showToast(`Added ${accountId}.`, 'success');
}
async function importAccount(event) {
const file = event.currentTarget.files?.[0];
if (!file) return;
try {
const payload = JSON.parse(await file.text());
const accountId = payload.account_id || payload.id;
if (!accountId) throw new Error('account_id is missing in import file');
await api('/api/accounts/import', {
method: 'POST',
body: JSON.stringify(payload),
});
event.currentTarget.value = '';
await loadAccounts();
await loadAccount(accountId);
showToast(`Imported ${accountId}.`, 'success');
} catch (err) {
showToast(`Failed to import account: ${err.message}`, 'error');
}
}
async function saveCredentials(event) {
event.preventDefault();
if (!settingsState.activeAccount) return;
const apiId = document.getElementById('settings-api-id-input').value.trim();
const apiHash = document.getElementById('settings-api-hash-input').value.trim();
if (!apiId || !apiHash) return;
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/credentials`, {
method: 'POST',
body: JSON.stringify({ api_id: apiId, api_hash: apiHash }),
});
await loadAccount(settingsState.activeAccount);
showToast('Credentials saved.', 'success');
}
async function startQrLogin() {
if (!settingsState.activeAccount) return;
const data = await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/qr/start`, {
method: 'POST',
body: JSON.stringify({}),
});
if (data.qr_image) {
document.getElementById('settings-qr-image').src = data.qr_image;
document.getElementById('settings-qr-wrap').classList.remove('hidden');
}
await loadAccount(settingsState.activeAccount);
}
async function requestPhoneCode(event) {
event.preventDefault();
if (!settingsState.activeAccount) return;
const phone = document.getElementById('settings-phone-input').value.trim();
if (!phone) return;
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/phone/request`, {
method: 'POST',
body: JSON.stringify({ phone }),
});
document.getElementById('settings-code-form').classList.remove('hidden');
await loadAccount(settingsState.activeAccount);
}
async function submitPhoneCode(event) {
event.preventDefault();
if (!settingsState.activeAccount) return;
const code = document.getElementById('settings-code-input').value.trim();
if (!code) return;
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/phone/submit`, {
method: 'POST',
body: JSON.stringify({ code }),
});
document.getElementById('settings-code-input').value = '';
await loadAccount(settingsState.activeAccount);
}
async function submitPassword(event) {
event.preventDefault();
if (!settingsState.activeAccount) return;
const password = document.getElementById('settings-password-input').value.trim();
if (!password) return;
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/password`, {
method: 'POST',
body: JSON.stringify({ password }),
});
document.getElementById('settings-password-input').value = '';
await loadAccount(settingsState.activeAccount);
}
async function exportAccount() {
if (!settingsState.activeAccount) return;
const payload = await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/export`);
downloadJson(`telegram-scraper-account-${settingsState.activeAccount}.json`, payload);
showToast('Account exported.', 'success');
}
async function deleteAccount() {
if (!settingsState.activeAccount) return;
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount);
if (!window.confirm(`Remove account "${accountLabel(account)}"? All its account data will be deleted.`)) return;
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}`, { method: 'DELETE' });
setActiveAccount(null);
await loadAccounts();
showToast('Account deleted.', 'success');
}
async function toggleMedia(event) {
if (!settingsState.activeAccount) return;
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/settings/media`, {
method: 'POST',
body: JSON.stringify({ value: event.currentTarget.checked }),
});
await loadAccount(settingsState.activeAccount);
}
function bindEvents() {
document.getElementById('settings-add-account-form').addEventListener('submit', async (event) => {
try {
await createAccount(event);
} catch (err) {
showToast(`Failed to add account: ${err.message}`, 'error');
}
});
document.getElementById('settings-import-account-file').addEventListener('change', importAccount);
document.getElementById('settings-credentials-form').addEventListener('submit', async (event) => {
try {
await saveCredentials(event);
} catch (err) {
showToast(`Failed to save credentials: ${err.message}`, 'error');
}
});
document.getElementById('settings-start-qr-btn').addEventListener('click', async () => {
try {
await startQrLogin();
} catch (err) {
showToast(`QR login failed: ${err.message}`, 'error');
}
});
document.getElementById('settings-phone-form').addEventListener('submit', async (event) => {
try {
await requestPhoneCode(event);
} catch (err) {
showToast(`Phone login failed: ${err.message}`, 'error');
}
});
document.getElementById('settings-code-form').addEventListener('submit', async (event) => {
try {
await submitPhoneCode(event);
} catch (err) {
showToast(`Code submit failed: ${err.message}`, 'error');
}
});
document.getElementById('settings-password-form').addEventListener('submit', async (event) => {
try {
await submitPassword(event);
} catch (err) {
showToast(`Password submit failed: ${err.message}`, 'error');
}
});
document.getElementById('settings-export-btn').addEventListener('click', async () => {
try {
await exportAccount();
} catch (err) {
showToast(`Export failed: ${err.message}`, 'error');
}
});
document.getElementById('settings-delete-btn').addEventListener('click', async () => {
try {
await deleteAccount();
} catch (err) {
showToast(`Delete failed: ${err.message}`, 'error');
}
});
document.getElementById('settings-scrape-media-toggle').addEventListener('change', async (event) => {
try {
await toggleMedia(event);
} catch (err) {
showToast(`Media setting failed: ${err.message}`, 'error');
}
});
}
bindEvents();
loadAccounts().catch((err) => {
console.error(err);
showToast(`Failed to load settings: ${err.message}`, 'error');
});
+80
View File
@@ -194,6 +194,10 @@ input,
margin-top: 8px;
}
.status-card .button:first-of-type {
margin-top: 0;
}
.status-badge {
display: inline-flex;
padding: 6px 10px;
@@ -224,6 +228,10 @@ input,
margin-bottom: 16px;
}
.settings-health-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.panel {
margin-bottom: 16px;
}
@@ -260,6 +268,78 @@ input,
margin-top: 12px;
}
.settings-layout {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(320px, 1.1fr);
gap: 16px;
align-items: start;
}
.settings-layout .panel:last-child {
grid-column: 1 / -1;
}
.settings-page-shell .content {
max-width: 1280px;
}
.settings-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.settings-account-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.settings-account-button {
width: 100%;
min-height: 48px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
text-align: left;
border: 1px solid var(--line);
border-radius: 14px;
background: #101010;
color: var(--text-color);
padding: 10px 12px;
cursor: pointer;
}
.settings-account-button.active {
border-color: #0f6f67;
background: #092522;
}
.settings-account-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-status {
color: var(--warn);
font-size: 0.78rem;
}
.settings-account-status.ok {
color: var(--ok);
}
.settings-meta-list {
display: grid;
gap: 8px;
margin-top: 16px;
color: var(--dim);
}
input {
min-width: 160px;
padding: 10px 13px;