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

This commit is contained in:
hh
2026-08-05 23:49:52 +02:00
parent 92fd20137e
commit 9c265af3d3
19 changed files with 917 additions and 268 deletions
+37
View File
@@ -14,6 +14,7 @@ import type {
JobStatus,
JobView,
LinkView,
LoginState,
MediaVersion,
MediaView,
MessageAt,
@@ -45,6 +46,41 @@ export function listAccounts(): Promise<Account[]> {
return request<Account[]>("/accounts");
}
export function startLogin(phone: string): Promise<LoginState> {
return request<LoginState>("/accounts/login", {
method: "POST",
body: { phone },
});
}
export function submitLoginCode(
loginId: string,
code: string
): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/code`, {
method: "POST",
body: { code },
});
}
export function submitLoginPassword(
loginId: string,
password: string
): Promise<LoginState> {
return request<LoginState>(`/accounts/login/${loginId}/password`, {
method: "POST",
body: { password },
});
}
export function cancelLogin(loginId: string): Promise<void> {
return request<void>(`/accounts/login/${loginId}`, { method: "DELETE" });
}
export function logoutAccount(accountId: number): Promise<void> {
return request<void>(`/accounts/${accountId}`, { method: "DELETE" });
}
export function listChats(page: Page = {}): Promise<Chat[]> {
return request<Chat[]>("/chats", { account: true, query: { ...page } });
}
@@ -70,6 +106,7 @@ export function updatePolicy(
): Promise<PolicyRecord> {
return request<PolicyRecord>(`/policy/${id}`, {
method: "PUT",
account: true,
body: toggles,
});
}
+8
View File
@@ -22,6 +22,14 @@ export interface Account {
tg_user_id: number | null;
}
export type LoginStage = "code" | "password" | "done";
export interface LoginState {
account: Account | null;
login_id: string;
stage: LoginStage;
}
export interface Chat {
chat_id: number;
has_avatar: boolean;
@@ -1,87 +0,0 @@
<script lang="ts">
import { DropdownMenu } from "bits-ui";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
const current = $derived(accounts.selected);
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="account-trigger">
{#if current}
<Avatar
name={accountName(current)}
colorKey={current.account_id}
size={2.25}
/>
<span class="account-name">{accountName(current)}</span>
{:else}
<span class="account-name">No account</span>
{/if}
<Icon name="down" size="1.25rem" />
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content class="bg-menu-content" sideOffset={6} align="start">
{#each accounts.list as account (account.account_id)}
<DropdownMenu.Item
class="bg-menu-item"
data-selected={account.account_id === accounts.selectedId
? ""
: undefined}
onSelect={() => accounts.select(account.account_id)}
>
<Avatar
name={accountName(account)}
colorKey={account.account_id}
size={1.75}
/>
<span>{accountName(account)}</span>
{#if account.account_id === accounts.selectedId}
<Icon name="check" size="1.125rem" class="trailing" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
<style lang="scss">
:global(.account-trigger) {
cursor: pointer;
display: flex;
flex: 1;
align-items: center;
gap: 0.625rem;
min-width: 0;
padding: 0.375rem 0.5rem;
border: 0;
border-radius: 0.625rem;
color: var(--color-text);
background-color: transparent;
transition: background-color 0.15s ease;
&:hover {
background-color: var(--color-chat-hover);
}
}
.account-name {
overflow: hidden;
flex: 1;
font-size: 1rem;
font-weight: var(--font-weight-medium);
text-align: start;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.bg-menu-item .trailing) {
margin-inline-start: auto;
color: var(--color-primary);
}
</style>
@@ -85,61 +85,6 @@
</Dialog.Root>
<style lang="scss">
:global(.dialog-overlay) {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background-color: rgba(0, 0, 0, 0.5);
}
:global(.dialog-content) {
position: fixed;
z-index: var(--z-modal);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
width: min(32rem, 92vw);
max-height: 80vh;
border-radius: var(--border-radius-default);
background-color: var(--color-background);
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
outline: none;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-borders);
}
:global(.dialog-title) {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-medium);
}
:global(.dialog-close) {
cursor: pointer;
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.versions {
overflow-y: auto;
padding: 0.75rem 1.25rem 1.25rem;
@@ -3,6 +3,7 @@
import type { JobStatus, JobView } from "$lib/api/types";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { formatFull } from "$lib/format/datetime";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
interface Props {
@@ -93,7 +94,7 @@
}
$effect(() => {
if (version >= 0) {
if (version >= 0 && accounts.selectedId !== null) {
load().catch(() => {
loading = false;
});
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from "svelte";
import {
createPolicy,
deletePolicy,
@@ -16,6 +15,7 @@
import CaptureToggleList from "$lib/components/policy/CaptureToggleList.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import Spinner from "$lib/components/ui/Spinner.svelte";
import { accounts } from "$lib/stores/accounts.svelte";
import { chats } from "$lib/stores/chats.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
@@ -110,7 +110,8 @@
p.id === record.id ? { ...p, [key]: value } : p
);
try {
await updatePolicy(record.id, next);
const saved = await updatePolicy(record.id, next);
policies = policies.map((p) => (p.id === record.id ? saved : p));
} catch {
toasts.error("Не удалось сохранить политику");
await reload();
@@ -143,7 +144,8 @@
}
}
onMount(async () => {
async function load(_account: number | null) {
loading = true;
chats.load();
try {
await reload();
@@ -152,6 +154,10 @@
} finally {
loading = false;
}
}
$effect(() => {
load(accounts.selectedId);
});
</script>
@@ -159,7 +165,10 @@
<div class="card">
<div class="card-head">
<span class="card-title">{title}</span>
{#if onremove}
{#if record.account_id === null}
<span class="shared">для всех аккаунтов</span>
{/if}
{#if onremove && record.account_id !== null}
<button
type="button"
class="remove"
@@ -303,6 +312,11 @@
font-weight: var(--font-weight-medium);
}
.shared {
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.remove {
cursor: pointer;
display: flex;
@@ -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>
@@ -1,9 +1,14 @@
<script lang="ts">
import { Dialog } from "bits-ui";
import { ripple } from "$lib/actions/ripple";
import AddAccountDialog from "$lib/components/settings/AddAccountDialog.svelte";
import SettingsItem from "$lib/components/settings/SettingsItem.svelte";
import Avatar from "$lib/components/ui/Avatar.svelte";
import Button from "$lib/components/ui/Button.svelte";
import Icon from "$lib/components/ui/Icon.svelte";
import { accountName } from "$lib/format/peer";
import { accounts } from "$lib/stores/accounts.svelte";
import { toasts } from "$lib/stores/toasts.svelte";
const current = $derived(accounts.selected);
const others = $derived(
@@ -11,6 +16,26 @@
(account) => account.account_id !== accounts.selectedId
)
);
let adding = $state(false);
let confirming = $state(false);
let busy = $state(false);
async function logout() {
if (!current || busy) {
return;
}
busy = true;
try {
await accounts.logout(current.account_id);
confirming = false;
toasts.success("Сессия завершена");
} catch {
toasts.error("Не удалось выйти из аккаунта");
} finally {
busy = false;
}
}
</script>
<div class="my-account">
@@ -25,6 +50,9 @@
{#if current.phone}
<div class="phone">+{current.phone}</div>
{/if}
{#if !current.is_active}
<div class="inactive">Сессия завершена, доступен только архив</div>
{/if}
</div>
{/if}
@@ -43,13 +71,72 @@
size={2.25}
/>
<span>{accountName(account)}</span>
{#if !account.is_active}
<span class="tag">архив</span>
{/if}
<Icon name="arrow-right" size="1rem" class="chevron" />
</button>
{/each}
</div>
{/if}
<div class="actions">
<SettingsItem
icon="add-user"
label="Добавить аккаунт"
onclick={() => {
adding = true;
}}
/>
{#if current?.is_active}
<SettingsItem
icon="logout"
label="Выйти из аккаунта"
onclick={() => {
confirming = true;
}}
/>
{/if}
</div>
</div>
<AddAccountDialog bind:open={adding} />
<Dialog.Root bind:open={confirming}>
<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>
<div class="dialog-body">
<p class="confirm-text">
Сессия {current ? accountName(current) : ""} будет завершена в
Telegram, новые сообщения перестанут собираться. Уже собранный архив
останется доступным.
</p>
</div>
<div class="dialog-actions">
<Button
variant="text"
pill
onclick={() => {
confirming = false;
}}
>
Отмена
</Button>
<Button variant="danger" pill loading={busy} onclick={logout}>
Выйти
</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style lang="scss">
.profile {
display: flex;
@@ -71,6 +158,11 @@
color: var(--color-text-secondary);
}
.inactive {
font-size: 0.8125rem;
color: var(--color-error);
}
.switch-row {
cursor: pointer;
position: relative;
@@ -99,6 +191,22 @@
}
}
.tag {
flex: 0 0 auto !important;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.actions {
padding: 0.5rem 0;
border-top: 1px solid var(--color-borders);
}
.confirm-text {
margin: 0;
color: var(--color-text-secondary);
}
:global(.switch-row .chevron) {
flex-shrink: 0;
color: var(--color-icon-secondary);
+24 -14
View File
@@ -1,5 +1,5 @@
import { browser } from "$app/environment";
import { listAccounts } from "$lib/api/endpoints";
import { listAccounts, logoutAccount } from "$lib/api/endpoints";
import type { Account } from "$lib/api/types";
const STORAGE_KEY = "bg.account";
@@ -32,6 +32,22 @@ function createAccounts() {
}
}
function select(id: number | null) {
selectedId = id;
persist(id);
}
async function load() {
list = await listAccounts();
loaded = true;
const exists = list.some((account) => account.account_id === selectedId);
if (!exists) {
const fallback =
list.find((account) => account.is_active) ?? list.at(0) ?? null;
select(fallback ? fallback.account_id : null);
}
}
return {
get list() {
return list;
@@ -45,20 +61,14 @@ function createAccounts() {
get loaded() {
return loaded;
},
async load() {
list = await listAccounts();
loaded = true;
const exists = list.some((account) => account.account_id === selectedId);
if (!exists) {
const fallback =
list.find((account) => account.is_active) ?? list.at(0) ?? null;
selectedId = fallback ? fallback.account_id : null;
persist(selectedId);
load,
select,
async logout(id: number) {
await logoutAccount(id);
if (id === selectedId) {
select(null);
}
},
select(id: number) {
selectedId = id;
persist(id);
await load();
},
};
}
+66
View File
@@ -0,0 +1,66 @@
.dialog-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
background-color: rgba(0, 0, 0, 0.5);
}
.dialog-content {
position: fixed;
z-index: var(--z-modal);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
width: min(32rem, 92vw);
max-height: 80vh;
border-radius: var(--border-radius-default);
background-color: var(--color-background);
box-shadow: 0 0.5rem 2rem var(--color-default-shadow);
outline: none;
}
.dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-borders);
}
.dialog-title {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-medium);
}
.dialog-close {
cursor: pointer;
display: flex;
padding: 0.375rem;
border: 0;
border-radius: 50%;
color: var(--color-text-secondary);
background-color: transparent;
&:hover {
background-color: var(--color-chat-hover);
}
}
.dialog-body {
overflow-y: auto;
padding: 1.25rem;
}
.dialog-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
padding: 0 1.25rem 1.25rem;
}
+1
View File
@@ -1,6 +1,7 @@
@use "variables";
@use "spacing";
@use "forms";
@use "dialogs";
@use "dark-theme";
html,