Newer
Older
gn-synapse / frontend / src / views / SettingsView.vue
<!-- Настройки — общий раздел: у всех личный блок 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>