Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions admin/src/api/google.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { api } from './client'

export interface GoogleOAuthStatus {
connected: boolean
google_email: string | null
scopes: string[]
}

export const googleApi = {
getAuthUrl: () =>
api.get<{ auth_url: string }>('/admin/google/auth-url'),

getStatus: () =>
api.get<GoogleOAuthStatus>('/admin/google/status'),

disconnect: () =>
api.post<{ status: string }>('/admin/google/disconnect'),
}
1 change: 1 addition & 0 deletions admin/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ export * from './kanban'
export * from './woocommerce'
export * from './workspace'
export * from './githubRepos'
export * from './google'
33 changes: 33 additions & 0 deletions admin/src/plugins/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1343,6 +1343,17 @@ const messages = {
profileUpdated: "Профиль обновлён",
guestReadOnly: "Гостевой аккаунт (только чтение)",
},
google: {
connectButton: "Подключить Google",
connectDescription: "Подключите Google для доступа к Диску, Документам, Таблицам и Gmail",
connectedAs: "Подключён как {email}",
disconnect: "Отключить",
disconnectTitle: "Отключить Google",
disconnectConfirm: "Вы уверены? Доступ к Google Диску, Документам и Gmail будет отключён.",
connected: "Google аккаунт подключён",
disconnected: "Google аккаунт отключён",
connectionFailed: "Не удалось подключить Google",
},
},
en: {
// Navigation
Expand Down Expand Up @@ -2684,6 +2695,17 @@ const messages = {
profileUpdated: "Profile updated",
guestReadOnly: "Guest account (read-only)",
},
google: {
connectButton: "Connect Google",
connectDescription: "Connect Google for Drive, Docs, Sheets and Gmail access",
connectedAs: "Connected as {email}",
disconnect: "Disconnect",
disconnectTitle: "Disconnect Google",
disconnectConfirm: "Are you sure? Access to Google Drive, Docs and Gmail will be revoked.",
connected: "Google account connected",
disconnected: "Google account disconnected",
connectionFailed: "Failed to connect Google",
},
},
kk: {
// Navigation
Expand Down Expand Up @@ -4025,6 +4047,17 @@ const messages = {
profileUpdated: "Профиль жаңартылды",
guestReadOnly: "Қонақ аккаунты (тек оқу)",
},
google: {
connectButton: "Google қосу",
connectDescription: "Google Drive, Docs, Sheets және Gmail қол жетімділігі үшін қосыңыз",
connectedAs: "{email} ретінде қосылған",
disconnect: "Ажырату",
disconnectTitle: "Google ажырату",
disconnectConfirm: "Сенімдісіз бе? Google Drive, Docs және Gmail қол жетімділігі ажыратылады.",
connected: "Google аккаунт қосылды",
disconnected: "Google аккаунт ажыратылды",
connectionFailed: "Google қосу сәтсіз аяқталды",
},
},
};

Expand Down
91 changes: 91 additions & 0 deletions admin/src/views/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
Lock,
Save
} from 'lucide-vue-next'
import { useRoute, useRouter } from 'vue-router'
import { useExportImport } from '@/composables/useExportImport'
import { googleApi, type GoogleOAuthStatus } from '@/api/google'
import { useAuditStore } from '@/stores/audit'
import { useAuthStore } from '@/stores/auth'
import { useThemeStore } from '@/stores/theme'
Expand Down Expand Up @@ -118,8 +120,60 @@ async function changePassword() {
}
}

// Google OAuth
const route = useRoute()
const router = useRouter()
const googleStatus = ref<GoogleOAuthStatus>({ connected: false, google_email: null, scopes: [] })
const googleLoading = ref(false)

async function loadGoogleStatus() {
try {
googleStatus.value = await googleApi.getStatus()
} catch {
// ignore
}
}

async function connectGoogle() {
googleLoading.value = true
try {
const { auth_url } = await googleApi.getAuthUrl()
window.location.href = auth_url
} catch {
toast.error('Failed to start Google auth')
googleLoading.value = false
}
}

async function disconnectGoogle() {
const ok = await confirm.confirm({
title: t('google.disconnectTitle'),
message: t('google.disconnectConfirm'),
confirmText: t('google.disconnect'),
type: 'danger'
})
if (!ok) return
try {
await googleApi.disconnect()
googleStatus.value = { connected: false, google_email: null, scopes: [] }
toast.success(t('google.disconnected'))
} catch {
toast.error('Error')
}
}

onMounted(() => {
loadProfile()
loadGoogleStatus()
// Handle OAuth callback redirect
if (route.query.google === 'connected') {
toast.success(t('google.connected'))
router.replace({ query: {} })
loadGoogleStatus()
} else if (route.query.google === 'error') {
toast.error(t('google.connectionFailed'))
router.replace({ query: {} })
}
})

