Newer
Older
tgclient-mcp / frontend / src / App.vue
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import {
  GnAvatar, GnButton, GnIconButton, GnIdentity, GnLoader,
  GnNavList, GnNavigationShell, GnToastProvider,
} from 'gnexus-ui-kit/vue'

import { api } from './api.js'
import { setLocale, t } from './i18n/index.js'
import { router } from './router.js'

// GnNavList захватывает $route одним снапшотом в setup, и с vue-router 4
// класс active «застывает» (грабля hard-panel). Активность считаем сами через
// реактивный useRoute и отдаём флагом item.active; переходы — наш onSelect.
const route = useRoute()

const navItems = computed(() => [
  {
    label: t('nav.accounts'), icon: 'ph-chat-circle-dots', href: '/accounts',
    active: route.path.startsWith('/accounts'),
    onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
  },
  {
    label: t('nav.mcpKeys'), icon: 'ph-key', href: '/keys',
    active: route.path.startsWith('/keys'),
    onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
  },
  {
    label: t('nav.settings'), icon: 'ph-gear', href: '/settings',
    active: route.path.startsWith('/settings'),
    onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
  },
  {
    // видна только админам; AdminPage ещё раз проверяет роль из /me
    label: t('nav.admin'), icon: 'ph-users', href: '/admin',
    active: route.path.startsWith('/admin'),
    onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
  },
])

// Язык, пока SPA открыт (SettingsPage меняет его через /me или localStorage).
function bootLocale(data) {
  if (data.auth_enabled && data.user) {
    // язык аккаунта с перекрытием из настроек — бэкенд считает сам
    setLocale(data.locale_effective)
    return
  }
  // открытый режим / не вошли — выбор хранится в этом браузере
  setLocale(localStorage.getItem('tgclient.locale'))
}

// --- Шлюз входа (как в hard-panel): splash → /me → сервис или экран входа.
// OAuth стартует по клику — без тихого авто-редиректа на page load.
const authState = ref('checking') // checking | denied | ready
const me = ref(null)
const userInitials = ref('')
const isAdmin = computed(() =>
  ['admin', 'superadmin'].includes(me.value?.role))

function initials(name, email) {
  const base = (name || email || '?').trim()
  return base.slice(0, 2).toUpperCase()
}

async function loadMe() {
  authState.value = 'checking'
  try {
    const data = await api.me()
    bootLocale(data)
    me.value = data.user
    // auth-off (auth_enabled false) — сервис открыт, всё открываем
    authState.value = 'ready'
  } catch {
    // 401 (нет сессии) или сеть — экран входа; /auth/login вернёт обратно
    setLocale(localStorage.getItem('tgclient.locale'))
    authState.value = 'denied'
  }
}

function loginNow() {
  // клик начинает OAuth; return_to — текущий маршрут, чтобы вернуться сюда
  const to = window.location.pathname + window.location.search
  window.location.href = '/auth/login?return_to=' + encodeURIComponent(to)
}

async function logoutNow() {
  await api.logout()
  me.value = null
  authState.value = 'denied'
}

const onUnauthenticated = () => {
  me.value = null
  authState.value = 'denied'
}

onMounted(() => {
  loadMe()
  window.addEventListener('tgclient:unauthenticated', onUnauthenticated)
})

onBeforeUnmount(() => {
  window.removeEventListener('tgclient:unauthenticated', onUnauthenticated)
})
</script>

<template>
  <GnToastProvider :lifetime="4000">
    <!-- splash, пока /me не ответил: лого + спиннер -->
    <div v-if="authState === 'checking'" class="login-screen" aria-hidden="true">
      <article class="card login-screen-card">
        <img src="/logo.svg" alt="" class="login-screen-logo" />
        <GnLoader circle class="login-screen-loader" />
      </article>
    </div>

    <!-- экран входа: OAuth начинается с клика -->
    <div v-else-if="authState === 'denied'" class="login-screen">
      <article class="card login-screen-card">
        <img src="/logo.svg" alt="" class="login-screen-logo" />
        <h1 class="login-screen-title">{{ t('login.title') }}</h1>
        <p class="login-screen-subtitle">{{ t('login.subtitle') }}</p>
        <GnButton variant="primary" icon="ph ph-sign-in" @click="loginNow">{{ t('login.action') }}</GnButton>
      </article>
    </div>

    <GnNavigationShell
      v-else
      brand="tgclient"
      logo-src="/logo.svg"
      current="tgclient"
      :title="t('shell.title')"
      :subtitle="t('shell.subtitle')"
    >
      <template #default="{ close }">
        <GnNavList
          :items="isAdmin ? navItems : navItems.filter((item) => item.href !== '/admin')"
          @select="close"
        />
      </template>

      <template #footer>
        <span v-if="me" class="profile-identity">
          <GnIdentity
            :title="me.display_name || me.email"
            :meta="me.email"
            :avatar="{ src: me.avatar_url || '', initials: userInitials, size: 'sm', variant: 'primary' }"
          />
        </span>
        <GnIconButton
          v-if="me"
          icon="ph ph-sign-out"
          :label="t('nav.signOut')"
          @click="logoutNow"
        />
      </template>

      <template #content>
        <div class="app-page">
          <router-view />
        </div>
      </template>
    </GnNavigationShell>
  </GnToastProvider>
</template>

<style>
/* Шлюз входа: полноэкранная карточка с одной кнопкой (стили hard-panel) */
.login-screen {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
}

.login-screen-card {
  width: 100%;
  max-width: 380px;
  padding: 40px 32px;
  text-align: center;
  min-width: 0;
}

.login-screen-logo {
  width: 56px;
  height: 56px;
  margin: 0 auto 18px;
  display: block;
}

.login-screen-title {
  margin-bottom: 6px;
}

.login-screen-subtitle {
  margin-bottom: 22px;
}

.login-screen-loader {
  margin: 0 auto;
  justify-content: center;
}

/* identity текущего пользователя в футере дровера */
.profile-identity {
  display: block;
  color: inherit;
  min-width: 0;
}

/* Служебный layout-контейнер страницы (стили кита не дублируем) */
.app-page {
  max-width: 1200px;
  margin: 0 auto;
  padding: 24px 24px 64px;
  width: 100%;
  box-sizing: border-box;
}

/* .tabs у кита — flex-wrap: панели занимают собственную строку */
.tabs .tabs-panels {
  flex-basis: 100%;
}
</style>