diff --git a/docs/TZ.md b/docs/TZ.md index e37d6eb..b423ff1 100644 --- a/docs/TZ.md +++ b/docs/TZ.md @@ -4,8 +4,8 @@ | | | |---|---| -| Версия ТЗ | 0.33 | -| Дата | 2026-09-21 | +| Версия ТЗ | 0.34 | +| Дата | 2026-09-22 | | Статус | На обсуждении | --- @@ -18,9 +18,13 @@ ### 1.2. Пользователи -- Один пользователь (владелец). -- Авторизация — через централизованный SSO-сервер **auth.gnexus.space** с клиентской библиотекой **gnexus-gauth** (см. 6.4). -- Косвенные «пользователи» — ИИ-агенты, действующие от имени владельца через MCP. +- Несколько пользователей: данные (задачи, проекты, теги, документы, сад, XP/монеты, + настройки) полностью изолированы по `user_id` — один пользователь не видит чужого. +- Авторизация — через централизованный SSO-сервер **auth.gnexus.space** с клиентской библиотекой **gnexus-gauth** (см. 6.4). Пользователь создаётся в БД при первом входе (email, avatar, locale — из SSO). +- **Claim при первом логине** (миграция на мультиюзерность): все строки с + `user_id = NULL` (наследие однопользовательской версии) и глобальные настройки + забираются первым вошедшим пользователем. +- Косвенные «пользователи» — ИИ-агенты, действующие от имени своего пользователя через MCP (3.10). ### 1.3. Развёртывание @@ -188,11 +192,15 @@ диапазоны приоритета); - ошибки подсказывают следующий шаг («call list_projects()», «найдите id через list_tasks(query=...)»); ответ `create_task` без проекта содержит подсказку. -- В «Настройках» — ссылка на страницу-инструкцию по MCP (`/mcp-help`): что реализует, - адрес сервера (совпадает с адресом приложения), токен, примеры подключения - (Claude Code, универсальный JSON). -- Аутентификация агентов — через SSO-механизм (токен, выданный централизованной системой; детали после предоставления данных SSO). -- Агент действует от имени пользователя; отдельной мультитенантности нет. +- В «Настройках» — раздел **«Токены MCP»**: генерация и отзыв персональных токенов + (label + plaintext `gnt_...`, показывается один раз при создании; в БД только + sha256-хэш). Там же ссылка на страницу-инструкцию по MCP (`/mcp-help`): что + реализует, адрес сервера (совпадает с адресом приложения), токен, примеры + подключения (Claude Code, универсальный JSON). +- Аутентификация агентов — **per-user токены** (с 0.34): Bearer-токен из «Токенов MCP» + → агент действует от имени своего пользователя и видит только его данные. + Переменная окружения `MCP_TOKEN` удалена — старые конфиги агентов получат 401, + пока владелец не создаст токен в «Настройках». - Закрытие задачи агентом (`update_task`/`complete_task`) идёт по тому же общему пути, что и в UI: спавн регулярной, XP, монеты, растение (3.5, 3.13); в открытых вкладках — скромный тост о награде (3.14). @@ -433,7 +441,16 @@ ## 5. Модель данных (черновик) +Мультиюзерность (с 0.34): на задачах, проектах, тегах, документах, XP/монетах, +саде и настройках есть `user_id` (nullable — NULL забирается первым вошедшим, +см. 1.2); все запросы API/сервисов/MCP-тулов скоупятся по нему. + ``` +User — пользователь SSO: id (= user_id от SSO), email, avatar_url, + locale, created_at +McpToken — персональный MCP-токен (3.10): user_id, token_hash (sha256, + уникален), label, created_at; plaintext показывается один раз + Task id, title description markdown — живёт в Document (owner_type = task) @@ -483,6 +500,11 @@ ref_id (→ xp_events для растений; уникально в паре с kind), item_key (вид растения / ключ декорации), x, y (ячейки сетки), stage (0 росток, 1 куст, 2 цветение) + +AppSetting — per-user настройки (валюта, язык): PK (user_id, key), value +AppSettingGlobal — таблица-наследие однопользовательской версии + (бывшая app_settings); источник claim при первом логине, + позже удаляется ``` История завершённых задач (фактическое время) — источник для прогнозирования длительности. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c577a0c..7bec8d6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,6 +5,7 @@ import { api, type Task } from './api' import { resolveLocale } from './i18n/locale' import { startRealtime } from './realtime' +import { setStorageUserId } from './userScope' import { setCurrency } from './taskui' import GamifyBridge from './components/GamifyBridge.vue' import TaskForm from './components/TaskForm.vue' @@ -61,6 +62,9 @@ if (me) { user.value = me.user accountUrl.value = me.account_url ?? '' + // localStorage (фильтры, дедуп тостов) — скоуп по пользователю: второй + // аккаунт в том же браузере не видит чужих настроек вида + setStorageUserId(me.user.user_id) // SSE-события (ТЗ 3.14): только для авторизованных — иначе EventSource // будет бесконечно ловить 401 startRealtime() diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3163ddc..2ee0770 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -118,6 +118,17 @@ export type AppSettingsUpdate = Partial +// Per-user MCP-токены (ТЗ 3.10): plaintext показывается один раз при генерации +export interface McpTokenInfo { + id: number + label: string + created_at: string +} + +export interface McpTokenCreated extends McpTokenInfo { + token: string +} + // Сад-сцена (ТЗ 3.13): элементы, каталоги, магазин export interface GardenItem { id: number @@ -309,6 +320,15 @@ getSettings: () => request('/api/settings'), updateSettings: (patch: AppSettingsUpdate) => request('/api/settings', { method: 'PUT', body: JSON.stringify(patch) }), + // per-user MCP-токены (ТЗ 3.10) + listMcpTokens: () => request('/api/mcp-tokens'), + createMcpToken: (label: string) => + request('/api/mcp-tokens', { + method: 'POST', + body: JSON.stringify({ label }), + }), + deleteMcpToken: (id: number) => + request<{ ok: boolean }>(`/api/mcp-tokens/${id}`, { method: 'DELETE' }), // геймификация (ТЗ 3.13) getXp: () => request('/api/xp'), getXpEvents: () => request('/api/xp/events'), diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 79a3ffe..5006ab3 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -433,7 +433,18 @@ langRu: 'Русский', langEn: 'English', langUk: 'Українська', - mcpHint: 'AI agents (e.g. Claude) can work with your tasks via MCP.', + mcpTokenTitle: 'MCP tokens', + mcpTokenHint: + 'A token for an AI agent (e.g. Claude): the agent acts on your behalf and sees only your data. The value is shown once at creation — copy it right away into the agent config.', + mcpTokenCreated: 'Token created. It will never be shown again — copy it now.', + mcpTokenShown: 'I saved the token', + mcpTokenCopyFailed: 'Could not copy — select and copy manually', + mcpTokenUnnamed: 'Unnamed', + mcpTokenDelete: 'Revoke', + mcpTokenNone: 'No tokens yet — create one to connect an agent.', + mcpTokenLabel: 'Label (e.g. "laptop")', + mcpTokenLabelPlaceholder: 'what this agent is for', + mcpTokenCreate: 'Generate', mcpOpen: 'How to connect', }, mcp: { @@ -458,7 +469,8 @@ connectUrlLabel: 'Server address', connectTokenLabel: 'Access token', connectTokenText: - 'Authorization is a bearer token: the MCP_TOKEN setting in the backend config (environment variable / .env; never committed). One token for all agents — treat it like a password: anyone holding it can manage your tasks.', + 'The agent acts on your behalf. Create a personal token in Settings — the “MCP tokens” section — and paste it here.', + connectClaudeLabel: 'Claude Code — one command', connectJsonLabel: 'Universal JSON (Claude Desktop and other clients)', copy: 'Copy', diff --git a/frontend/src/locales/ru.ts b/frontend/src/locales/ru.ts index 7a05081..6c6790e 100644 --- a/frontend/src/locales/ru.ts +++ b/frontend/src/locales/ru.ts @@ -434,7 +434,18 @@ langRu: 'Русский', langEn: 'English', langUk: 'Українська', - mcpHint: 'ИИ-агенты (например, Claude) могут работать с вашими задачами через MCP.', + mcpTokenTitle: 'Токены MCP', + mcpTokenHint: + 'Токен для ИИ-агента (например, Claude): агент действует от вашего имени и видит только ваши данные. Значение показывается один раз при создании — сразу скопируйте и вставьте в конфиг агента.', + mcpTokenCreated: 'Токен создан. Больше он показан не будет — скопируйте сейчас.', + mcpTokenShown: 'Я сохранил токен', + mcpTokenCopyFailed: 'Не удалось скопировать — выделите и скопируйте вручную', + mcpTokenUnnamed: 'Без названия', + mcpTokenDelete: 'Отозвать', + mcpTokenNone: 'Токенов пока нет — создайте первый, чтобы подключить агента.', + mcpTokenLabel: 'Название (например, «ноутбук»)', + mcpTokenLabelPlaceholder: 'для чего этот агент', + mcpTokenCreate: 'Сгенерировать', mcpOpen: 'Как подключить', }, mcp: { @@ -459,7 +470,8 @@ connectUrlLabel: 'Адрес сервера', connectTokenLabel: 'Токен доступа', connectTokenText: - 'Авторизация — bearer-токен: значение настройки MCP_TOKEN в конфиге backend (переменная окружения, в .env; в репозиторий не попадает). Один токен для всех агентов — храните его как пароль: любой, у кого он есть, может управлять задачами.', + 'Агент действует от вашего имени. Создайте персональный токен в Настройках — раздел «Токены MCP» — и вставьте его сюда.', + connectClaudeLabel: 'Claude Code — одна команда', connectJsonLabel: 'Универсальный JSON (Claude Desktop и другие клиенты)', copy: 'Скопировать', diff --git a/frontend/src/locales/uk.ts b/frontend/src/locales/uk.ts index f60bab3..fc1e6f9 100644 --- a/frontend/src/locales/uk.ts +++ b/frontend/src/locales/uk.ts @@ -433,7 +433,18 @@ langRu: 'Русский', langEn: 'English', langUk: 'Українська', - mcpHint: "ІІ-агенти (наприклад, Claude) можуть працювати з вашими задачами через MCP.", + mcpTokenTitle: 'Токени MCP', + mcpTokenHint: + 'Токен для ІІ-агента (наприклад, Claude): агент діє від вашого імені та бачить лише ваші дані. Значення показується один раз під час створення — одразу скопіюйте його в конфіг агента.', + mcpTokenCreated: 'Токен створено. Він більше не буде показаний — скопіюйте зараз.', + mcpTokenShown: 'Я зберіг токен', + mcpTokenCopyFailed: 'Не вдалося скопіювати — виділіть і скопіюйте вручну', + mcpTokenUnnamed: 'Без назви', + mcpTokenDelete: 'Відкликати', + mcpTokenNone: 'Токенів поки немає — створіть перший, щоб підключити агента.', + mcpTokenLabel: 'Назва (наприклад, «ноутбук»)', + mcpTokenLabelPlaceholder: 'для чого цей агент', + mcpTokenCreate: 'Згенерувати', mcpOpen: 'Як підключити', }, mcp: { @@ -458,7 +469,8 @@ connectUrlLabel: 'Адреса сервера', connectTokenLabel: 'Токен доступу', connectTokenText: - 'Авторизація — bearer-токен: значення налаштування MCP_TOKEN у конфізі backend (змінна середовища, у .env; до репозиторію не потрапляє). Один токен для всіх агентів — зберігайте його як пароль: будь-хто, хто ним володіє, може керувати задачами.', + 'Агент діє від вашого імені. Створіть персональний токен у Налаштуваннях — розділ «Токени MCP» — і вставте його сюди.', + connectClaudeLabel: 'Claude Code — одна команда', connectJsonLabel: 'Універсальний JSON (Claude Desktop та інші клієнти)', copy: 'Копіювати', diff --git a/frontend/src/realtime.ts b/frontend/src/realtime.ts index 97686a5..b2cc444 100644 --- a/frontend/src/realtime.ts +++ b/frontend/src/realtime.ts @@ -3,6 +3,7 @@ // Reconnect: браузер сам переподключает EventSource, но при CLOSED-состоянии // (сессия истекла / сеть лежит долго) включается backoff 1→2→5→15 с. import { getCurrentScope, onScopeDispose } from 'vue' +import { scopedKey } from './userScope' export interface RealtimeEvent { kind: string @@ -103,12 +104,12 @@ // Дедуп XP-тостов (окно 3 с): собственные действия празднуются по заголовкам // ответа (celebrateEarned), SSE-эхо и эхо соседней вкладки молчат. -const OWN_ACTIVITY_KEY = 'gntodo:own-activity' +// Ключ — скоуп пользователя (userScope): смена аккаунта не читает чужой дедуп. const OWN_ACTIVITY_WINDOW = 3000 export function markOwnActivity(): void { try { - localStorage.setItem(OWN_ACTIVITY_KEY, String(Date.now())) + localStorage.setItem(scopedKey('own-activity'), String(Date.now())) } catch { // приватный режим — дедуп не работает, тосты могут задвоиться } @@ -116,7 +117,7 @@ export function wasOwnActivity(): boolean { try { - const ts = Number(localStorage.getItem(OWN_ACTIVITY_KEY)) + const ts = Number(localStorage.getItem(scopedKey('own-activity'))) return ts > 0 && Date.now() - ts < OWN_ACTIVITY_WINDOW } catch { return false diff --git a/frontend/src/userScope.ts b/frontend/src/userScope.ts new file mode 100644 index 0000000..e6cb8c0 --- /dev/null +++ b/frontend/src/userScope.ts @@ -0,0 +1,14 @@ +// Скоуп localStorage по пользователю (ТЗ 1.2): в одном браузере может смениться +// аккаунт — фильтры проектов и дедуп тостов не должны перетекать между людьми. +// id ставится в App.vue после /auth/me; до этого (гость, оффлайн-старт PWA) +// ключи остаются старыми без префикса — запись и чтение совпадают. + +let currentUserId = '' + +export function setStorageUserId(userId: string): void { + currentUserId = userId +} + +export function scopedKey(name: string): string { + return currentUserId ? `gntodo:u:${currentUserId}:${name}` : `gntodo:${name}` +} \ No newline at end of file diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 13d6a04..2ada970 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -6,6 +6,7 @@ import { celebrateEarned } from '../gamification' import { api, ApiError, type Attachment, type Project, type Tag, type Task } from '../api' import { useRealtimeRefresh } from '../realtime' +import { scopedKey } from '../userScope' import { deadlineOverdue, gradeToPriority, priorityOptions, priorityToGrade, priorityLabel, priorityVariant, relevanceLabel, renderMarkdown, setPageTitle, statusOptions } from '../taskui' import PriorityBadge from '../components/PriorityBadge.vue' import MdEditor from '../components/MdEditor.vue' @@ -42,11 +43,12 @@ }) // Фильтры списка: статус (по умолчанию «К выполнению»), тег. Выбор пользователя -// запоминается в localStorage с привязкой к проекту — вернувшись, видит те же -// фильтры; в приватном окне/при очищенных данных просто работают дефолты. +// запоминается в localStorage с привязкой к проекту и пользователю — вернувшись, +// видит те же фильтры; в приватном окне/при очищенных данных просто работают +// дефолты. const filters = ref({ status: 'to_do', tag_id: '' }) -const filtersKey = computed(() => `gntodo:project-filters:${projectId.value}`) +const filtersKey = computed(() => scopedKey(`project-filters:${projectId.value}`)) function loadStoredFilters() { try { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 3298b1a..cda572e 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -3,11 +3,11 @@ import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { useToast } from 'gnexus-ui-kit/vue' -import { api } from '../api' +import { api, type McpTokenInfo } from '../api' import { setLocale } from '../i18n/locale' import { currencyOptions, setCurrency } from '../taskui' -// Глобальные настройки: валюта бюджетов и язык интерфейса. +// Настройки пользователя: валюта бюджетов, язык интерфейса, MCP-токены. const { t } = useI18n() const toast = useToast() @@ -18,6 +18,14 @@ const error = ref('') const saving = ref(false) +// --- Токены MCP (ТЗ 3.10) --- +const tokens = ref([]) +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') }, @@ -25,6 +33,14 @@ { value: 'uk', label: t('settings.langUk') }, ]) +async function loadTokens() { + try { + tokens.value = await api.listMcpTokens() + } catch { + // не авторизован / сеть — список просто пуст + } +} + onMounted(async () => { try { const s = await api.getSettings() @@ -33,6 +49,7 @@ } catch (e) { error.value = String(e) } + void loadTokens() }) async function save() { @@ -56,6 +73,53 @@ 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 + } +} @@ -109,4 +222,50 @@ .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; +} \ No newline at end of file