Files
homelab/userbot/panel/frontend/src/components/AddAccountDrawer.svelte
T
forust 71cddd6a91 feat(userbot): add Kubernetes control panel
Manage Telegram instances through Kubernetes with legacy adoption for forust and anna. Build and deploy the panel image alongside the runtime.
2026-09-06 20:39:12 +02:00

355 lines
12 KiB
Svelte

<script lang="ts">
import { ArrowLeft, Check, Eye, EyeOff, LoaderCircle, X } from 'lucide-svelte';
import { createEventDispatcher } from 'svelte';
import { api } from '../lib/api';
import type { AccountDraft, Instance } from '../lib/types';
export let open = false;
type Method = 'phone' | 'session';
type AuthPhase = 'code' | 'password';
const emptyDraft = (): AccountDraft => ({
instance_id: '',
display_name: '',
api_id: '',
api_hash: '',
phone: '',
session_string: '',
resources: {
storage: '1Gi',
cpu_limit: '300m',
memory_limit: '1536Mi',
},
});
let draft = emptyDraft();
let step = 1;
let method: Method = 'phone';
let authPhase: AuthPhase = 'code';
let flowId = '';
let code = '';
let password = '';
let error = '';
let busy = false;
let showHash = false;
let showPassword = false;
const dispatch = createEventDispatcher<{ close: void; created: Instance }>();
function reset() {
draft = emptyDraft();
step = 1;
method = 'phone';
authPhase = 'code';
flowId = '';
code = '';
password = '';
error = '';
busy = false;
showHash = false;
showPassword = false;
}
function close() {
reset();
dispatch('close');
}
function validSettings() {
return (
/^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/.test(draft.instance_id) &&
draft.display_name.trim().length > 0 &&
Number(draft.api_id) > 0 &&
draft.api_hash.trim().length >= 16 &&
(method === 'phone'
? /^\+\d[\d\s()-]{6,}$/.test(draft.phone)
: draft.session_string.trim().length >= 32)
);
}
async function waitUntilReady(initial: Instance) {
let instance = initial;
for (let attempt = 0; attempt < 60 && instance.status === 'pending'; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 2000));
instance = await api.instance(initial.instance_id);
if (instance.status === 'error') {
throw new Error(`Pod не запустился: ${instance.reason || 'неизвестная ошибка'}`);
}
}
return instance;
}
async function finishProvisioning(instance: Instance) {
step = 3;
const readyInstance = await waitUntilReady(instance);
dispatch('created', readyInstance);
close();
}
async function begin() {
error = '';
if (!validSettings()) {
error = 'Заполните обязательные поля и проверьте формат значений.';
return;
}
busy = true;
try {
if (method === 'session') {
step = 3;
const result = await api.stringSession(draft);
if (result.instance) {
await finishProvisioning(result.instance);
}
return;
}
const result = await api.startPhone(draft);
flowId = result.flow_id || '';
authPhase = 'code';
step = 2;
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось начать авторизацию';
step = 1;
} finally {
busy = false;
}
}
async function submitCode() {
if (!flowId || !code.trim()) return;
error = '';
busy = true;
try {
const result = await api.submitCode(flowId, code);
if (result.status === 'password_required') {
authPhase = 'password';
} else if (result.instance) {
await finishProvisioning(result.instance);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось проверить код';
} finally {
busy = false;
}
}
async function submitPassword() {
if (!flowId || !password) return;
error = '';
busy = true;
try {
const result = await api.submitPassword(flowId, password);
if (result.instance) {
await finishProvisioning(result.instance);
}
} catch (cause) {
error = cause instanceof Error ? cause.message : 'Не удалось проверить пароль';
} finally {
busy = false;
}
}
</script>
{#if open}
<div class="drawer-backdrop" on:click={close} aria-hidden="true"></div>
<aside class="drawer add-drawer" aria-label="Добавить аккаунт">
<header class="drawer-header">
<div>
<h2>Добавить аккаунт</h2>
<p>Новый Kubernetes-инстанс userbot</p>
</div>
<button class="icon-button borderless" aria-label="Закрыть" on:click={close}>
<X size={19} />
</button>
</header>
<ol class="steps" aria-label="Шаги создания">
<li class:active={step === 1} class:complete={step > 1}><span>1</span>Настройки</li>
<li class:active={step === 2} class:complete={step > 2}><span>2</span>Авторизация</li>
<li class:active={step === 3}><span>3</span>Создание</li>
</ol>
{#if step === 1}
<form class="drawer-form" on:submit|preventDefault={begin}>
<fieldset>
<legend>Инстанс</legend>
<label>
Имя
<input
bind:value={draft.instance_id}
placeholder="personal"
maxlength="40"
autocomplete="off"
required
/>
<small>Только a-z, 0-9 и дефис</small>
</label>
<label>
Отображаемое имя
<input bind:value={draft.display_name} placeholder="Personal" maxlength="80" required />
</label>
</fieldset>
<fieldset>
<legend>Telegram API</legend>
<div class="two-columns">
<label>
API ID
<input bind:value={draft.api_id} inputmode="numeric" placeholder="12345678" required />
</label>
<label>
API Hash
<span class="input-with-action">
<input
bind:value={draft.api_hash}
type={showHash ? 'text' : 'password'}
placeholder="0123456789abcdef"
required
/>
<button
type="button"
aria-label={showHash ? 'Скрыть API Hash' : 'Показать API Hash'}
on:click={() => (showHash = !showHash)}
>
{#if showHash}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
</button>
</span>
</label>
</div>
</fieldset>
<fieldset>
<legend>Авторизация</legend>
<div class="segmented">
<button type="button" class:active={method === 'phone'} on:click={() => (method = 'phone')}>
По номеру
</button>
<button type="button" class:active={method === 'session'} on:click={() => (method = 'session')}>
StringSession
</button>
</div>
{#if method === 'phone'}
<label>
Номер телефона
<input bind:value={draft.phone} type="tel" placeholder="+421 900 123 456" required />
<small>Код придёт в Telegram. При включённой 2FA потребуется пароль.</small>
</label>
{:else}
<label>
StringSession
<textarea
bind:value={draft.session_string}
rows="3"
placeholder="Вставьте Pyrogram/Pyrofork StringSession"
required
></textarea>
</label>
{/if}
</fieldset>
<fieldset>
<legend>Ресурсы</legend>
<div class="three-columns">
<label>
Хранилище
<select bind:value={draft.resources.storage}>
<option value="1Gi">1 GiB</option>
<option value="2Gi">2 GiB</option>
<option value="5Gi">5 GiB</option>
</select>
</label>
<label>
CPU
<select bind:value={draft.resources.cpu_limit}>
<option value="200m">200m</option>
<option value="300m">300m</option>
<option value="500m">500m</option>
</select>
</label>
<label>
RAM
<select bind:value={draft.resources.memory_limit}>
<option value="1024Mi">1 GiB</option>
<option value="1536Mi">1.5 GiB</option>
<option value="2048Mi">2 GiB</option>
</select>
</label>
</div>
</fieldset>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<footer class="form-actions">
<button type="button" on:click={close}>Отмена</button>
<button class="primary" type="submit" disabled={busy}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
{method === 'phone' ? 'Запросить код' : 'Создать инстанс'}
</button>
</footer>
</form>
{:else if step === 2}
<div class="auth-step">
<button class="back-link" on:click={() => (step = 1)}>
<ArrowLeft size={15} />Назад к настройкам
</button>
{#if authPhase === 'code'}
<h3>Введите код</h3>
<p>Код отправлен в Telegram на {draft.phone}.</p>
<form on:submit|preventDefault={submitCode}>
<label>
Код Telegram
<input
class="code-input"
bind:value={code}
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
/>
</label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="primary full" type="submit" disabled={busy || !code.trim()}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
Авторизовать
</button>
</form>
{:else}
<h3>Пароль 2FA</h3>
<p>Для этого аккаунта включена двухэтапная аутентификация.</p>
<form on:submit|preventDefault={submitPassword}>
<label>
Пароль
<span class="input-with-action">
<input
bind:value={password}
type={showPassword ? 'text' : 'password'}
autocomplete="current-password"
/>
<button
type="button"
aria-label={showPassword ? 'Скрыть пароль' : 'Показать пароль'}
on:click={() => (showPassword = !showPassword)}
>
{#if showPassword}<EyeOff size={15} />{:else}<Eye size={15} />{/if}
</button>
</span>
</label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="primary full" type="submit" disabled={busy || !password}>
{#if busy}<LoaderCircle class="spin" size={16} />{/if}
Продолжить
</button>
</form>
{/if}
</div>
{:else}
<div class="creating-state">
<LoaderCircle class="spin" size={28} />
<h3>Создаём инстанс</h3>
<p>Secret, PVC и Deployment созданы. Ждём Ready в namespace userbot.</p>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
</div>
{/if}
</aside>
{/if}