// Format date for display
Expand Down Expand Up @@ -320,6 +374,43 @@ function toggleLocale() {
</button>
</div>
</div>

<!-- Google Account -->
<div class="bg-card rounded-xl border border-border p-4">
<h3 class="font-medium mb-3 flex items-center gap-2">
<svg class="w-5 h-5" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
Google
</h3>
<div v-if="googleStatus.connected" class="space-y-2">
<div class="flex items-center gap-2 text-sm">
<Check class="w-4 h-4 text-green-400" />
<span>{{ t('google.connectedAs', { email: googleStatus.google_email }) }}</span>
</div>
<div class="flex flex-wrap gap-1.5 text-xs text-muted-foreground">
<span v-if="googleStatus.scopes.some(s => s.includes('drive'))" class="px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400">Drive</span>
<span v-if="googleStatus.scopes.some(s => s.includes('documents'))" class="px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">Docs</span>
<span v-if="googleStatus.scopes.some(s => s.includes('spreadsheets'))" class="px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400">Sheets</span>
<span v-if="googleStatus.scopes.some(s => s.includes('gmail'))" class="px-2 py-0.5 rounded-full bg-red-500/10 text-red-400">Gmail</span>
</div>
<button
class="mt-2 px-3 py-1.5 text-sm bg-destructive/20 text-destructive rounded-lg hover:bg-destructive/30 transition-colors"
@click="disconnectGoogle"
>
{{ t('google.disconnect') }}
</button>
</div>
<div v-else>
<p class="text-sm text-muted-foreground mb-3">{{ t('google.connectDescription') }}</p>
<button
:disabled="googleLoading"
class="flex items-center gap-2 px-4 py-2 bg-white text-gray-700 rounded-lg hover:bg-gray-100 transition-colors font-medium text-sm border border-gray-300"
@click="connectGoogle"
>
<svg class="w-4 h-4" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.27-4.74 3.27-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
{{ t('google.connectButton') }}
</button>
</div>
</div>
</div>

<!-- General Settings -->
Expand Down
24 changes: 23 additions & 1 deletion admin/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'

// API path segments that should be proxied to the orchestrator.
// Everything else under /admin/ is served by Vite (SPA, HMR, assets).
const apiSegments = [
'auth', 'chat', 'wiki-rag', 'telegram', 'whatsapp', 'widget', 'mobile',
'faq', 'roles', 'workspace', 'backup', 'legal', 'llm', 'tts', 'stt',
'services', 'voices', 'voice', 'models', 'logs', 'finetune', 'tts-finetune',
'gsm', 'kanban', 'claude-code', 'github-webhook', 'github-repos', 'audit',
'usage', 'monitor', 'deployment-mode', 'amocrm', 'woocommerce', 'bot-sales',
'resource-shares', 'yoomoney', 'google',
]
const apiRegex = new RegExp(`^/admin/(${apiSegments.join('|')})(/|$)`)

export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
const isDemo = env.VITE_DEMO_MODE === 'true'
Expand All @@ -19,7 +31,13 @@ export default defineConfig(({ mode }) => {
proxy: {
'/admin': {
target: 'http://localhost:8002',
changeOrigin: true
changeOrigin: true,
bypass(req) {
// Only proxy known API paths; let Vite handle SPA, assets, HMR
if (!req.url || !apiRegex.test(req.url)) {
return req.url
}
}
},
'/v1': {
target: 'http://localhost:8002',
Expand All @@ -28,6 +46,10 @@ export default defineConfig(({ mode }) => {
'/health': {
target: 'http://localhost:8002',
changeOrigin: true
},
'/webhooks': {
target: 'http://localhost:8002',
changeOrigin: true
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""add google_oauth_tokens table

Revision ID: ed1d201ecb55
Revises: f145b30530c0
Create Date: 2026-03-23 23:52:45.375702
"""

from typing import Sequence, Union

import sqlalchemy as sa

from alembic import op


revision: str = "ed1d201ecb55"
down_revision: Union[str, None] = "f145b30530c0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.create_table(
"google_oauth_tokens",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("access_token", sa.Text(), nullable=False),
sa.Column("refresh_token", sa.Text(), nullable=True),
sa.Column("token_expiry", sa.DateTime(), nullable=True),
sa.Column("scopes", sa.Text(), nullable=False),
sa.Column("google_email", sa.String(255), nullable=True),
sa.Column("created", sa.DateTime(), nullable=True),
sa.Column("updated", sa.DateTime(), nullable=True),
)
op.create_index("ix_google_oauth_tokens_user_id", "google_oauth_tokens", ["user_id"])


def downgrade() -> None:
op.drop_table("google_oauth_tokens")
4 changes: 4 additions & 0 deletions app/routers/google.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from modules.google.router import callback_router, router


__all__ = ["callback_router", "router"]
Loading
Loading