<!-- Настройки — общий раздел: у всех личный блок web-push (подписка этого
браузера + список устройств), у admin ещё и конфиги Synapse поверх .env
(секреты write-only: пустое поле = не менять, значение хранится в БД).
Раздел один (/settings), разница по роли из сессии — модель владельца. -->
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import {
GnButton,
GnIconButton,
GnInput,
GnLoader,
GnPageHeader,
GnSelect,
GnStatusCard,
GnSwitch,
} from "gnexus-ui-kit/vue";
import { useToast } from "gnexus-ui-kit/vue";
import { api, getSession } from "../api.js";
import { fmtTime } from "../ui.js";
import { currentSubscription, permissionState, pushSupported, subscribe, unsubscribe } from "../push.js";
const toast = useToast();
// --- общее ---
const session = ref(null);
const loading = ref(true);
const error = ref("");
const isAdmin = computed(() => ["admin", "superadmin"].includes(session.value?.system_role || ""));
// --- личный блок: push-подписка этого браузера + устройства ---
// not-configured — VAPID не заданы; denied — браузер запретил; unsupported — API нет.
const pushUi = reactive({
supported: pushSupported(),
permission: "default",
configured: false,
on: false,
busy: false,
});
const subscriptions = ref([]);
async function loadSubscriptions() {
subscriptions.value = await api.listSubscriptions();
}
/** Хост endpoint'а для показа; в шаблонах URL недоступен (не входит в
* белую-список глобалов компилятора Vue) — считаем в скрипте. */
function hostOf(endpoint) {
try {
return new URL(endpoint).host;
} catch {
return endpoint;
}
}
async function refreshPushState() {
const cfg = await api.pushConfig();
pushUi.configured = cfg.configured;
pushUi.permission = permissionState();
const browserSub = await currentSubscription();
// on = в этом браузере реально есть активная подписка (не по списку сервера)
pushUi.on = Boolean(browserSub);
}
async function removeDevice(sub) {
try {
await api.removeSubscription(sub.id);
await loadSubscriptions();
// если удалили подписку текущего браузера — синхронизировать и тумблер
const browserSub = await currentSubscription();
if (browserSub && browserSub.endpoint === sub.endpoint) await unsubscribe();
pushUi.on = Boolean(await currentSubscription());
toast.success("Подписка удалена");
} catch (ex) {
toast.error(ex.message);
}
}
async function togglePush(next) {
pushUi.busy = true;
try {
if (next) {
if (!pushUi.configured) {
toast.error("Пуши ещё не настроены — админ задаст VAPID-ключи в настройках");
return;
}
const sub = await subscribe(await (await api.pushConfig()).public_key);
if (sub === null) {
pushUi.permission = "denied";
toast.info("Браузеру запрещены уведомления для этого сайта — включите в настройках сайта");
return;
}
await api.saveSubscription(sub);
pushUi.permission = "granted";
toast.success("Пуши включены");
} else {
const browserSub = await currentSubscription();
if (browserSub) {
const ours = subscriptions.value.find((s) => s.endpoint === browserSub.endpoint);
if (ours) await api.removeSubscription(ours.id);
}
await unsubscribe();
toast.success("Пуши выключены");
}
pushUi.on = Boolean(await currentSubscription());
await loadSubscriptions();
} catch (ex) {
toast.error(ex.message);
} finally {
pushUi.busy = false;
}
}
// --- админ-блок: оверрайды поверх .env ---
const SECTIONS = [
{ id: "routing", label: "Маршрутизация", icon: "ph-funnel" },
{ id: "push", label: "Web Push", icon: "ph-bell-ringing" },
{ id: "telegram", label: "Telegram", icon: "ph-telegram-logo" },
{ id: "smtp", label: "Email · SMTP", icon: "ph-envelope" },
];
const items = ref([]); // из реестра: {key, section, label, kind, help, options, value, set}
const edit = reactive({});
const saving = ref(false);
// секции админ-конфига: показываем только заполненные в реестре
const sections = computed(() => SECTIONS.filter((s) => items.value.some((i) => i.section === s.id)));
function initEdit() {
for (const item of items.value) edit[item.key] = item.value ?? "";
}
function dirtyValues() {
// секрет: пустое поле = «не менять» (не отправляем); открытая настройка:
// пустое поле = сброс оверрайда (null — вернётся дефолт .env)
const values = {};
for (const item of items.value) {
const current = edit[item.key] ?? "";
const original = item.value ?? "";
if (current === original) continue;
if (item.kind === "secret") {
if (current) values[item.key] = current;
} else {
values[item.key] = current === "" ? null : current;
}
}
return values;
}
async function save() {
const values = dirtyValues();
saving.value = true;
try {
if (!Object.keys(values).length) return;
await api.saveAdminSettings(values);
toast.success("Настройки сохранены");
await loadAdmin();
} catch (ex) {
toast.error(ex.message);
} finally {
saving.value = false;
}
}
async function loadAdmin() {
items.value = await api.adminSettings();
initEdit();
}
async function load() {
loading.value = true;
error.value = "";
try {
session.value = await getSession();
const jobs = [refreshPushState().then(() => tryLoad(loadSubscriptions()))];
if (isAdmin.value) jobs.push(tryLoad(loadAdmin()));
// ошибки личного пуши не мешают загрузке страницы
await Promise.allSettled(jobs);
} catch (ex) {
error.value = ex.message;
} finally {
loading.value = false;
}
}
function tryLoad(promise) {
return promise.then(() => []).catch((ex) => (console.warn(ex.message), []));
}
onMounted(load);
const DEVICE_TIP = "Устройство, подписанное на ваши пуши (одна строка — один браузер). Удалите строку, если устройства больше нет.";
</script>
<template>
<main class="container page">
<GnPageHeader
kicker="Synapse"
title="Настройки"
:subtitle="isAdmin ? 'Личная подписка на пуши и конфигурация Synapse (перебивает дефолты .env)' : 'Личное: подписка на push-уведомления этого браузера'"
/>
<GnLoader v-if="loading" circle label="Загрузка…" />
<GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
<template v-else>
<!-- --- личное: push-подписка (все роли) --- -->
<div class="push-panel">
<div class="section-title">Push-уведомления этого браузера</div>
<GnStatusCard
v-if="!pushUi.supported"
variant="warning"
icon="ph-bell-slash"
title="Пуши недоступны"
text="Браузер не поддерживает web-push (нужны Service Worker и Notification API; на http-странице — только https/localhost)."
/>
<GnStatusCard
v-else-if="!pushUi.configured"
variant="info"
icon="ph-bell"
title="Сервер ещё не настроен"
text="Админ не задал VAPID-ключи — включите после настройки (админ: Настройки → Web Push)."
/>
<p v-else-if="pushUi.permission === 'denied'" class="form-note">
<i class="ph ph-bell-slash"></i>
Разрешение на уведомления запрещено — включите его в настройках сайта браузера.
</p>
<p v-else-if="pushUi.permission === 'unsupported'" class="form-note">
<i class="ph ph-bell-slash"></i>
Браузер не умеет web-push.
</p>
<div v-else class="push-body">
<div class="push-toggle">
<GnSwitch
:model-value="pushUi.on"
label="Получать пуши от Synapse"
:disabled="pushUi.busy"
@update:model-value="togglePush"
/>
<span class="form-note">{{ pushUi.on ? "Подписка этого браузера активна" : "События с адресатом именно вам (payload.user_id) будут открываться как системные уведомления" }}</span>
</div>
<p class="form-note" :title="DEVICE_TIP">
<i class="ph ph-monitor"></i>
{{ DEVICE_TIP }}
</p>
<div v-for="sub in subscriptions" :key="sub.id" class="device-row">
<i class="ph ph-monitor"></i>
<span class="device-name">{{ hostOf(sub.endpoint) }}</span>
<span class="device-meta">{{ fmtTime(sub.created_at) }}</span>
<GnIconButton icon="ph-x" label="Удалить подписку" @click="removeDevice(sub)" />
</div>
</div>
</div>
<!-- --- админ: конфиги поверх .env --- -->
<template v-if="isAdmin">
<div v-for="sec in sections" :key="sec.id" class="config-section">
<div class="section-title">
<i :class="['ph', sec.icon]"></i>
{{ sec.label }}
</div>
<div class="fields">
<template v-for="item in items.filter((i) => i.section === sec.id)" :key="item.key">
<GnSelect
v-if="item.options.length"
v-model="edit[item.key]"
:label="item.label"
:options="item.options.map((o) => ({ value: o, label: o }))"
:help="item.help"
/>
<GnInput
v-else
v-model="edit[item.key]"
:label="item.label"
:type="item.kind === 'secret' ? 'password' : item.kind === 'int' ? 'number' : 'text'"
:placeholder="item.kind === 'secret' && item.set ? '••••• задано — оставить пустым, чтобы не менять' : item.set ? item.value : 'не задано'"
:help="item.kind === 'secret' ? `write-only: ${item.help}` : item.help"
/>
</template>
</div>
</div>
<div class="save-row">
<GnButton
variant="primary"
icon="ph-arrows-clockwise"
:disabled="saving || !Object.keys(dirtyValues()).length"
@click="save"
>Перебить дефолт</GnButton>
<span class="form-note">Значения отсюда перекрывают .env на ходу — контейнер перезапускать не нужно</span>
</div>
</template>
</template>
</main>
</template>
<style scoped>
.page {
max-width: 900px;
margin-inline: auto;
padding-block: var(--gn-space-6);
display: flex;
flex-direction: column;
gap: var(--gn-space-5);
}
.section-title {
display: flex;
align-items: center;
gap: var(--gn-space-2);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.8em;
}
.config-section {
display: flex;
flex-direction: column;
gap: var(--gn-space-3);
}
.fields {
display: flex;
flex-direction: column;
gap: var(--gn-space-1);
}
.push-toggle,
.save-row {
display: flex;
align-items: center;
gap: var(--gn-space-4);
}
.device-row {
display: flex;
align-items: center;
gap: var(--gn-space-3);
font-size: 0.9em;
padding: var(--gn-space-2) 0;
border-top: 1px solid var(--gn-surface-divider, rgba(255, 255, 255, 0.06));
}
.device-row i {
opacity: 0.6;
}
.device-name {
flex: 1;
font-family: inherit;
}
.device-meta {
opacity: 0.55;
font-size: 0.9em;
}
.form-note {
display: flex;
align-items: center;
gap: var(--gn-space-2);
font-size: 0.82em;
opacity: 0.72;
margin: 0;
}
</style>