Newer
Older
hard-panel / panel / 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 { router } from './router.js'

// GnNavList захватывает $route одним снапшотом в setup, и с vue-router 4
// (объект маршрута заменяется при навигации) класс active «застывает».
// Поэтому активность считаем сами через реактивный useRoute и отдаём киту
// флагом item.active: без item.to кит рендерит <a> и берёт именно наш флаг,
// а переходы делает наш onSelect (preventDefault + router.push).
const route = useRoute()

const navItems = computed(() => [
  {
    label: 'Dashboard', icon: 'ph-squares-four', href: '/',
    active: route.path === '/' || route.path.startsWith('/servers/'),
    onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
  },
  {
    label: 'Storage', icon: 'ph-hard-drives', href: '/storage',
    active: route.path.startsWith('/storage'),
    onSelect: (item, event) => { event.preventDefault(); router.push(item.href) },
  },
])

// --- Шлюз входа (как в gnexus-creds): splash → /me → панель или экран входа.
// OAuth стартует по клику пользователя — без тихого авто-редиректа на page load
// (иначе gnexus-auth молча подхватит ещё живую сессию вместо явного входа).
const authState = ref('checking') // checking | denied | ready
const me = ref(null)
const userInitials = ref('')

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()
    me.value = data.user
    if (!data.auth_enabled || !data.user) {
      // панель открыта вовсе (gnexus-auth не настроен)
      authState.value = 'ready'
    } else {
      userInitials.value = initials(data.user.display_name, data.user.email)
      authState.value = 'ready'
    }
  } catch (err) {
    if (err?.status === 401) {
      authState.value = 'denied'
    } else {
      // сеть/бэкенд упал — продолжаем с экраном входа, /auth/login вернёт обратно
      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('ghard:unauthenticated', onUnauthenticated)
})

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

<template>
  <GnToastProvider :lifetime="4000">
    <!-- splash, пока /me не ответил: лого + спиннер, чтобы shell не мигал -->
    <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">Sign in</h1>
        <p class="login-screen-subtitle">GHard Monitor is protected by gnexus-auth</p>
        <GnButton variant="primary" icon="ph ph-sign-in" @click="loginNow">Sign in</GnButton>
      </article>
    </div>

    <GnNavigationShell
      v-else
      brand="GHard Monitor"
      logo-src="/logo.svg"
      current="Gnexus Hardware Monitor"
      title="Monitoring"
      subtitle="GHard"
    >
      <template #default="{ close }">
        <GnNavList :items="navItems" @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="Sign out"
          @click="logoutNow"
        />
      </template>

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

<style>
/* Шлюз входа: полноэкранная карточка с одной кнопкой; OAuth начинается
   с клика, не с загрузки страницы (как в gnexus-creds) */
.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;
}

/* splash, пока /me не ответил: лого + спиннер, ничего лишнего */
.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>