2 Commits
Author SHA1 Message Date
ab e8873f61a5 Fixed mobile UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s
2026-07-06 16:57:26 +03:00
ab 2dbbdb0252 Fixed TG login
Build and Publish / Build and Publish Docker Image (push) Successful in 5m9s
2026-07-06 16:30:16 +03:00
10 changed files with 265 additions and 22 deletions
Generated
+1 -1
View File
@@ -61,7 +61,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "amnezia-fellow" name = "amnezia-fellow"
version = "0.1.6" version = "0.1.8"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "amnezia-fellow" name = "amnezia-fellow"
version = "0.1.7" version = "1.0.0"
edition = "2024" edition = "2024"
description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync" description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync"
+2 -1
View File
@@ -156,12 +156,13 @@ delivered.
## API ## API
The JSON API is session-authenticated: The JSON API is session-authenticated unless noted:
- `GET /api/me` - `GET /api/me`
- `GET /api/vpn-clients` - `GET /api/vpn-clients`
- `GET /api/vpn-status` - `GET /api/vpn-status`
- `GET /api/telegram-link/status` - `GET /api/telegram-link/status`
- `POST /api/telegram-login/webapp` (public Telegram `initData` login)
- `POST /api/telegram-link/webapp` - `POST /api/telegram-link/webapp`
- `POST /api/telegram-link/start` - `POST /api/telegram-link/start`
- `POST /api/telegram-link/decline` - `POST /api/telegram-link/decline`
+75
View File
@@ -162,6 +162,11 @@ struct TelegramLinkResponse {
status: TelegramLinkStatusResponse, status: TelegramLinkStatusResponse,
} }
#[derive(Debug, Serialize, JsonSchema)]
struct TelegramLoginResponse {
redirect_to: String,
}
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct TelegramWebAppLinkRequest { struct TelegramWebAppLinkRequest {
init_data: String, init_data: String,
@@ -292,6 +297,71 @@ async fn telegram_link_webapp_handler(
.into_response() .into_response()
} }
async fn telegram_login_webapp_handler(
session: Session,
db: Database,
Json(request): Json<TelegramWebAppLinkRequest>,
) -> cot::Result<cot::response::Response> {
let (config, _) = AppConfig::load_with_db(&db).await;
if !config.telegram_bot_enabled {
return Ok(json_error_typed(
cot::http::StatusCode::CONFLICT,
"telegram_disabled",
"Telegram is disabled",
"Telegram integration is disabled by the administrator.",
"",
));
}
let telegram_user = match telegram::validate_web_app_init_data(
&request.init_data,
&config.telegram_bot_token,
TELEGRAM_INIT_DATA_MAX_AGE,
) {
Ok(user) => user,
Err(e) => {
return Ok(json_error_typed(
cot::http::StatusCode::BAD_REQUEST,
"telegram_webapp_auth_failed",
"Telegram verification failed",
"Could not verify Telegram Web App data.",
&e.to_string(),
));
}
};
let telegram_id = telegram_user.id.to_string();
let Some(user) = User::get_by_telegram_id(&db, &telegram_id)
.await
.map_err(|e| cot::Error::internal(format!("failed to load Telegram user: {e}")))?
else {
return Ok(json_error_typed(
cot::http::StatusCode::UNAUTHORIZED,
"telegram_account_not_linked",
"Telegram is not linked",
"This Telegram account is not connected to any VPN account.",
"",
));
};
if !user.is_active() {
return Ok(json_error_typed(
cot::http::StatusCode::FORBIDDEN,
"user_inactive",
"Account is inactive",
"This account is inactive.",
"",
));
}
auth::login(&session, user.id_val()).await?;
Json(TelegramLoginResponse {
redirect_to: "/configs".to_owned(),
})
.into_response()
}
async fn telegram_link_start_handler( async fn telegram_link_start_handler(
session: Session, session: Session,
db: Database, db: Database,
@@ -831,6 +901,11 @@ impl App for ApiApp {
api_post(telegram_link_webapp_handler), api_post(telegram_link_webapp_handler),
"api_telegram_link_webapp", "api_telegram_link_webapp",
), ),
Route::with_api_handler_and_name(
"/telegram-login/webapp",
api_post(telegram_login_webapp_handler),
"api_telegram_login_webapp",
),
Route::with_api_handler_and_name( Route::with_api_handler_and_name(
"/telegram-link/start", "/telegram-link/start",
api_post(telegram_link_start_handler), api_post(telegram_link_start_handler),
+4 -1
View File
@@ -42,6 +42,9 @@ translations! {
login_submit: "Sign in" , "Войти"; login_submit: "Sign in" , "Войти";
login_disabled: "Login is currently disabled." , "Вход сейчас отключён."; login_disabled: "Login is currently disabled." , "Вход сейчас отключён.";
login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль."; login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль.";
login_telegram_wait: "Signing in with Telegram..." , "Входим через Telegram...";
login_telegram_fallback: "Open the regular login page" , "Открыть обычный вход";
login_telegram_failed: "Telegram login failed. Use regular login." , "Не удалось войти через Telegram. Используйте обычный вход.";
// Logout // Logout
nav_logout: "Logout" , "Выход"; nav_logout: "Logout" , "Выход";
@@ -169,7 +172,7 @@ translations! {
telegram_open_bot: "Open bot" , "Открыть бота"; telegram_open_bot: "Open bot" , "Открыть бота";
telegram_guide_start: "Open the bot and send /start once if you have not done it before." , "Откройте бота и один раз отправьте /start, если ещё не делали этого."; telegram_guide_start: "Open the bot and send /start once if you have not done it before." , "Откройте бота и один раз отправьте /start, если ещё не делали этого.";
telegram_guide_get_id: "Send the secret code below to the bot." , "Отправьте боту секретный код ниже."; telegram_guide_get_id: "Send the secret code below to the bot." , "Отправьте боту секретный код ниже.";
telegram_guide_paste: "Return here and refresh the status." , "Вернитесь сюда и обновите статус."; telegram_guide_paste: "Return here; the status updates automatically." , "Вернитесь сюда; статус обновится автоматически.";
telegram_secret_label: "Secret code" , "Секретный код"; telegram_secret_label: "Secret code" , "Секретный код";
telegram_save: "Save Telegram" , "Сохранить Telegram"; telegram_save: "Save Telegram" , "Сохранить Telegram";
telegram_saved: "Telegram connected." , "Telegram подключён."; telegram_saved: "Telegram connected." , "Telegram подключён.";
+11 -3
View File
@@ -67,14 +67,22 @@ struct ClientPortalTemplate {
app_version: &'static str, app_version: &'static str,
} }
#[derive(Debug, Template)]
#[template(path = "telegram_login.html")]
struct TelegramLoginTemplate {
t: &'static Translations,
}
async fn configs_page( async fn configs_page(
session: Session, session: Session,
db: Database, db: Database,
i18n: I18n, i18n: I18n,
) -> cot::Result<cot::response::Response> { ) -> cot::Result<cot::response::Response> {
let user = match auth::require_user_or_redirect(&session, &db).await { let user = match auth::get_session_user(&session, &db).await {
Ok(user) => user, Some(user) => user,
Err(response) => return Ok(response), None => {
return Html::new(TelegramLoginTemplate { t: i18n.t }.render()?).into_response();
}
}; };
let is_admin = user.role == auth::Role::Admin; let is_admin = user.role == auth::Role::Admin;
+24 -2
View File
@@ -461,12 +461,15 @@ function clientPortal() {
telegramModal: null, telegramModal: null,
telegramBusy: false, telegramBusy: false,
telegramPromptChecked: false, telegramPromptChecked: false,
telegramStatusTimer: null,
telegramStatusPolling: false,
init() { init() {
this.initTelegramWebApp(); this.initTelegramWebApp();
this.load(); this.load();
this.loadServerStatus(); this.loadServerStatus();
this.loadTelegramStatus(); this.loadTelegramStatus();
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000); this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
this.telegramStatusTimer = setInterval(() => this.pollTelegramStatus(), 2500);
}, },
async request(url, options = {}) { async request(url, options = {}) {
const response = await fetch(url, { const response = await fetch(url, {
@@ -517,11 +520,11 @@ function clientPortal() {
this.telegram.isWebApp = Boolean(webApp.initData); this.telegram.isWebApp = Boolean(webApp.initData);
this.telegram.initData = webApp.initData || ''; this.telegram.initData = webApp.initData || '';
}, },
async loadTelegramStatus() { async loadTelegramStatus(options = {}) {
try { try {
const status = await this.request('/api/telegram-link/status'); const status = await this.request('/api/telegram-link/status');
this.applyTelegramStatus(status); this.applyTelegramStatus(status);
this.maybePromptTelegram(); if (options.prompt !== false) this.maybePromptTelegram();
} catch (e) { } catch (e) {
console.warn('telegram status failed', e); console.warn('telegram status failed', e);
} }
@@ -596,6 +599,25 @@ function clientPortal() {
this.telegramBusy = false; this.telegramBusy = false;
} }
}, },
async pollTelegramStatus() {
if (this.telegramStatusPolling || this.telegramBusy) return;
if (!this.telegram.enabled || !this.telegram.pending) return;
if (this.telegramModal !== 'manualGuide' && this.telegramModal !== 'manage') return;
this.telegramStatusPolling = true;
const wasPending = this.telegram.pending;
try {
await this.loadTelegramStatus({ prompt: false });
if (wasPending && this.telegram.linked) {
this.telegramModal = 'manage';
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
} else if (wasPending && !this.telegram.pending) {
this.telegramModal = 'manage';
}
} finally {
this.telegramStatusPolling = false;
}
},
async copyTelegramSecret() { async copyTelegramSecret() {
if (!this.telegram.pending_secret) return; if (!this.telegram.pending_secret) return;
this.clearNotice(); this.clearNotice();
+50 -13
View File
@@ -41,7 +41,7 @@
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; } button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
button.danger { background: #9d2323; border-color: #9d2323; } button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; } button:disabled { opacity: .55; cursor: default; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; } .config-table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; } th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; } th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
@@ -122,15 +122,30 @@
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; } .secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
@media (max-width: 760px) { @media (max-width: 760px) {
.shell { grid-template-columns: 1fr; } .shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; } .sidebar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; overflow-x: visible; }
.sidebar h1 { margin: 0; white-space: nowrap; } .sidebar h1 { margin: 0; white-space: nowrap; }
.sidebar a { margin: 0; white-space: nowrap; } .sidebar a { margin: 0; white-space: nowrap; }
.toolbar { align-items: stretch; flex-direction: column; } .toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; } .actions { align-items: stretch; }
.actions input, .actions button { width: 100%; } .actions input, .actions button { width: 100%; }
.main { padding: 1rem; } .main { padding: 1rem; }
table { display: block; overflow-x: auto; } .config-table { display: block; border: 0; border-radius: 0; background: transparent; overflow: visible; }
.row-actions { align-items: stretch; } .config-table thead { display: none; }
.config-table tbody { display: grid; gap: .65rem; margin-bottom: .75rem; }
.config-table tr { display: grid; min-width: 0; border: 1px solid #dde2e6; border-radius: 8px; background: #fff; overflow: hidden; box-shadow: 0 4px 16px rgba(23, 32, 42, .05); }
.config-table tr.owner-row { border: 0; border-radius: 0; background: transparent; box-shadow: none; margin: .2rem 0 -.2rem; }
.config-table tr.owner-row td { display: flex; align-items: center; gap: .25rem; border: 0; padding: .15rem .1rem; background: transparent; color: #34414f; }
.config-table tr.owner-row td::before { display: none; }
.config-table td { display: grid; grid-template-columns: minmax(84px, .4fr) minmax(0, 1fr); gap: .55rem; align-items: center; min-width: 0; padding: .55rem .65rem; }
.config-table td::before { content: attr(data-label); min-width: 0; color: #53606d; font-size: .78rem; font-weight: 750; }
.config-table td:last-child { border-bottom: 0; }
.config-table code { max-width: 100%; white-space: normal; overflow-wrap: anywhere; }
.client-name-cell { display: block !important; padding: .7rem .65rem .55rem !important; color: #1d252d; font-size: 1rem; font-weight: 800; overflow-wrap: anywhere; }
.client-name-cell::before, .client-actions-cell::before { display: none; }
.client-actions-cell { display: block !important; padding: .65rem !important; }
.row-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; width: 100%; }
.row-actions button { width: 100%; min-width: 0; white-space: normal; }
.row-actions button:first-child { grid-column: 1 / -1; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); } .servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card-actions { grid-template-columns: 1fr; } .server-card-actions { grid-template-columns: 1fr; }
.server-card-actions button { flex: 1 1 100%; } .server-card-actions button { flex: 1 1 100%; }
@@ -232,7 +247,7 @@
</template> </template>
<template x-if="clients.length > 0"> <template x-if="clients.length > 0">
<table> <table class="config-table">
<thead> <thead>
<tr> <tr>
<th>{{ t.configs_name }}</th> <th>{{ t.configs_name }}</th>
@@ -257,14 +272,14 @@
{% endif %} {% endif %}
<template x-for="client in group.clients" :key="client.id"> <template x-for="client in group.clients" :key="client.id">
<tr> <tr>
<td x-text="client.name"></td> <td class="client-name-cell" data-label="{{ t.configs_name }}" x-text="client.name"></td>
{% if is_admin %} {% if is_admin %}
<td x-text="ownerLabel(client)"></td> <td data-label="{{ t.configs_owner }}" x-text="ownerLabel(client)"></td>
{% endif %} {% endif %}
<td><code x-text="client.address + '/32'"></code></td> <td data-label="{{ t.configs_address }}"><code x-text="client.address + '/32'"></code></td>
<td><code x-text="shortKey(client.public_key)"></code></td> <td data-label="{{ t.configs_public_key }}"><code x-text="shortKey(client.public_key)"></code></td>
<td x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td> <td data-label="{{ t.configs_enabled }}" x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td> <td class="client-actions-cell" data-label="{{ t.users_actions }}">
<div class="row-actions"> <div class="row-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button> <button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button> <button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
@@ -480,6 +495,8 @@ function configsPage() {
telegramModal: null, telegramModal: null,
telegramBusy: false, telegramBusy: false,
telegramPromptChecked: false, telegramPromptChecked: false,
telegramStatusTimer: null,
telegramStatusPolling: false,
isAdmin: {% if is_admin %}true{% else %}false{% endif %}, isAdmin: {% if is_admin %}true{% else %}false{% endif %},
init() { init() {
this.initTelegramWebApp(); this.initTelegramWebApp();
@@ -489,6 +506,7 @@ function configsPage() {
this.loadRolloutStatus(); this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000); this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
} }
this.telegramStatusTimer = setInterval(() => this.pollTelegramStatus(), 2500);
}, },
async request(url, options = {}) { async request(url, options = {}) {
const response = await fetch(url, { const response = await fetch(url, {
@@ -516,11 +534,11 @@ function configsPage() {
this.telegram.isWebApp = Boolean(webApp.initData); this.telegram.isWebApp = Boolean(webApp.initData);
this.telegram.initData = webApp.initData || ''; this.telegram.initData = webApp.initData || '';
}, },
async loadTelegramStatus() { async loadTelegramStatus(options = {}) {
try { try {
const status = await this.request('/api/telegram-link/status'); const status = await this.request('/api/telegram-link/status');
this.applyTelegramStatus(status); this.applyTelegramStatus(status);
this.maybePromptTelegram(); if (options.prompt !== false) this.maybePromptTelegram();
} catch (e) { } catch (e) {
console.warn('telegram status failed', e); console.warn('telegram status failed', e);
} }
@@ -595,6 +613,25 @@ function configsPage() {
this.telegramBusy = false; this.telegramBusy = false;
} }
}, },
async pollTelegramStatus() {
if (this.telegramStatusPolling || this.telegramBusy) return;
if (!this.telegram.enabled || !this.telegram.pending) return;
if (this.telegramModal !== 'manualGuide' && this.telegramModal !== 'manage') return;
this.telegramStatusPolling = true;
const wasPending = this.telegram.pending;
try {
await this.loadTelegramStatus({ prompt: false });
if (wasPending && this.telegram.linked) {
this.telegramModal = 'manage';
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
} else if (wasPending && !this.telegram.pending) {
this.telegramModal = 'manage';
}
} finally {
this.telegramStatusPolling = false;
}
},
async copyTelegramSecret() { async copyTelegramSecret() {
if (!this.telegram.pending_secret) return; if (!this.telegram.pending_secret) return;
this.clearNotice(); this.clearNotice();
+35
View File
@@ -3,6 +3,7 @@
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %} {% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %} {% block head_extra %}
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; } body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
@@ -30,6 +31,7 @@
{% if !message.is_empty() %} {% if !message.is_empty() %}
<div class="flash">{{ message }}</div> <div class="flash">{{ message }}</div>
{% endif %} {% endif %}
<div id="telegram-login-flash" class="flash" style="display: none;"></div>
{% if !auth_password_enabled && !auth_sso_enabled %} {% if !auth_password_enabled && !auth_sso_enabled %}
<p class="message">{{ t.login_disabled }}</p> <p class="message">{{ t.login_disabled }}</p>
@@ -53,4 +55,37 @@
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a> <a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
{% endif %} {% endif %}
</div> </div>
<script>
(async () => {
const webApp = window.Telegram && window.Telegram.WebApp;
if (!webApp || !webApp.initData) return;
try {
webApp.ready();
webApp.expand();
} catch (_) {}
try {
const response = await fetch('/api/telegram-login/webapp', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ init_data: webApp.initData }),
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
window.location.replace(data.redirect_to || '/configs');
return;
}
const flash = document.getElementById('telegram-login-flash');
flash.textContent = data.error || data.detail || '{{ t.login_telegram_failed }}';
flash.style.display = 'block';
} catch (_) {
const flash = document.getElementById('telegram-login-flash');
flash.textContent = '{{ t.login_telegram_failed }}';
flash.style.display = 'block';
}
})();
</script>
{% endblock body %} {% endblock body %}
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; padding: 1rem; background: #f5f5f5; color: #333; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.telegram-login-card { width: min(380px, 100%); border-radius: 8px; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2rem; text-align: center; display: grid; gap: .8rem; }
.telegram-login-card h1 { margin: 0; font-size: 1.35rem; }
.telegram-login-card p { margin: 0; color: #666; line-height: 1.45; }
.telegram-login-card a { color: #1a1a2e; font-weight: 700; }
</style>
{% endblock head_extra %}
{% block body %}
<div class="telegram-login-card">
<h1>{{ t.login_heading }}</h1>
<p id="telegram-login-status">{{ t.login_telegram_wait }}</p>
<a href="/login">{{ t.login_telegram_fallback }}</a>
</div>
<script>
(async () => {
const status = document.getElementById('telegram-login-status');
const fallback = () => window.location.replace('/login');
const webApp = window.Telegram && window.Telegram.WebApp;
try {
if (webApp) {
webApp.ready();
webApp.expand();
}
} catch (_) {}
if (!webApp || !webApp.initData) {
fallback();
return;
}
try {
const response = await fetch('/api/telegram-login/webapp', {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ init_data: webApp.initData }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
status.textContent = data.error || data.detail || '{{ t.login_telegram_failed }}';
setTimeout(fallback, 1800);
return;
}
window.location.replace(data.redirect_to || '/configs');
} catch (_) {
status.textContent = '{{ t.login_telegram_failed }}';
setTimeout(fallback, 1800);
}
})();
</script>
{% endblock body %}