<!-- Настройки — общий раздел: у всех личный блок 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, patchMeLocale } from "../api.js";
import { fmtTime } from "../ui.js";
import { setLocale, t } from "../i18n/index.js";
import { currentSubscription, permissionState, pushSupported, subscribe, unsubscribe } from "../push.js";
const toast = useToast();
// --- личное: язык (все роли) ---
// Имена языков всегда родные (invariant к текущему языку) — конвенция i18n.md.
const LANGUAGE_OPTIONS = [
{ value: "auto", label: "Авто" },
{ value: "en", label: "English" },
{ value: "uk", label: "Українська" },
{ value: "ru", label: "Русский" },
];
// Override из сессии (locale); null/"" → auto
const localeChoice = ref("auto");
async function changeLocale(value) {
try {
const updated = await patchMeLocale(value);
session.value = updated;
setLocale(updated.locale_effective);
toast.success(t("settings.languageSaved"));
} catch (ex) {
toast.error(ex.message);
}
}
// --- общее ---
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(t("settings.toastDeviceRemoved"));
} catch (ex) {
toast.error(ex.message);
}
}
async function togglePush(next) {
pushUi.busy = true;
try {
if (next) {
if (!pushUi.configured) {
toast.error(t("settings.toastNotConfigured"));
return;
}
const sub = await subscribe(await (await api.pushConfig()).public_key);
if (sub === null) {
pushUi.permission = "denied";
toast.info(t("settings.toastPermissionDenied"));
return;
}
await api.saveSubscription(sub);
pushUi.permission = "granted";
toast.success(t("settings.toastPushEnabled"));
} 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(t("settings.toastPushDisabled"));
}
pushUi.on = Boolean(await currentSubscription());
await loadSubscriptions();
} catch (ex) {
toast.error(ex.message);
} finally {
pushUi.busy = false;
}
}
// --- админ-блок: оверрайды поверх .env ---
// язык может смениться на ходу — computed вместо module-const (см. i18n/SPEC)
const SECTIONS = computed(() => [
{ id: "routing", label: t("settings.sectionRouting"), icon: "ph-funnel" },
{ id: "push", label: t("settings.sectionPush"), icon: "ph-bell-ringing" },
{ id: "telegram", label: t("settings.sectionTelegram"), icon: "ph-telegram-logo" },
{ id: "smtp", label: t("settings.sectionSmtp"), 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.value.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(t("settings.toastSaved"));
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();
localeChoice.value = session.value?.locale || "auto";
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);
// подсказка про устройства — строка UI, поэтому computed (не module-const)
const DEVICE_TIP = computed(() => t("settings.deviceTip"));
</script>
<template>
<main class="container page">
<GnPageHeader
kicker="Synapse"
:title="t('settings.title')"
:subtitle="isAdmin ? t('settings.subtitleAdmin') : t('settings.subtitleUser')"
/>
<GnLoader v-if="loading" circle :label="t('settings.loading')" />
<GnStatusCard v-else-if="error" variant="error" icon="ph-warning-circle" :text="error" />
<template v-else>
<!-- --- личное: язык интерфейса (все роли; i18n.md хендбука) --- -->
<div class="push-panel">
<div class="section-title">{{ t("settings.language") }}</div>
<div class="form-row">
<GnSelect
v-model="localeChoice"
:label="t('settings.language')"
:options="LANGUAGE_OPTIONS"
@update:model-value="changeLocale"
/>
</div>
<p class="form-note">{{ t("settings.languageHelp") }}</p>
</div>
<!-- --- личное: push-подписка (все роли) --- -->
<div class="push-panel">
<div class="section-title">{{ t("settings.pushTitle") }}</div>
<GnStatusCard
v-if="!pushUi.supported"
variant="warning"
icon="ph-bell-slash"
:title="t('settings.pushUnsupportedTitle')"
:text="t('settings.pushUnsupportedText')"
/>
<GnStatusCard
v-else-if="!pushUi.configured"
variant="info"
icon="ph-bell"
:title="t('settings.pushNotConfiguredTitle')"
:text="t('settings.pushNotConfiguredText')"
/>
<p v-else-if="pushUi.permission === 'denied'" class="form-note">
<i class="ph ph-bell-slash"></i>
{{ t("settings.permissionDeniedNote") }}
</p>
<p v-else-if="pushUi.permission === 'unsupported'" class="form-note">
<i class="ph ph-bell-slash"></i>
{{ t("settings.permissionUnsupportedNote") }}
</p>
<div v-else class="push-body">
<div class="push-toggle">
<GnSwitch
:model-value="pushUi.on"
:label="t('settings.pushSwitchLabel')"
:disabled="pushUi.busy"
@update:model-value="togglePush"
/>
<span class="form-note">{{ pushUi.on ? t("settings.pushActiveNote") : t("settings.pushInactiveNote") }}</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="t('settings.removeDeviceLabel')" @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 ? t('settings.secretSetPlaceholder') : item.set ? item.value : t('settings.notSetPlaceholder')"
:help="item.kind === 'secret' ? t('settings.writeOnlyHelp', { text: 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"
>{{ t("settings.overrideButton") }}</GnButton>
<span class="form-note">{{ t("settings.overrideHint") }}</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>