Newer
Older
gnexus-tasks / frontend / src / views / SettingsView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'gnexus-ui-kit/vue'
import { api, type McpTokenInfo } from '../api'
import { setLocale } from '../i18n/locale'
import { currencyOptions, setCurrency } from '../taskui'

// Настройки пользователя: валюта бюджетов, язык интерфейса, MCP-токены.

const { t } = useI18n()
const toast = useToast()
const router = useRouter()

const currency = ref('UAH')
const language = ref('') // '' = как в аккаунте (SSO)
const error = ref('')
const saving = ref(false)

// --- Токены MCP (ТЗ 3.10) ---
const tokens = ref<McpTokenInfo[]>([])
const newTokenLabel = ref('')
const creatingToken = ref(false)
// plaintext только что созданного токена — показывается один раз до закрытия
const freshToken = ref('')
const tokenCopied = ref(false)

const languageOptions = computed(() => [
  { value: '', label: t('settings.languageAuto') },
  { value: 'ru', label: t('settings.langRu') },
  { value: 'en', label: t('settings.langEn') },
  { value: 'uk', label: t('settings.langUk') },
])

async function loadTokens() {
  try {
    tokens.value = await api.listMcpTokens()
  } catch {
    // не авторизован / сеть — список просто пуст
  }
}

onMounted(async () => {
  try {
    const s = await api.getSettings()
    currency.value = s.currency
    language.value = s.language
  } catch (e) {
    error.value = String(e)
  }
  void loadTokens()
})

async function save() {
  error.value = ''
  saving.value = true
  try {
    const s = await api.updateSettings({ currency: currency.value, language: language.value })
    setCurrency(s.currency)
    // язык применяется сразу; без переопределения — SSO locale или ru
    if (s.language === '') {
      const me = await fetch('/auth/me').then((r) => (r.ok ? r.json() : null))
      setLocale(me?.user?.locale || 'ru')
    } else {
      setLocale(s.language)
    }
    language.value = s.language
    toast.success({ title: t('common.saved') })
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  } finally {
    saving.value = false
  }
}

async function createToken() {
  if (creatingToken.value) return
  creatingToken.value = true
  try {
    const created = await api.createMcpToken(newTokenLabel.value.trim())
    freshToken.value = created.token
    newTokenLabel.value = ''
    tokenCopied.value = false
    await loadTokens()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  } finally {
    creatingToken.value = false
  }
}

async function copyFreshToken() {
  try {
    await navigator.clipboard.writeText(freshToken.value)
    tokenCopied.value = true
  } catch {
    toast.error({ title: t('common.error'), text: t('settings.mcpTokenCopyFailed') })
  }
}

function closeFreshToken() {
  freshToken.value = ''
  tokenCopied.value = false
}

async function revokeToken(id: number) {
  try {
    await api.deleteMcpToken(id)
    await loadTokens()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

function formatDate(iso: string): string {
  try {
    return new Date(iso).toLocaleDateString()
  } catch {
    return iso
  }
}
</script>

<template>
  <section>
    <GnPageHeader kicker="GNexus Tasks" :title="t('settings.title')" />

    <GnAlert v-if="error" variant="error">{{ error }}</GnAlert>

    <GnCard>
      <template #title>{{ t('settings.currencyTitle') }}</template>
      <p class="hint">{{ t('settings.currencyHint') }}</p>
      <GnSelect v-model="currency" icon="ph-currency-circle-dollar" :options="currencyOptions()" />
      <div class="form-actions">
        <GnButton variant="primary" icon="ph-floppy-disk" :disabled="saving" @click="save">
          {{ t('common.save') }}
        </GnButton>
      </div>
    </GnCard>

    <GnCard>
      <template #title>{{ t('settings.languageTitle') }}</template>
      <p class="hint">{{ t('settings.languageHint') }}</p>
      <GnSelect v-model="language" icon="ph-globe-hemisphere-west" :options="languageOptions" />
      <div class="form-actions">
        <GnButton variant="primary" icon="ph-floppy-disk" :disabled="saving" @click="save">
          {{ t('common.save') }}
        </GnButton>
      </div>
    </GnCard>

    <!-- MCP для агентов (ТЗ 3.10): per-user токены + страница-инструкция -->
    <GnCard>
      <template #title>{{ t('settings.mcpTokenTitle') }}</template>
      <p class="hint">{{ t('settings.mcpTokenHint') }}</p>

      <!-- Одноразовый показ plaintext: пока окно не закрыли, можно скопировать -->
      <div v-if="freshToken" class="fresh-token">
        <p class="hint">{{ t('settings.mcpTokenCreated') }}</p>
        <code class="fresh-token-value">{{ freshToken }}</code>
        <div class="fresh-token-actions">
          <GnButton variant="secondary" icon="ph-copy" @click="copyFreshToken">
            {{ tokenCopied ? t('mcp.copied') : t('mcp.copy') }}
          </GnButton>
          <GnButton variant="primary" @click="closeFreshToken">
            {{ t('settings.mcpTokenShown') }}
          </GnButton>
        </div>
      </div>

      <ul v-if="tokens.length" class="token-list">
        <li v-for="tok in tokens" :key="tok.id" class="token-row">
          <span class="token-label">{{ tok.label || t('settings.mcpTokenUnnamed') }}</span>
          <span class="token-date">{{ formatDate(tok.created_at) }}</span>
          <GnButton
            variant="ghost"
            icon="ph-trash"
            :aria-label="t('settings.mcpTokenDelete')"
            @click="revokeToken(tok.id)"
          >
            {{ t('settings.mcpTokenDelete') }}
          </GnButton>
        </li>
      </ul>
      <p v-else class="hint">{{ t('settings.mcpTokenNone') }}</p>

      <div class="token-create">
        <GnInput
          v-model="newTokenLabel"
          :label="t('settings.mcpTokenLabel')"
          :placeholder="t('settings.mcpTokenLabelPlaceholder')"
        />
        <GnButton
          variant="primary"
          icon="ph-plus"
          :disabled="creatingToken"
          @click="createToken"
        >
          {{ t('settings.mcpTokenCreate') }}
        </GnButton>
      </div>

      <div class="form-actions">
        <GnButton variant="secondary" icon="ph-plugs-connected" @click="router.push('/mcp-help')">
          {{ t('settings.mcpOpen') }}
        </GnButton>
      </div>
    </GnCard>
  </section>
</template>

<style scoped>
.hint {
  color: var(--muted, #888);
  margin: 0 0 0.75rem;
}
/* карточки кита не имеют собственного вертикального отступа */
.card + .card {
  margin-top: 1.5rem;
}
.form-actions {
  margin-top: 0.75rem;
}
.token-list {
  list-style: none;
  margin: 0 0 1rem;
  padding: 0;
}
.token-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.5rem 0;
  border-bottom: 1px solid var(--border, #e0e0e0);
}
.token-label {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.token-date {
  color: var(--muted, #888);
  font-size: 0.85rem;
}
.token-create {
  display: flex;
  align-items: flex-end;
  gap: 0.75rem;
}
.fresh-token {
  margin-bottom: 1rem;
  padding: 0.75rem;
  border: 1px solid var(--border, #e0e0e0);
  border-radius: 8px;
}
.fresh-token-value {
  display: block;
  margin-bottom: 0.75rem;
  padding: 0.5rem;
  background: var(--surface-muted, #f5f5f5);
  border-radius: 6px;
  overflow-wrap: anywhere;
  user-select: all;
}
.fresh-token-actions {
  display: flex;
  gap: 0.75rem;
}
</style>