Files
remnawave-minishop/frontend/src/admin/sections/UserDetailModal.svelte
T

1634 lines
65 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { Label, Separator, Tabs } from "$components/ui/primitives.js";
import { Checkbox, Input, ScrollArea, Textarea } from "$components/ui/index.js";
import Dialog from "$components/ui/dialog.svelte";
import {
AdminBadge,
AdminButton,
AdminEmptyState,
AdminPagination,
AdminSectionHeader,
AdminSelect,
AdminTable,
AdminTableSkeleton,
AdminTrafficCard,
} from "$components/patterns/admin/index.js";
import {
Copy,
Eye,
ExternalLink,
RefreshCw,
Send,
Plus,
Trash2,
UserMinus,
UserPlus,
UsersRound,
} from "$components/ui/icons.js";
import { getContext } from "svelte";
export let at;
export let fmtDate;
export let fmtMoney;
export let resolvedAvatarUrl;
export let userDisplayName;
export let userSecondaryName;
export let paymentStatusVariant;
export let trafficPercentValue;
export let trafficLeftLabel;
export let trafficOfLabel;
export let userInitials = () => "";
export let fmtDateShort = (v) => v;
export let userTelegramProfileLink = () => "";
export let userTelegramProfileLinkKind = () => "";
export let openTelegramProfileLink = () => false;
export let onClose = () => usersStore.closeUser();
let avatarPreviewOpen = false;
let avatarPreviewUrl = "";
let avatarPreviewName = "";
function pretty(val) {
if (val === true) return at("yes", {}, "Да");
if (val === false) return at("no", {}, "Нет");
return String(val ?? "—");
}
function isTrialSubscription(sub) {
return Boolean(sub?.is_trial || String(sub?.provider || "").toLowerCase() === "trial");
}
function subscriptionDisplayLabel(sub) {
if (!sub) return "—";
if (isTrialSubscription(sub)) return at("user_subscription_trial", {}, "Триал");
if (sub.display_label) return sub.display_label;
return sub.tariff_name || sub.tariff_key || at("user_history_no_tariff", {}, "Без тарифа");
}
function trialSummaryText(trial) {
if (!trial?.used) return at("user_trial_not_used", {}, "Не брал");
const date = trial.latest_activated_at || trial.first_activated_at;
const base = date
? at("user_trial_used_at", { date: fmtDate(date) }, `Брал ${fmtDate(date)}`)
: at("user_trial_used", {}, "Брал");
return trial.active ? `${base} · ${at("user_trial_active", {}, "активен")}` : base;
}
function hwidLimitLabel(sub) {
const rawBase = sub?.hwid_device_limit;
const hasBase = rawBase !== null && rawBase !== undefined;
const extra = Math.max(0, Number(sub?.extra_hwid_devices || 0));
if (!hasBase) return at("user_hwid_limit_default", {}, "Тарифный / default");
const base = Number(rawBase);
if (base === 0) return at("user_hwid_limit_unlimited", {}, "Безлимит");
if (extra > 0) {
return at(
"user_hwid_limit_with_extra",
{ base, extra, total: base + extra },
`${base + extra} (${base} + ${extra})`
);
}
return at("user_hwid_limit_count", { count: base }, `${base}`);
}
function vpnLastConnectionLabel(detail) {
const connectedAt = detail?.last_vpn_connected_at;
const status = detail?.vpn_connection_status;
if (connectedAt) return fmtDate(connectedAt);
if (status === "never") return at("user_vpn_never_connected", {}, "Никогда");
if (status === "connected") {
return at("user_vpn_connected_no_time", {}, "Подключался, время неизвестно");
}
return "—";
}
const usersStore = getContext("usersStore");
$: ({
openedUser,
openedUserDetail,
userDetailLoading,
userMessageDraft,
userActionBusy,
userDeleteOpen,
userBanConfirmOpen,
userMessageConfirmOpen,
userReferralsOpen,
userReferralsLoading,
userReferrals,
userReferralsTotal,
userReferralsPage,
userReferralsPageSize,
premiumUnlimitedDraft,
hwidUnlimitedDraft,
userDetailTab,
userLogs,
userLogsTotal,
userLogsPage,
userLogsLoading,
userLogsLoaded,
userLogsPageSize,
} = $usersStore);
$: userLogsPageCount = Math.max(
1,
Math.ceil(Number(userLogsTotal || 0) / Number(userLogsPageSize || 20))
);
$: userReferralsPageCount = Math.max(
1,
Math.ceil(Number(userReferralsTotal || 0) / Number(userReferralsPageSize || 25))
);
$: openedUserAvatarUrl = openedUser ? resolvedAvatarUrl(openedUser) : "";
$: referralInviter = openedUserDetail?.referral?.inviter || null;
$: referralInviteesTotal = Number(openedUserDetail?.referral?.invitees_total || 0);
$: openedUserTelegramProfileLink = openedUser ? userTelegramProfileLink(openedUser) : "";
$: openedUserTelegramProfileLinkKind = openedUser ? userTelegramProfileLinkKind(openedUser) : "";
$: openedUserTelegramProfileHint =
openedUserTelegramProfileLinkKind === "id"
? at("user_open_tg_profile_id_hint", {}, "Бот отправит кнопку профиля в Telegram")
: at("user_open_tg_profile_hint", {}, "Открыть профиль Telegram");
$: if (openedUser && userDetailTab === "logs" && !userLogsLoading && !userLogsLoaded) {
usersStore.loadUserLogs(0);
}
$: if (!openedUser) {
avatarPreviewOpen = false;
avatarPreviewUrl = "";
avatarPreviewName = "";
}
function openAvatarPreview() {
if (!openedUserAvatarUrl || !openedUser) return;
avatarPreviewUrl = openedUserAvatarUrl;
avatarPreviewName = userDisplayName(openedUser);
avatarPreviewOpen = true;
}
function closeAvatarPreview() {
avatarPreviewOpen = false;
}
function openUserTelegramProfile() {
if (!openedUserTelegramProfileLink) {
usersStore.copyToClipboard(
String(openedUser?.telegram_id || ""),
at("user_tg_profile_unavailable", {}, "Ссылка на профиль Telegram недоступна")
);
return;
}
if (openedUserTelegramProfileLinkKind === "id") {
usersStore.sendTelegramProfileLink();
return;
}
openTelegramProfileLink(openedUserTelegramProfileLink);
}
function openRelatedUser(user) {
if (!user?.user_id) return;
usersStore.closeUserReferrals();
usersStore.openUser(user);
}
</script>
<Dialog
open={Boolean(openedUser)}
title={openedUser
? at("user_detail_title", { id: openedUser.user_id }, `Пользователь #${openedUser.user_id}`)
: ""}
description={openedUser?.username ? "@" + openedUser.username : ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={onClose}
class="admin-dialog admin-user-dialog"
>
{#if openedUser}
{#if userDetailLoading || !openedUserDetail}
<p class="admin-muted">{at("loading", {}, "Загрузка…")}</p>
{:else}
<div class="admin-user-dialog-body">
<aside class="admin-user-aside">
<div class="admin-user-summary">
<button
type="button"
class="admin-avatar admin-avatar-lg admin-avatar-preview-trigger"
class:is-clickable={Boolean(openedUserAvatarUrl)}
disabled={!openedUserAvatarUrl}
onclick={openAvatarPreview}
aria-label={at("user_avatar_open", {}, "Открыть аватар")}
title={openedUserAvatarUrl ? at("user_avatar_open", {}, "Открыть аватар") : ""}
>
{#if openedUserAvatarUrl}
<img src={openedUserAvatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
<span>{userInitials(openedUser)}</span>
{/if}
</button>
<div class="admin-user-summary-meta">
<strong>{userDisplayName(openedUser)}</strong>
<small>{userSecondaryName(openedUser)}</small>
<div class="admin-user-summary-tags">
{#if openedUser.is_banned}
<AdminBadge variant="danger">{at("badge_banned", {}, "Бан")}</AdminBadge>
{:else}
<AdminBadge variant="success">{at("badge_active", {}, "Активен")}</AdminBadge>
{/if}
{#if openedUserDetail.active_subscription}
<AdminBadge variant="success"
>{at("badge_subscription", {}, "Подписка")}</AdminBadge
>
{:else}
<AdminBadge variant="muted"
>{at("badge_no_subscription", {}, "Без подписки")}</AdminBadge
>
{/if}
</div>
<div class="admin-user-summary-actions">
<AdminButton
size="sm"
variant="ghost"
onclick={openUserTelegramProfile}
disabled={!openedUserTelegramProfileLink}
title={openedUserTelegramProfileHint}
aria-label={at("user_open_tg_profile", {}, "Открыть профиль Telegram")}
>
<ExternalLink size={14} />
{at("user_open_tg_profile", {}, "Открыть Telegram")}
</AdminButton>
</div>
</div>
</div>
<div class="admin-user-stats">
<div class="admin-user-stat">
<span>{at("user_label_paid", {}, "Заплачено")}</span>
<strong>{fmtMoney(openedUserDetail.total_paid)}</strong>
</div>
<div class="admin-user-stat">
<span>{at("user_label_logs", {}, "Логов")}</span>
<strong>{openedUserDetail.log_count}</strong>
</div>
</div>
<div class="admin-subsection-title">{at("user_section_profile", {}, "Профиль")}</div>
<ul class="admin-meta-list">
<li><span>ID</span><strong>{openedUser.user_id}</strong></li>
<li><span>Telegram ID</span><strong>{openedUser.telegram_id || "—"}</strong></li>
<li>
<span>Username</span><strong
>{openedUser.username ? "@" + openedUser.username : "—"}</strong
>
</li>
<li>
<span>Email</span><strong class="admin-meta-truncate"
>{openedUser.email || "—"}</strong
>
</li>
<li>
<span>{at("user_label_registration", {}, "Регистрация")}</span><strong
>{fmtDate(openedUser.registration_date)}</strong
>
</li>
<li>
<span>{at("user_label_vpn_last_connected", {}, "Последнее VPN-подключение")}</span
><strong>{vpnLastConnectionLabel(openedUserDetail)}</strong>
</li>
<li>
<span>{at("user_label_ref_code", {}, "Реф. код")}</span><strong
>{openedUserDetail.referral?.code ||
openedUserDetail.user?.referral_code ||
"—"}</strong
>
</li>
<li class="admin-user-ref-row">
<span>{at("user_label_invited_by", {}, "Пригласил")}</span>
<strong class="admin-user-ref-value">
{#if referralInviter}
<span>{userDisplayName(referralInviter)}</span>
<small>ID {referralInviter.user_id}</small>
{:else}
<span>{at("user_invited_by_none", {}, "—")}</span>
{/if}
</strong>
{#if referralInviter}
<AdminButton
size="icon"
variant="icon"
title={at("user_open_related", {}, "Открыть карточку")}
aria-label={at("user_open_related", {}, "Открыть карточку")}
onclick={() => openRelatedUser(referralInviter)}
>
<ExternalLink size={14} />
</AdminButton>
{/if}
</li>
<li class="admin-user-ref-row">
<span>{at("user_label_invited_users", {}, "Приглашённые")}</span>
<strong>{referralInviteesTotal}</strong>
<AdminButton
size="sm"
variant="ghost"
disabled={referralInviteesTotal <= 0}
onclick={() => usersStore.openUserReferrals(0)}
>
<UsersRound size={14} />
{at("user_invitees_open", {}, "Показать")}
</AdminButton>
</li>
</ul>
{#if openedUserDetail.subscription_url || openedUserDetail.referral?.bot_link || openedUserDetail.referral?.webapp_link}
<div class="admin-subsection-title">{at("user_section_links", {}, "Ссылки")}</div>
<div class="admin-link-list">
{#if openedUserDetail.subscription_url}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label"
>{at("status_subscription", {}, "Подписка")}</span
>
<a
class="admin-link-row-url"
href={openedUserDetail.subscription_url}
target="_blank"
rel="noopener"
>
{openedUserDetail.subscription_url}
</a>
</div>
<AdminButton
size="icon"
variant="icon"
title={at("user_copy_tooltip", {}, "Скопировать")}
onclick={() =>
usersStore.copyToClipboard(
openedUserDetail.subscription_url,
at("user_sub_link_copied", {}, "Ссылка на подписку скопирована")
)}
>
<Copy size={14} />
</AdminButton>
</div>
{/if}
{#if openedUserDetail.referral?.bot_link}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label"
>{at("user_label_ref_bot", {}, "Реф. ссылка (бот)")}</span
>
<a
class="admin-link-row-url"
href={openedUserDetail.referral.bot_link}
target="_blank"
rel="noopener"
>
{openedUserDetail.referral.bot_link}
</a>
</div>
<AdminButton
size="icon"
variant="icon"
title={at("user_copy_tooltip", {}, "Скопировать")}
onclick={() =>
usersStore.copyToClipboard(
openedUserDetail.referral.bot_link,
at("user_ref_link_copied", {}, "Реф. ссылка скопирована")
)}
>
<Copy size={14} />
</AdminButton>
</div>
{/if}
{#if openedUserDetail.referral?.webapp_link}
<div class="admin-link-row">
<div class="admin-link-row-meta">
<span class="admin-link-row-label"
>{at("user_label_ref_web", {}, "Реф. ссылка (веб)")}</span
>
<a
class="admin-link-row-url"
href={openedUserDetail.referral.webapp_link}
target="_blank"
rel="noopener"
>
{openedUserDetail.referral.webapp_link}
</a>
</div>
<AdminButton
size="icon"
variant="icon"
title={at("user_copy_tooltip", {}, "Скопировать")}
onclick={() =>
usersStore.copyToClipboard(
openedUserDetail.referral.webapp_link,
at("user_ref_link_copied", {}, "Реф. ссылка скопирована")
)}
>
<Copy size={14} />
</AdminButton>
</div>
{/if}
</div>
{/if}
</aside>
<main class="admin-user-main">
<Tabs.Root
bind:value={$usersStore.userDetailTab}
class="admin-tabs-root admin-user-tabs-root"
>
<Tabs.List class="admin-tabs-list">
<Tabs.Trigger value="subscription" class="admin-tabs-trigger"
>{at("user_tab_subscription", {}, "Подписка")}</Tabs.Trigger
>
<Tabs.Trigger value="activity" class="admin-tabs-trigger"
>{at("user_tab_activity", {}, "Активность")}</Tabs.Trigger
>
<Tabs.Trigger value="logs" class="admin-tabs-trigger"
>{at("user_tab_logs", {}, "Логи")}</Tabs.Trigger
>
<Tabs.Trigger value="actions" class="admin-tabs-trigger"
>{at("user_tab_actions", {}, "Действия")}</Tabs.Trigger
>
</Tabs.List>
<Tabs.Content value="subscription" class="admin-tabs-content">
{#if openedUserDetail.active_subscription}
<ul class="admin-meta-list">
<li>
<span>{at("user_label_active_until", {}, "Активна до")}</span><strong
>{fmtDate(openedUserDetail.active_subscription.end_date)}</strong
>
</li>
<li>
<span>{at("user_label_tariff", {}, "Тариф")}</span><strong
>{subscriptionDisplayLabel(openedUserDetail.active_subscription)}</strong
>
</li>
<li>
<span>{at("user_label_auto_renew", {}, "Авто-продление")}</span><strong
>{pretty(openedUserDetail.active_subscription.auto_renew_enabled)}</strong
>
</li>
<li>
<span>{at("user_label_provider", {}, "Провайдер")}</span><strong
>{openedUserDetail.active_subscription.provider || "—"}</strong
>
</li>
<li>
<span>{at("user_label_hwid_devices", {}, "HWID-устройства")}</span><strong
>{hwidLimitLabel(openedUserDetail.active_subscription)}</strong
>
</li>
</ul>
<div class="admin-traffic-summary">
<AdminTrafficCard
title={at("user_label_main_traffic", {}, "Основной трафик")}
value={trafficOfLabel(
openedUserDetail.active_subscription.traffic_used_bytes,
openedUserDetail.active_subscription.traffic_limit_bytes
)}
left={at(
"user_traffic_left",
{
left: trafficLeftLabel(
openedUserDetail.active_subscription.traffic_used_bytes,
openedUserDetail.active_subscription.traffic_limit_bytes
),
},
"Осталось: " +
trafficLeftLabel(
openedUserDetail.active_subscription.traffic_used_bytes,
openedUserDetail.active_subscription.traffic_limit_bytes
)
)}
percent={trafficPercentValue(
openedUserDetail.active_subscription.traffic_used_bytes,
openedUserDetail.active_subscription.traffic_limit_bytes
)}
warning={openedUserDetail.active_subscription.is_throttled}
label={at("aria_label_main_traffic", {}, "Использование основного трафика")}
/>
{#if openedUserDetail.active_subscription.premium_unlimited_override}
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
value={at(
"user_premium_unlimited_value",
{
used: trafficLeftLabel(
0,
openedUserDetail.active_subscription.premium_used_bytes
),
},
"∞ (использовано " +
trafficLeftLabel(
0,
openedUserDetail.active_subscription.premium_used_bytes
) +
")"
)}
left={at("user_premium_unlimited_hint", {}, "Безлимит (админ-оверрайд)")}
percent={0}
warning={false}
label={at("aria_label_premium_traffic", {}, "Использование premium-трафика")}
/>
{:else if Number(openedUserDetail.active_subscription.premium_limit_bytes || 0) > 0}
<AdminTrafficCard
premium
title={at("user_label_premium_squads", {}, "Premium-сквады")}
value={trafficOfLabel(
openedUserDetail.active_subscription.premium_used_bytes,
openedUserDetail.active_subscription.premium_limit_bytes
)}
left={at(
"user_traffic_left",
{
left: trafficLeftLabel(
openedUserDetail.active_subscription.premium_used_bytes,
openedUserDetail.active_subscription.premium_limit_bytes
),
},
"Осталось: " +
trafficLeftLabel(
openedUserDetail.active_subscription.premium_used_bytes,
openedUserDetail.active_subscription.premium_limit_bytes
)
)}
percent={trafficPercentValue(
openedUserDetail.active_subscription.premium_used_bytes,
openedUserDetail.active_subscription.premium_limit_bytes
)}
warning={openedUserDetail.active_subscription.premium_is_limited}
label={at("aria_label_premium_traffic", {}, "Использование premium-трафика")}
/>
{/if}
</div>
{:else}
<p class="admin-muted">
{at("user_no_active_subscription", {}, "Активной подписки нет")}
</p>
{/if}
{#if openedUserDetail?.trial}
<ul class="admin-meta-list">
<li>
<span>{at("user_label_trial", {}, "Пробник / триал")}</span><strong
>{trialSummaryText(openedUserDetail.trial)}</strong
>
</li>
{#if openedUserDetail.trial.used && openedUserDetail.trial.latest_end_date}
<li>
<span>{at("user_label_trial_until", {}, "Триал до")}</span><strong
>{fmtDate(openedUserDetail.trial.latest_end_date)}</strong
>
</li>
{/if}
{#if Number(openedUserDetail.trial.count || 0) > 1}
<li>
<span>{at("user_label_trial_count", {}, "Триалов")}</span><strong
>{openedUserDetail.trial.count}</strong
>
</li>
{/if}
{#if openedUserDetail.trial.last_reset_at}
<li>
<span>{at("user_label_trial_reset_at", {}, "Сброс триала")}</span><strong
>{fmtDate(openedUserDetail.trial.last_reset_at)}</strong
>
</li>
{/if}
</ul>
{/if}
{#if (openedUserDetail.subscriptions || []).length}
<Separator.Root class="admin-separator" />
<div class="admin-subsection-title">
{at(
"user_history_title",
{ count: openedUserDetail.subscriptions.length },
`История подписок · ${openedUserDetail.subscriptions.length}`
)}
</div>
<div class="admin-mini-list">
{#each openedUserDetail.subscriptions.slice(0, 8) as sub}
<div class="admin-mini-list-row">
<div>
<strong>{subscriptionDisplayLabel(sub)}</strong>
<small
>{at(
"user_history_until",
{ date: fmtDate(sub.end_date) },
`до ${fmtDate(sub.end_date)}`
)}</small
>
</div>
{#if sub.is_active}
<AdminBadge variant="success"
>{at("user_history_active", {}, "Активна")}</AdminBadge
>
{:else}
<AdminBadge variant="muted"
>{sub.status_from_panel ||
at("user_history_status_panel", {}, "История")}</AdminBadge
>
{/if}
</div>
{/each}
</div>
{/if}
</Tabs.Content>
<Tabs.Content value="activity" class="admin-tabs-content">
<div class="admin-subsection-title">
{at(
"user_recent_payments_title",
{ count: (openedUserDetail.recent_payments || []).length },
`Последние платежи · ${(openedUserDetail.recent_payments || []).length}`
)}
</div>
{#if (openedUserDetail.recent_payments || []).length}
<div class="admin-mini-list">
{#each openedUserDetail.recent_payments.slice(0, 8) as payment}
<div class="admin-mini-list-row">
<div>
<strong>{fmtMoney(payment.amount, payment.currency)}</strong>
<small>{payment.provider} · {fmtDateShort(payment.created_at)}</small>
</div>
<AdminBadge variant={paymentStatusVariant(payment.status)}
>{payment.status}</AdminBadge
>
</div>
{/each}
</div>
{:else}
<p class="admin-muted">{at("user_no_payments", {}, "Платежей нет")}</p>
{/if}
</Tabs.Content>
<Tabs.Content value="logs" class="admin-tabs-content admin-user-logs-tab">
<div class="admin-user-logs-head">
<div class="admin-subsection-title">
{at("user_logs_section_title", {}, "Логи пользователя")}
</div>
<div class="admin-user-logs-meta">
<span class="admin-muted">{at("total", {}, "Всего")}</span>
<strong>{userLogsTotal}</strong>
<AdminButton
size="sm"
variant="ghost"
disabled={userLogsLoading}
onclick={() => usersStore.loadUserLogs(userLogsPage)}
title={at("refresh", {}, "Обновить")}
>
<RefreshCw size={14} />
{at("refresh", {}, "Обновить")}
</AdminButton>
</div>
</div>
<ScrollArea class="admin-user-logs-wrap" maxHeight="min(52vh, 460px)">
{#if userLogsLoading}
<AdminTableSkeleton
headers={[
at("date", {}, "Дата"),
at("event", {}, "Событие"),
at("content", {}, "Контент"),
]}
rows={6}
widths={["140px", "140px", "60%"]}
/>
{:else if !userLogs.length}
<AdminEmptyState tone="card">
<span class="admin-muted">{at("logs_empty", {}, "Записей нет")}</span>
</AdminEmptyState>
{:else}
<AdminTable>
<thead>
<tr>
<th>{at("date", {}, "Дата")}</th>
<th>{at("event", {}, "Событие")}</th>
<th>{at("content", {}, "Контент")}</th>
</tr>
</thead>
<tbody>
{#each userLogs as entry (entry.log_id)}
<tr>
<td data-label={at("date", {}, "Дата")}>{fmtDate(entry.timestamp)}</td>
<td class="admin-cell-mono" data-label={at("event", {}, "Событие")}>
<span class="admin-user-log-event">
<span>{entry.event_type || "—"}</span>
{#if entry.is_admin_event}
<AdminBadge variant="warning"
>{at("user_logs_admin_event", {}, "Админ")}</AdminBadge
>
{/if}
{#if entry.target_user_id && entry.target_user_id !== openedUser?.user_id}
<small class="admin-muted">→ {entry.target_user_id}</small>
{/if}
</span>
</td>
<td
class="admin-cell-wrap admin-user-log-content"
data-label={at("content", {}, "Контент")}
>
{entry.content || ""}
</td>
</tr>
{/each}
</tbody>
</AdminTable>
{/if}
</ScrollArea>
{#if userLogsLoaded && userLogsTotal > userLogsPageSize}
<AdminPagination
page={userLogsPage}
pageCount={userLogsPageCount}
total={userLogsTotal}
pageLabel={at("page_short", {}, "Стр.")}
ofLabel={at("pagination_of", {}, "из")}
totalLabel={at("total", {}, "Всего")}
jumpLabel={at("page_short", {}, "Стр.")}
jumpAriaLabel={at("pagination_jump_aria", {}, "Перейти к странице")}
goLabel={at("pagination_go", {}, "Перейти")}
prevLabel={at("back", {}, "Назад")}
nextLabel={at("next", {}, "Далее")}
disabled={userLogsLoading}
onPageChange={(page) => usersStore.setUserLogsPage(page)}
/>
{/if}
</Tabs.Content>
<Tabs.Content value="actions" class="admin-tabs-content admin-actions-tab">
<div class="admin-user-quick-actions">
<AdminButton
class="admin-reset-trial-btn"
onclick={usersStore.resetTrialUser}
disabled={userActionBusy}
>
<RefreshCw size={14} />
{at("user_btn_reset_trial", {}, "Сбросить триал")}
</AdminButton>
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_label_extend", {}, "Продлить подписку")}</span>
<div class="admin-extend-control">
<Input
class="input"
type="number"
min="1"
bind:value={$usersStore.userExtendDays}
aria-label={at("user_label_extend_days", {}, "Дней")}
/>
<AdminButton onclick={usersStore.extendUser} disabled={userActionBusy}>
<Plus size={14} />
{at("user_btn_extend", {}, "Продлить")}
</AdminButton>
</div>
{#if Number(openedUserDetail?.active_subscription?.extra_hwid_devices || 0) > 0}
<label class="admin-extend-hwid-option">
<Checkbox
bind:checked={$usersStore.userExtendHwidDevices}
disabled={userActionBusy}
ariaLabel={at(
"user_extend_hwid_devices_aria",
{},
"Продлить докупленные HWID-устройства"
)}
/>
<span>
<strong>
{at(
"user_extend_hwid_devices",
{
count: Number(
openedUserDetail.active_subscription.extra_hwid_devices || 0
),
},
`Продлить также +${Number(
openedUserDetail.active_subscription.extra_hwid_devices || 0
)} HWID-устройств`
)}
</strong>
<small>
{at(
"user_extend_hwid_devices_hint",
{},
"Срок действующих докупок увеличится на те же дни."
)}
</small>
</span>
</label>
{/if}
</Label.Root>
</div>
{#if openedUserDetail?.active_subscription}
<section class="admin-user-action-sheet admin-user-action-sheet--premium-override">
<AdminSectionHeader
title={at("user_premium_override_card_title", {}, "Премиум-трафик")}
description={at(
"user_premium_override_card_hint",
{},
"Безлимит и дополнительный объём для премиум-сквадов поверх тарифа."
)}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_premium_override_bonus", {}, "Доп. премиум-трафик, GB")}</span
>
<small>{at("user_premium_override_bonus_hint", {}, "")}</small>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
disabled={premiumUnlimitedDraft}
aria-label={at(
"user_premium_override_bonus",
{},
"Доп. премиум-трафик, GB"
)}
bind:value={$usersStore.premiumBonusGbDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<Checkbox
bind:checked={$usersStore.premiumUnlimitedDraft}
aria-label={at("user_override_unlimited_short", {}, "Безлимит")}
/>
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton
variant="primary"
onclick={usersStore.savePremiumTrafficOverride}
disabled={userActionBusy}
>
{at("user_premium_override_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
{#if openedUserDetail.active_subscription.premium_unlimited_override}
<span class="admin-meta-truncate">
{at("user_premium_override_status_unlimited", {}, "Сейчас: безлимит")}
</span>
{:else if Number(openedUserDetail.active_subscription.premium_bonus_bytes || 0) > 0}
<span class="admin-meta-truncate">
{at(
"user_premium_override_status_bonus",
{
gb: +(
Number(openedUserDetail.active_subscription.premium_bonus_bytes) /
1024 ** 3
).toFixed(2),
},
`Премиум сейчас: +${+(Number(openedUserDetail.active_subscription.premium_bonus_bytes) / 1024 ** 3).toFixed(2)} GB`
)}
</span>
{:else}
<span class="admin-muted"
>{at(
"user_premium_override_status_none",
{},
"Премиум-оверрайд не задан"
)}</span
>
{/if}
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--regular-override">
<AdminSectionHeader
title={at("user_regular_override_card_title", {}, "Основной трафик")}
description={at(
"user_regular_override_card_hint",
{},
"Безлимит и постоянный бонус к лимиту основного трафика."
)}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span
>{at("user_regular_override_bonus", {}, "Доп. основной трафик, GB")}</span
>
<small>{at("user_regular_override_bonus_hint", {}, "")}</small>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
disabled={$usersStore.regularUnlimitedDraft}
aria-label={at(
"user_regular_override_bonus",
{},
"Доп. основной трафик, GB"
)}
bind:value={$usersStore.regularBonusGbDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<Checkbox
bind:checked={$usersStore.regularUnlimitedDraft}
aria-label={at("user_override_unlimited_short", {}, "Безлимит")}
/>
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton
variant="primary"
onclick={usersStore.saveRegularTrafficOverride}
disabled={userActionBusy}
>
{at("user_regular_override_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
{#if openedUserDetail.active_subscription.regular_unlimited_override}
<span class="admin-meta-truncate">
{at("user_regular_override_status_unlimited", {}, "Сейчас: безлимит")}
</span>
{:else if Number(openedUserDetail.active_subscription.regular_bonus_bytes || 0) > 0}
<span class="admin-meta-truncate">
{at(
"user_regular_override_status_bonus",
{
gb: +(
Number(openedUserDetail.active_subscription.regular_bonus_bytes) /
1024 ** 3
).toFixed(2),
},
`Основной сейчас: +${+(Number(openedUserDetail.active_subscription.regular_bonus_bytes) / 1024 ** 3).toFixed(2)} GB`
)}
</span>
{:else}
<span class="admin-muted"
>{at(
"user_regular_override_status_none",
{},
"Бонус основного трафика не задан"
)}</span
>
{/if}
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--hwid-limit">
<AdminSectionHeader
title={at("user_hwid_limit_card_title", {}, "HWID-устройства")}
description={at(
"user_hwid_limit_card_hint",
{},
"Ручной лимит устройств для пользователя. Пустое поле вернёт тарифный или default-лимит."
)}
/>
<div class="admin-user-action-sheet-body admin-user-override-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_hwid_limit_input", {}, "Лимит устройств")}</span>
<small
>{at(
"user_hwid_limit_input_hint",
{},
"Пусто — тариф/default; 0 или галочка — безлимит."
)}</small
>
<Input
class="input"
type="number"
min="0"
step="1"
placeholder={at("user_hwid_limit_default_placeholder", {}, "Тариф")}
disabled={hwidUnlimitedDraft}
aria-label={at("user_hwid_limit_input", {}, "Лимит устройств")}
bind:value={$usersStore.hwidDeviceLimitDraft}
/>
</Label.Root>
</div>
<div class="admin-user-action-sheet-footer admin-override-card-footer">
<div class="admin-override-card-toolbar">
<label class="admin-override-unlimited-label">
<Checkbox
bind:checked={$usersStore.hwidUnlimitedDraft}
aria-label={at("user_override_unlimited_short", {}, "Безлимит")}
/>
<span>{at("user_override_unlimited_short", {}, "Безлимит")}</span>
</label>
<AdminButton
variant="primary"
onclick={usersStore.saveHwidDeviceLimit}
disabled={userActionBusy}
>
{at("user_hwid_limit_save", {}, "Сохранить")}
</AdminButton>
</div>
<div class="admin-override-status-lines">
<span class="admin-meta-truncate">
{at(
"user_hwid_limit_status",
{ current: hwidLimitLabel(openedUserDetail.active_subscription) },
`Сейчас: ${hwidLimitLabel(openedUserDetail.active_subscription)}`
)}
</span>
</div>
</div>
</section>
<section class="admin-user-action-sheet admin-user-action-sheet--traffic-grant">
<AdminSectionHeader
title={at("user_traffic_grant_title", {}, "Выдать трафик")}
description={at(
"user_traffic_grant_hint",
{},
"Зачисление ГБ на баланс пользователя — как при докупке, но без оплаты. Лимит и сквады в панели обновятся сразу."
)}
/>
<div class="admin-user-action-sheet-body admin-user-grant-stack">
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_kind", {}, "Тип трафика")}</span>
<AdminSelect
class="admin-grant-kind-select"
value={$usersStore.grantTrafficKindDraft}
items={[
{
value: "regular",
label: at("user_traffic_grant_kind_regular", {}, "Обычный"),
},
{
value: "premium",
label: at("user_traffic_grant_kind_premium", {}, "Премиум"),
},
]}
onValueChange={(v) => usersStore.updateState({ grantTrafficKindDraft: v })}
ariaLabel={at("user_traffic_grant_kind", {}, "Тип трафика")}
/>
</Label.Root>
<Label.Root class="admin-field-label admin-extend-field">
<span>{at("user_traffic_grant_gb", {}, "ГБ к выдаче")}</span>
<div class="admin-extend-control">
<Input
class="input"
type="number"
min="0"
step="1"
placeholder="0"
aria-label={at("user_traffic_grant_gb", {}, "ГБ к выдаче")}
bind:value={$usersStore.grantTrafficGbDraft}
/>
<AdminButton
variant="primary"
onclick={usersStore.grantTraffic}
disabled={userActionBusy}
>
<Plus size={14} />
{at("user_traffic_grant_submit", {}, "Выдать")}
</AdminButton>
</div>
</Label.Root>
</div>
</section>
{/if}
<Label.Root class="admin-field-label">
<span>{at("user_label_telegram_msg", {}, "Сообщение в Telegram")}</span>
<small
>{at(
"user_hint_telegram_msg",
{},
"Поддерживается HTML-разметка Telegram"
)}</small
>
<Textarea
class="admin-textarea"
rows="3"
placeholder={at("user_placeholder_msg", {}, "Текст сообщения")}
bind:value={$usersStore.userMessageDraft}
/>
</Label.Root>
<div class="admin-message-actions">
<AdminButton
onclick={usersStore.previewUserMessage}
disabled={userActionBusy || !userMessageDraft.trim()}
>
<Eye size={14} />
{at("btn_preview_tg", {}, "Превью в Telegram")}
</AdminButton>
<AdminButton
variant="primary"
onclick={usersStore.requestSendUserMessage}
disabled={userActionBusy || !userMessageDraft.trim()}
>
<Send size={14} />
{at("btn_send_msg", {}, "Отправить сообщение")}
</AdminButton>
</div>
<section class="admin-danger-zone">
<header class="admin-danger-zone-head">
<strong>{at("user_danger_zone_title", {}, "Опасные действия")}</strong>
<small
>{at(
"user_danger_zone_subtitle",
{},
"Эти действия требуют подтверждения и (для удаления) необратимы"
)}</small
>
</header>
<div class="admin-action-grid">
{#if openedUser.is_banned}
<AdminButton
variant="dangerSoft"
onclick={usersStore.requestBanToggle}
disabled={userActionBusy}
>
<UserPlus size={14} />
{at("btn_unban", {}, "Разбанить пользователя")}
</AdminButton>
{:else}
<AdminButton
variant="danger"
onclick={usersStore.requestBanToggle}
disabled={userActionBusy}
>
<UserMinus size={14} />
{at("btn_ban", {}, "Заблокировать")}
</AdminButton>
{/if}
<AdminButton
variant="danger"
onclick={() => usersStore.updateState({ userDeleteOpen: true })}
disabled={userActionBusy}
>
<Trash2 size={14} />
{at("btn_delete_account", {}, "Удалить аккаунт")}
</AdminButton>
</div>
</section>
</Tabs.Content>
</Tabs.Root>
</main>
</div>
{/if}
{/if}
</Dialog>
<Dialog
open={userReferralsOpen}
title={at("user_invitees_title", {}, "Приглашённые пользователи")}
description={openedUser
? at(
"user_invitees_description",
{ name: userDisplayName(openedUser), count: userReferralsTotal },
`${userDisplayName(openedUser)} · ${userReferralsTotal}`
)
: ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={usersStore.closeUserReferrals}
class="admin-dialog admin-user-referrals-dialog"
>
<div class="admin-user-referrals-body">
{#if userReferralsLoading}
<AdminTableSkeleton
headers={[
at("user_col_user", {}, "Пользователь"),
"ID",
at("user_label_registration", {}, "Регистрация"),
"",
]}
rows={5}
widths={["42%", "18%", "26%", "14%"]}
/>
{:else if !userReferrals.length}
<AdminEmptyState tone="card">
<span class="admin-muted"
>{at("user_invitees_empty", {}, "Пользователь пока никого не пригласил")}</span
>
</AdminEmptyState>
{:else}
<ScrollArea class="admin-user-referrals-table-wrap" maxHeight="min(55vh, 460px)">
<AdminTable class="admin-user-referrals-table">
<thead>
<tr>
<th>{at("user_col_user", {}, "Пользователь")}</th>
<th>ID</th>
<th>{at("user_label_registration", {}, "Регистрация")}</th>
<th></th>
</tr>
</thead>
<tbody>
{#each userReferrals as invitee (invitee.user_id)}
<tr>
<td data-label={at("user_col_user", {}, "Пользователь")}>
<span class="admin-referral-user-cell">
<strong>{userDisplayName(invitee)}</strong>
<small>{userSecondaryName(invitee)}</small>
</span>
</td>
<td class="admin-cell-mono" data-label="ID">{invitee.user_id}</td>
<td data-label={at("user_label_registration", {}, "Регистрация")}>
{fmtDateShort(invitee.registration_date)}
</td>
<td class="admin-referral-user-actions">
<AdminButton
size="icon"
variant="icon"
title={at("user_open_related", {}, "Открыть карточку")}
aria-label={at("user_open_related", {}, "Открыть карточку")}
onclick={() => openRelatedUser(invitee)}
>
<ExternalLink size={14} />
</AdminButton>
</td>
</tr>
{/each}
</tbody>
</AdminTable>
</ScrollArea>
{/if}
{#if userReferralsTotal > userReferralsPageSize}
<AdminPagination
page={userReferralsPage}
pageCount={userReferralsPageCount}
total={userReferralsTotal}
pageLabel={at("page_short", {}, "Стр.")}
ofLabel={at("pagination_of", {}, "из")}
totalLabel={at("total", {}, "Всего")}
jumpLabel={at("page_short", {}, "Стр.")}
jumpAriaLabel={at("pagination_jump_aria", {}, "Перейти к странице")}
goLabel={at("pagination_go", {}, "Перейти")}
prevLabel={at("prev_page", {}, "Назад")}
nextLabel={at("next_page", {}, "Вперёд")}
disabled={userReferralsLoading}
onPageChange={(page) => usersStore.setUserReferralsPage(page)}
/>
{/if}
</div>
</Dialog>
<Dialog
open={avatarPreviewOpen}
title={avatarPreviewName || at("user_avatar_title", {}, "Аватар")}
closeLabel={at("close", {}, "Закрыть")}
onclose={closeAvatarPreview}
class="admin-dialog admin-avatar-dialog"
>
{#if avatarPreviewUrl}
<div class="admin-avatar-preview">
<img
src={avatarPreviewUrl}
alt={avatarPreviewName}
loading="eager"
referrerpolicy="no-referrer"
/>
</div>
{/if}
</Dialog>
<Dialog
open={userMessageConfirmOpen}
title={at("user_msg_confirm_title", {}, "Отправить сообщение пользователю?")}
description={openedUser
? at(
"user_msg_confirm_recipient",
{ name: userDisplayName(openedUser) },
`Получатель: ${userDisplayName(openedUser)}`
)
: ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => usersStore.updateState({ userMessageConfirmOpen: false })}
class="admin-dialog"
>
<ScrollArea class="admin-confirm-message-preview" maxHeight="min(280px, 45vh)">
{userMessageDraft}
</ScrollArea>
<div class="admin-dialog-actions">
<AdminButton onclick={() => usersStore.updateState({ userMessageConfirmOpen: false })}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton
variant="primary"
onclick={usersStore.sendUserMessage}
disabled={userActionBusy || !userMessageDraft.trim()}
>
<Send size={14} />
{at("btn_confirm_send", {}, "Подтвердить отправку")}
</AdminButton>
</div>
</Dialog>
<Dialog
open={userBanConfirmOpen}
title={at("user_ban_confirm_title", {}, "Заблокировать пользователя?")}
description={openedUser
? at(
"user_ban_confirm_subtitle",
{ name: userDisplayName(openedUser) },
`${userDisplayName(openedUser)} больше не сможет взаимодействовать с ботом. Действие можно отменить позже.`
)
: ""}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => usersStore.updateState({ userBanConfirmOpen: false })}
class="admin-dialog"
>
<div class="admin-dialog-actions">
<AdminButton onclick={() => usersStore.updateState({ userBanConfirmOpen: false })}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton
variant="danger"
onclick={() => usersStore.applyBanToggle(true)}
disabled={userActionBusy}
>
<UserMinus size={14} />
{at("btn_ban", {}, "Заблокировать")}
</AdminButton>
</div>
</Dialog>
<Dialog
open={userDeleteOpen}
title={at("user_delete_confirm_title", {}, "Удалить пользователя?")}
description={at(
"user_delete_confirm_subtitle",
{},
"Действие необратимо. Удалятся записи в БД бота и пользователь в Remnawave Panel."
)}
closeLabel={at("close", {}, "Закрыть")}
onclose={() => usersStore.updateState({ userDeleteOpen: false })}
class="admin-dialog"
>
<div class="admin-form-row">
<AdminButton onclick={() => usersStore.updateState({ userDeleteOpen: false })}
>{at("btn_cancel", {}, "Отмена")}</AdminButton
>
<AdminButton variant="danger" onclick={usersStore.deleteUser} disabled={userActionBusy}>
<Trash2 size={14} />
{at("btn_confirm_delete", {}, "Подтвердить удаление")}
</AdminButton>
</div>
</Dialog>
<style>
.admin-user-action-sheet {
border: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.08));
border-radius: 12px;
margin-bottom: 14px;
overflow: hidden;
background: var(--admin-surface-1, rgba(255, 255, 255, 0.02));
}
.admin-user-action-sheet :global(.admin-dashboard-section-head) {
padding: 12px 14px 10px;
margin: 0;
border-bottom: 1px solid var(--admin-border-muted, rgba(255, 255, 255, 0.06));
}
.admin-user-action-sheet-body {
padding: 12px 14px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-user-override-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-user-grant-stack {
display: flex;
flex-direction: column;
gap: 14px;
}
.admin-user-action-sheet-footer {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
padding: 4px 14px 12px;
}
.admin-override-card-footer {
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.admin-override-card-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 14px;
width: 100%;
}
.admin-override-card-toolbar :global(.admin-btn) {
flex: 0 0 auto;
min-height: 36px;
padding-left: 16px;
padding-right: 16px;
}
.admin-override-unlimited-label {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--admin-text, inherit);
cursor: pointer;
user-select: none;
min-height: 36px;
}
@media (max-width: 520px) {
.admin-override-card-toolbar {
flex-direction: column;
align-items: stretch;
}
.admin-override-card-toolbar :global(.admin-btn) {
width: 100%;
}
}
.admin-override-status-lines {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
font-size: 12px;
line-height: 1.35;
}
:global(.admin-user-dialog .admin-actions-tab) {
padding-bottom: 14px;
}
.admin-user-action-sheet--regular-override {
margin-top: 10px;
}
.admin-user-action-sheet--traffic-grant {
margin-top: 10px;
}
.admin-user-action-sheet :global(.admin-grant-kind-select) {
width: 100%;
max-width: 100%;
}
.admin-avatar-preview-trigger {
padding: 0;
appearance: none;
}
.admin-avatar-preview-trigger img {
width: 100%;
height: 100%;
object-fit: cover;
}
.admin-avatar-preview-trigger:disabled {
cursor: default;
}
.admin-avatar-preview-trigger.is-clickable {
cursor: zoom-in;
}
.admin-avatar-preview-trigger.is-clickable:hover,
.admin-avatar-preview-trigger.is-clickable:focus-visible {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
.admin-user-summary-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.admin-user-ref-row {
grid-template-columns: 130px minmax(0, 1fr) auto;
align-items: center;
}
.admin-user-ref-row :global(.admin-btn) {
flex: 0 0 auto;
}
.admin-user-ref-value {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-user-ref-value small {
color: var(--admin-muted);
font-size: 11px;
font-weight: 500;
}
:global(.admin-user-referrals-dialog) {
width: min(760px, calc(100vw - 28px));
max-height: min(760px, calc(100dvh - 28px));
}
.admin-user-referrals-body {
display: grid;
gap: 12px;
min-width: 0;
}
:global(.admin-user-referrals-table-wrap) {
min-height: 120px;
}
.admin-referral-user-cell {
display: grid;
gap: 2px;
min-width: 0;
}
.admin-referral-user-cell strong {
color: var(--admin-text);
font-weight: 650;
word-break: break-word;
}
.admin-referral-user-cell small {
color: var(--admin-muted);
font-size: 12px;
word-break: break-word;
}
.admin-referral-user-actions {
text-align: right;
}
@media (max-width: 560px) {
.admin-user-ref-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 8px;
}
.admin-user-ref-row > span {
grid-column: 1 / -1;
}
}
:global(.admin-avatar-dialog) {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(920px, calc(100vw - 28px));
height: min(820px, calc(100dvh - 28px));
max-height: calc(100dvh - 28px);
gap: 10px;
padding: 12px;
overflow: hidden;
}
.admin-avatar-preview {
display: grid;
place-items: center;
min-height: 0;
width: 100%;
height: 100%;
padding: 4px;
overflow: hidden;
}
.admin-avatar-preview img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 14px;
border: 1px solid var(--admin-border);
background: var(--admin-surface-2);
}
@media (max-width: 640px) {
:global(.dialog:has(.admin-avatar-dialog)) {
padding: max(8px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right))
max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
}
:global(.admin-avatar-dialog) {
width: calc(100vw - 16px);
height: min(88dvh, calc(100dvh - 16px));
max-height: calc(100dvh - 16px);
border-radius: 18px;
padding: 10px;
}
}
:global(.admin-user-dialog .admin-user-logs-tab) {
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-user-logs-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.admin-user-logs-meta {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
:global(.admin-user-logs-wrap) {
min-height: 120px;
}
.admin-user-log-event {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
:global(.admin-user-log-content) {
white-space: pre-wrap;
word-break: break-word;
max-width: 520px;
}
@media (max-width: 640px) {
:global(.admin-user-log-content) {
max-width: 100%;
}
}
</style>