feat(api,userbot,frontend): add accounts from the web ui and isolate per-account settings

This commit is contained in:
hh committed 2026-08-05 23:49:52 +02:00
1 parent 92fd20137e
commit 9c265af3d3
19 files changed
+917 -268

No files matched your search

@@ -0,0 +1,169 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { ApiError } from "$lib/api/client";
import {
cancelLogin,
startLogin,
submitLoginCode,
submitLoginPassword,
} from "$lib/api/endpoints";
import type { LoginState } from "$lib/api/types";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
type Step = "phone" | "code" | "password";
let { open = $bindable(false) }: { open?: boolean } = $props();
let step = $state<Step>("phone");
let loginId = $state("");
let phone = $state("");
let code = $state("");
let password = $state("");
let busy = $state(false);
const hints: Record<Step, string> = {
phone: "Номер телефона в международном формате, например +79991234567.",
code: "Код отправлен в Telegram на этот номер.",
password: "Аккаунт защищён двухэтапной аутентификацией.",
};
const filled = $derived.by(() => {
if (step === "phone") {
return phone.trim().length > 0;
}
if (step === "code") {
return code.trim().length > 0;
}
return password.length > 0;
});
function reset() {
step = "phone";
loginId = "";
phone = "";
code = "";
password = "";
}
function next(): Promise<LoginState> {
if (step === "phone") {
return startLogin(phone.trim());
}
if (step === "code") {
return submitLoginCode(loginId, code.trim());
}
return submitLoginPassword(loginId, password);
}
async function apply(state: LoginState) {
if (state.stage !== "done") {
loginId = state.login_id;
step = state.stage;
return;
}
loginId = "";
await accounts.load();
if (state.account) {
accounts.select(state.account.account_id);
}
toasts.success("Аккаунт добавлен");
open = false;
}
async function submit(event: SubmitEvent) {
event.preventDefault();
if (busy || !filled) {
return;
}
busy = true;
try {
await apply(await next());
} catch (error) {
toasts.error(
error instanceof ApiError ? error.detail : "Не удалось войти"
);
} finally {
busy = false;
}
}
function onOpenChange(value: boolean) {
if (!value && loginId) {
cancelLogin(loginId).catch(() => undefined);
}
reset();
}
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay class="dialog-overlay" />
<Dialog.Content class="dialog-content">
<header class="dialog-head">
<Dialog.Title class="dialog-title">Добавить аккаунт</Dialog.Title>
<Dialog.Close class="dialog-close" aria-label="Закрыть">
<Icon name="close" size="1.25rem" />
</Dialog.Close>
</header>
<form onsubmit={submit}>
<div class="dialog-body">
<p class="hint">{hints[step]}</p>
{#if step === "phone"}
<div class="input-group">
<input
id="login-phone"
class="form-control"
type="tel"
autocomplete="tel"
placeholder="+7 999 123-45-67"
bind:value={phone}
>
<label for="login-phone">Номер телефона</label>
</div>
{:else if step === "code"}
<div class="input-group">
<input
id="login-code"
class="form-control"
type="text"
inputmode="numeric"
autocomplete="one-time-code"
placeholder="12345"
bind:value={code}
>
<label for="login-code">Код подтверждения</label>
</div>
{:else}
<div class="input-group">
<input
id="login-password"
class="form-control"
type="password"
autocomplete="current-password"
placeholder="Пароль"
bind:value={password}
>
<label for="login-password">Облачный пароль</label>
</div>
{/if}
</div>
<div class="dialog-actions">
<Button type="submit" pill loading={busy} disabled={busy || !filled}>
{step === "phone" ? "Отправить код" : "Продолжить"}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.hint {
margin: 0 0 1.5rem;
font-size: 0.9375rem;
color: var(--color-text-secondary);
}
</style>