This commit is contained in:
+37
-16
@@ -47,6 +47,9 @@
|
||||
.section-tabs button.active { background: #17202a; color: #fff; border-color: #17202a; }
|
||||
.settings-grid { display: grid; grid-template-columns: minmax(180px, .7fr) minmax(240px, 1fr) auto; gap: .55rem .75rem; align-items: center; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .75rem; }
|
||||
.settings-row { display: contents; }
|
||||
.settings-info-row { grid-column: 1 / -1; border: 1px solid #dde2e6; border-left: 4px solid #2d6cdf; border-radius: 6px; background: #f8fafc; padding: .75rem; display: grid; gap: .45rem; }
|
||||
.settings-info-title { color: #1d2733; font-weight: 750; }
|
||||
.settings-info-text { margin: 0; color: #34414f; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; line-height: 1.45; }
|
||||
.field-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
|
||||
.field-help { color: #53606d; font-size: .78rem; margin-top: .15rem; overflow-wrap: anywhere; }
|
||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 650; }
|
||||
@@ -191,21 +194,33 @@
|
||||
<template x-if="settings.fields.length > 0">
|
||||
<div class="settings-grid">
|
||||
<template x-for="field in visibleSettings()" :key="field.key">
|
||||
<div class="settings-row">
|
||||
<div class="field-name">
|
||||
<span x-text="fieldLabel(field.key)"></span>
|
||||
<div class="field-help" x-text="field.env_var"></div>
|
||||
</div>
|
||||
<div>
|
||||
<template x-if="field.kind === 'bool'">
|
||||
<input type="checkbox" x-model="settingsForm[field.key]">
|
||||
</template>
|
||||
<template x-if="field.kind !== 'bool'">
|
||||
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
|
||||
</template>
|
||||
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
|
||||
</div>
|
||||
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
|
||||
<div :class="field.kind === 'info' ? 'settings-info-row' : 'settings-row'">
|
||||
<template x-if="field.kind === 'info'">
|
||||
<div>
|
||||
<div class="settings-info-title" x-text="fieldLabel(field.key)"></div>
|
||||
<pre class="settings-info-text" x-text="field.value"></pre>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="field.kind !== 'info'">
|
||||
<div class="field-name">
|
||||
<span x-text="fieldLabel(field.key)"></span>
|
||||
<div class="field-help" x-text="field.env_var"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="field.kind !== 'info'">
|
||||
<div>
|
||||
<template x-if="field.kind === 'bool'">
|
||||
<input type="checkbox" x-model="settingsForm[field.key]">
|
||||
</template>
|
||||
<template x-if="field.kind !== 'bool'">
|
||||
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
|
||||
</template>
|
||||
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="field.kind !== 'info'">
|
||||
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -316,7 +331,7 @@ function adminApp(initialView) {
|
||||
settings: { fields: [] },
|
||||
settingsForm: {},
|
||||
settingsSection: 'auth',
|
||||
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api'],
|
||||
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api', 'telegram'],
|
||||
debug: null,
|
||||
userModalOpen: false,
|
||||
userForm: {},
|
||||
@@ -401,6 +416,7 @@ function adminApp(initialView) {
|
||||
this.settings = await this.request('/admin/api/settings');
|
||||
const form = {};
|
||||
for (const field of this.settings.fields) {
|
||||
if (field.kind === 'info') continue;
|
||||
form[field.key] = field.kind === 'bool' ? field.value === 'true' : field.value;
|
||||
}
|
||||
this.settingsForm = form;
|
||||
@@ -491,6 +507,7 @@ function adminApp(initialView) {
|
||||
kubernetes: '{{ t.settings_kubernetes }}',
|
||||
vpn: '{{ t.settings_vpn }}',
|
||||
api: '{{ t.settings_api }}',
|
||||
telegram: '{{ t.settings_telegram }}',
|
||||
}[section] || section;
|
||||
},
|
||||
fieldLabel(key) {
|
||||
@@ -501,6 +518,10 @@ function adminApp(initialView) {
|
||||
oidc_admin_groups: '{{ t.settings_oidc_admin_groups }}',
|
||||
oidc_client_groups: '{{ t.settings_oidc_client_groups }}',
|
||||
swagger_enabled: '{{ t.settings_swagger }}',
|
||||
telegram_bot_enabled: '{{ t.settings_telegram_enabled }}',
|
||||
telegram_bot_username: '{{ t.settings_telegram_username }}',
|
||||
telegram_bot_token: '{{ t.settings_telegram_token }}',
|
||||
telegram_setup_instructions: '{{ t.settings_telegram_setup_instructions }}',
|
||||
}[key] || key;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block title %}{{ t.configs_portal_heading }} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
@@ -105,6 +106,11 @@
|
||||
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #d6dfda; border-radius: 8px; background: #fff; }
|
||||
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: .5rem; flex-wrap: wrap; }
|
||||
.telegram-sheet { width: min(480px, 100%); }
|
||||
.telegram-copy { color: #53616c; line-height: 1.45; margin: 0; }
|
||||
.guide-list { margin: 0; padding-left: 1.2rem; color: #34424b; display: grid; gap: .45rem; line-height: 1.4; }
|
||||
.guide-list a { color: #18342f; font-weight: 850; }
|
||||
.guide-list a.disabled { color: #69777f; pointer-events: none; text-decoration: none; }
|
||||
@media (max-width: 720px) {
|
||||
.topbar-inner { align-items: flex-start; flex-direction: column; }
|
||||
.top-actions { width: 100%; justify-content: space-between; }
|
||||
@@ -324,6 +330,56 @@
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="telegramModal">
|
||||
<div class="backdrop" x-cloak>
|
||||
<section class="sheet telegram-sheet">
|
||||
<div class="sheet-head">
|
||||
<div class="sheet-title">
|
||||
<h2>{{ t.telegram_link_title }}</h2>
|
||||
<span x-text="telegramBotLabel()"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="telegramModal === 'webapp'">
|
||||
<div>
|
||||
<p class="telegram-copy">{{ t.telegram_webapp_message }}</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
||||
<button type="button" @click="linkTelegramWebApp()" :disabled="telegramBusy">{{ t.telegram_save }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="telegramModal === 'manualPrompt'">
|
||||
<div>
|
||||
<p class="telegram-copy">{{ t.telegram_manual_message }}</p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
||||
<button type="button" @click="telegramModal = 'manualGuide'">{{ t.telegram_connect }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="telegramModal === 'manualGuide'">
|
||||
<div class="field">
|
||||
<p class="eyebrow">{{ t.telegram_guide_title }}</p>
|
||||
<ol class="guide-list">
|
||||
<li>{{ t.telegram_guide_start }} <a :href="telegramBotUrl()" target="_blank" rel="noreferrer" :class="{ disabled: !telegram.bot_url }">{{ t.telegram_open_bot }}</a></li>
|
||||
<li>{{ t.telegram_guide_get_id }}</li>
|
||||
<li>{{ t.telegram_guide_paste }}</li>
|
||||
</ol>
|
||||
<label for="telegram-id">{{ t.telegram_id_label }}</label>
|
||||
<input id="telegram-id" x-model="telegramManualId" inputmode="numeric" pattern="[0-9]*" placeholder="{{ t.telegram_id_placeholder }}" @keydown.enter.prevent="saveTelegramManual()">
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="secondary" @click="declineTelegramLink()" :disabled="telegramBusy">{{ t.telegram_skip }}</button>
|
||||
<button type="button" @click="saveTelegramManual()" :disabled="telegramBusy || !telegramManualId.trim()">{{ t.telegram_save }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -342,9 +398,25 @@ function clientPortal() {
|
||||
serverConfigs: {},
|
||||
serverModal: null,
|
||||
qrModal: null,
|
||||
telegram: {
|
||||
enabled: false,
|
||||
bot_username: '',
|
||||
bot_url: '',
|
||||
linked: false,
|
||||
declined: false,
|
||||
telegram_id: null,
|
||||
isWebApp: false,
|
||||
initData: '',
|
||||
},
|
||||
telegramModal: null,
|
||||
telegramManualId: '',
|
||||
telegramBusy: false,
|
||||
telegramPromptChecked: false,
|
||||
init() {
|
||||
this.initTelegramWebApp();
|
||||
this.load();
|
||||
this.loadServerStatus();
|
||||
this.loadTelegramStatus();
|
||||
this.serverStatusTimer = setInterval(() => this.loadServerStatus(true), 30000);
|
||||
},
|
||||
async request(url, options = {}) {
|
||||
@@ -386,6 +458,97 @@ function clientPortal() {
|
||||
this.serverStatusBusy = false;
|
||||
}
|
||||
},
|
||||
initTelegramWebApp() {
|
||||
const webApp = window.Telegram && window.Telegram.WebApp;
|
||||
if (!webApp) return;
|
||||
try {
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
} catch (_) {}
|
||||
this.telegram.isWebApp = Boolean(webApp.initData);
|
||||
this.telegram.initData = webApp.initData || '';
|
||||
},
|
||||
async loadTelegramStatus() {
|
||||
try {
|
||||
const status = await this.request('/api/telegram-link/status');
|
||||
this.applyTelegramStatus(status);
|
||||
this.maybePromptTelegram();
|
||||
} catch (e) {
|
||||
console.warn('telegram status failed', e);
|
||||
}
|
||||
},
|
||||
applyTelegramStatus(status) {
|
||||
this.telegram = {
|
||||
...this.telegram,
|
||||
enabled: Boolean(status.enabled),
|
||||
bot_username: status.bot_username || '',
|
||||
bot_url: status.bot_url || '',
|
||||
linked: Boolean(status.linked),
|
||||
declined: Boolean(status.declined),
|
||||
telegram_id: status.telegram_id || null,
|
||||
};
|
||||
},
|
||||
maybePromptTelegram() {
|
||||
if (this.telegramPromptChecked) return;
|
||||
this.telegramPromptChecked = true;
|
||||
if (!this.telegram.enabled || this.telegram.linked || this.telegram.declined) return;
|
||||
this.telegramModal = (this.telegram.isWebApp && this.telegram.initData) ? 'webapp' : 'manualPrompt';
|
||||
},
|
||||
async linkTelegramWebApp() {
|
||||
this.telegramBusy = true;
|
||||
this.clearNotice();
|
||||
try {
|
||||
const data = await this.request('/api/telegram-link/webapp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ init_data: this.telegram.initData }),
|
||||
});
|
||||
this.applyTelegramStatus(data.status || {});
|
||||
this.telegramModal = null;
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
async saveTelegramManual() {
|
||||
this.telegramBusy = true;
|
||||
this.clearNotice();
|
||||
try {
|
||||
const data = await this.request('/api/telegram-link/manual', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ telegram_id: this.telegramManualId }),
|
||||
});
|
||||
this.applyTelegramStatus(data.status || {});
|
||||
this.telegramManualId = '';
|
||||
this.telegramModal = null;
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_saved }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
async declineTelegramLink() {
|
||||
this.telegramBusy = true;
|
||||
this.clearNotice();
|
||||
try {
|
||||
const data = await this.request('/api/telegram-link/decline', { method: 'POST' });
|
||||
this.applyTelegramStatus(data.status || {});
|
||||
this.telegramModal = null;
|
||||
this.setNotice('success', '{{ t.notice_success_title }}', '{{ t.telegram_declined }}');
|
||||
} catch (e) {
|
||||
this.showError(e);
|
||||
} finally {
|
||||
this.telegramBusy = false;
|
||||
}
|
||||
},
|
||||
telegramBotUrl() {
|
||||
return this.telegram.bot_url || '#';
|
||||
},
|
||||
telegramBotLabel() {
|
||||
return this.telegram.bot_username ? `@${this.telegram.bot_username}` : '{{ t.telegram_bot_unconfigured }}';
|
||||
},
|
||||
async createClient() {
|
||||
this.clearNotice();
|
||||
this.busy = true;
|
||||
|
||||
Reference in New Issue
Block a user