feat: Add Google Device Authorization Flow for Antigravity native mode
Some checks failed
Release Binaries / release (push) Has been cancelled
Some checks failed
Release Binaries / release (push) Has been cancelled
- Implemented proper OAuth device flow using gcloud CLI client ID - Added /api/antigravity/device-auth/start endpoint - Added /api/antigravity/device-auth/poll endpoint with polling - Added /api/antigravity/device-auth/refresh for token renewal - Updated AntigravitySettings UI with user code display - Auto-opens Google sign-in page and polls for completion - Seamless authentication experience matching SDK mode
This commit is contained in:
@@ -11,6 +11,30 @@ interface AntigravityRouteDeps {
|
|||||||
// Maximum number of tool execution loops
|
// Maximum number of tool execution loops
|
||||||
const MAX_TOOL_LOOPS = 10
|
const MAX_TOOL_LOOPS = 10
|
||||||
|
|
||||||
|
// Google OAuth Device Flow configuration
|
||||||
|
// Using the same client ID as gcloud CLI / Cloud SDK
|
||||||
|
const GOOGLE_OAUTH_CONFIG = {
|
||||||
|
clientId: "764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com",
|
||||||
|
clientSecret: "d-FL95Q19q7MQmFpd7hHD0Ty", // Public client secret for device flow
|
||||||
|
deviceAuthEndpoint: "https://oauth2.googleapis.com/device/code",
|
||||||
|
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||||
|
scopes: [
|
||||||
|
"openid",
|
||||||
|
"email",
|
||||||
|
"profile",
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active device auth sessions (in-memory, per-server instance)
|
||||||
|
const deviceAuthSessions = new Map<string, {
|
||||||
|
deviceCode: string
|
||||||
|
userCode: string
|
||||||
|
verificationUrl: string
|
||||||
|
expiresAt: number
|
||||||
|
interval: number
|
||||||
|
}>()
|
||||||
|
|
||||||
export async function registerAntigravityRoutes(
|
export async function registerAntigravityRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
deps: AntigravityRouteDeps
|
deps: AntigravityRouteDeps
|
||||||
@@ -65,6 +89,193 @@ export async function registerAntigravityRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Google Device Authorization Flow Endpoints
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Step 1: Start device authorization - returns user_code and verification URL
|
||||||
|
app.post('/api/antigravity/device-auth/start', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
logger.info("Starting Google Device Authorization flow for Antigravity")
|
||||||
|
|
||||||
|
const response = await fetch(GOOGLE_OAUTH_CONFIG.deviceAuthEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
||||||
|
scope: GOOGLE_OAUTH_CONFIG.scopes.join(' ')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text()
|
||||||
|
logger.error({ error, status: response.status }, "Device auth request failed")
|
||||||
|
return reply.status(500).send({ error: "Failed to start device authorization" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as {
|
||||||
|
device_code: string
|
||||||
|
user_code: string
|
||||||
|
verification_url: string
|
||||||
|
expires_in: number
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a session ID for tracking this auth flow
|
||||||
|
const sessionId = crypto.randomUUID()
|
||||||
|
|
||||||
|
// Store the session
|
||||||
|
deviceAuthSessions.set(sessionId, {
|
||||||
|
deviceCode: data.device_code,
|
||||||
|
userCode: data.user_code,
|
||||||
|
verificationUrl: data.verification_url,
|
||||||
|
expiresAt: Date.now() + (data.expires_in * 1000),
|
||||||
|
interval: data.interval
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up expired sessions
|
||||||
|
for (const [id, session] of deviceAuthSessions) {
|
||||||
|
if (session.expiresAt < Date.now()) {
|
||||||
|
deviceAuthSessions.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({ sessionId, userCode: data.user_code }, "Device auth session created")
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
userCode: data.user_code,
|
||||||
|
verificationUrl: data.verification_url,
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
interval: data.interval
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to start device authorization")
|
||||||
|
return reply.status(500).send({ error: "Failed to start device authorization" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 2: Poll for token (called by client after user enters code)
|
||||||
|
app.post('/api/antigravity/device-auth/poll', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { sessionId } = request.body as { sessionId: string }
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return reply.status(400).send({ error: "Missing sessionId" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = deviceAuthSessions.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
return reply.status(404).send({ error: "Session not found or expired" })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.expiresAt < Date.now()) {
|
||||||
|
deviceAuthSessions.delete(sessionId)
|
||||||
|
return reply.status(410).send({ error: "Session expired" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll Google's token endpoint
|
||||||
|
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
||||||
|
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
|
||||||
|
device_code: session.deviceCode,
|
||||||
|
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json() as any
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
// Still waiting for user
|
||||||
|
if (data.error === 'authorization_pending') {
|
||||||
|
return { status: 'pending', interval: session.interval }
|
||||||
|
}
|
||||||
|
// Slow down polling
|
||||||
|
if (data.error === 'slow_down') {
|
||||||
|
session.interval = Math.min(session.interval + 5, 60)
|
||||||
|
return { status: 'pending', interval: session.interval }
|
||||||
|
}
|
||||||
|
// User denied or other error
|
||||||
|
if (data.error === 'access_denied') {
|
||||||
|
deviceAuthSessions.delete(sessionId)
|
||||||
|
return { status: 'denied' }
|
||||||
|
}
|
||||||
|
if (data.error === 'expired_token') {
|
||||||
|
deviceAuthSessions.delete(sessionId)
|
||||||
|
return { status: 'expired' }
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error({ error: data.error }, "Token poll error")
|
||||||
|
return { status: 'error', error: data.error }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success! We have tokens
|
||||||
|
deviceAuthSessions.delete(sessionId)
|
||||||
|
|
||||||
|
logger.info("Device authorization successful")
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
tokenType: data.token_type,
|
||||||
|
scope: data.scope
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to poll for token")
|
||||||
|
return reply.status(500).send({ error: "Failed to poll for token" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh an expired token
|
||||||
|
app.post('/api/antigravity/device-auth/refresh', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { refreshToken } = request.body as { refreshToken: string }
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
return reply.status(400).send({ error: "Missing refreshToken" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(GOOGLE_OAUTH_CONFIG.tokenEndpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
||||||
|
client_secret: GOOGLE_OAUTH_CONFIG.clientSecret,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: 'refresh_token'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text()
|
||||||
|
logger.error({ error }, "Token refresh failed")
|
||||||
|
return reply.status(401).send({ error: "Token refresh failed" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: data.access_token,
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
tokenType: data.token_type
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to refresh token")
|
||||||
|
return reply.status(500).send({ error: "Failed to refresh token" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Chat completion endpoint WITH MCP TOOL SUPPORT
|
// Chat completion endpoint WITH MCP TOOL SUPPORT
|
||||||
app.post('/api/antigravity/chat', async (request, reply) => {
|
app.post('/api/antigravity/chat', async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
@@ -124,7 +335,7 @@ export async function registerAntigravityRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info("Antigravity routes registered with MCP tool support - Google OAuth required!")
|
logger.info("Antigravity routes registered with Google Device Auth flow!")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, createSignal, onMount, For, Show } from 'solid-js'
|
import { Component, createSignal, onMount, onCleanup, For, Show } from 'solid-js'
|
||||||
import { Rocket, CheckCircle, XCircle, Loader, Sparkles, Key, LogOut, Shield, Terminal, Copy, ExternalLink } from 'lucide-solid'
|
import { Rocket, CheckCircle, XCircle, Loader, Sparkles, LogIn, LogOut, Shield, ExternalLink, Copy } from 'lucide-solid'
|
||||||
import { getUserScopedKey } from '../../lib/user-storage'
|
import { getUserScopedKey } from '../../lib/user-storage'
|
||||||
|
|
||||||
interface AntigravityModel {
|
interface AntigravityModel {
|
||||||
@@ -29,10 +29,18 @@ const AntigravitySettings: Component = () => {
|
|||||||
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
const [connectionStatus, setConnectionStatus] = createSignal<'idle' | 'testing' | 'connected' | 'failed'>('idle')
|
||||||
const [authStatus, setAuthStatus] = createSignal<'unknown' | 'authenticated' | 'unauthenticated'>('unknown')
|
const [authStatus, setAuthStatus] = createSignal<'unknown' | 'authenticated' | 'unauthenticated'>('unknown')
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
const [showTokenInput, setShowTokenInput] = createSignal(false)
|
|
||||||
const [tokenInput, setTokenInput] = createSignal('')
|
// Device auth state
|
||||||
|
const [isAuthenticating, setIsAuthenticating] = createSignal(false)
|
||||||
|
const [deviceAuthSession, setDeviceAuthSession] = createSignal<{
|
||||||
|
sessionId: string
|
||||||
|
userCode: string
|
||||||
|
verificationUrl: string
|
||||||
|
} | null>(null)
|
||||||
const [copied, setCopied] = createSignal(false)
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
|
||||||
|
let pollInterval: number | undefined
|
||||||
|
|
||||||
// Check stored token on mount
|
// Check stored token on mount
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
checkAuthStatus()
|
checkAuthStatus()
|
||||||
@@ -40,6 +48,12 @@ const AntigravitySettings: Component = () => {
|
|||||||
await testConnection()
|
await testConnection()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const getStoredToken = (): AntigravityToken | null => {
|
const getStoredToken = (): AntigravityToken | null => {
|
||||||
if (typeof window === "undefined") return null
|
if (typeof window === "undefined") return null
|
||||||
try {
|
try {
|
||||||
@@ -101,45 +115,148 @@ const AntigravitySettings: Component = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTokenSubmit = () => {
|
// Start device authorization flow
|
||||||
const token = tokenInput().trim()
|
const startDeviceAuth = async () => {
|
||||||
if (!token) {
|
setIsAuthenticating(true)
|
||||||
setError('Please enter a valid token')
|
setError(null)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenData: AntigravityToken = {
|
const response = await fetch('/api/antigravity/device-auth/start', {
|
||||||
access_token: token,
|
method: 'POST'
|
||||||
expires_in: 3600, // 1 hour default
|
})
|
||||||
created_at: Date.now()
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to start authentication')
|
||||||
}
|
}
|
||||||
|
|
||||||
window.localStorage.setItem(
|
const data = await response.json() as {
|
||||||
getUserScopedKey(ANTIGRAVITY_TOKEN_KEY),
|
sessionId: string
|
||||||
JSON.stringify(tokenData)
|
userCode: string
|
||||||
)
|
verificationUrl: string
|
||||||
|
expiresIn: number
|
||||||
|
interval: number
|
||||||
|
}
|
||||||
|
|
||||||
setAuthStatus('authenticated')
|
setDeviceAuthSession({
|
||||||
setShowTokenInput(false)
|
sessionId: data.sessionId,
|
||||||
setTokenInput('')
|
userCode: data.userCode,
|
||||||
setError(null)
|
verificationUrl: data.verificationUrl
|
||||||
loadModels()
|
})
|
||||||
} catch (err) {
|
|
||||||
setError('Failed to save token')
|
// Start polling for token
|
||||||
|
const pollIntervalMs = (data.interval || 5) * 1000
|
||||||
|
pollInterval = window.setInterval(() => {
|
||||||
|
pollForToken(data.sessionId)
|
||||||
|
}, pollIntervalMs)
|
||||||
|
|
||||||
|
// Open verification URL in new tab
|
||||||
|
window.open(data.verificationUrl, '_blank')
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Device auth error:', err)
|
||||||
|
setError(err.message || 'Authentication failed')
|
||||||
|
setIsAuthenticating(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Poll for token completion
|
||||||
|
const pollForToken = async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/antigravity/device-auth/poll', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
if (response.status === 410 || response.status === 404) {
|
||||||
|
// Session expired
|
||||||
|
stopPolling()
|
||||||
|
setError('Session expired. Please try again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(errorData.error || 'Poll failed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any
|
||||||
|
|
||||||
|
if (data.status === 'pending') {
|
||||||
|
// Still waiting, continue polling
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
// Got tokens! Save them
|
||||||
|
const token: AntigravityToken = {
|
||||||
|
access_token: data.accessToken,
|
||||||
|
refresh_token: data.refreshToken,
|
||||||
|
expires_in: data.expiresIn,
|
||||||
|
created_at: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(
|
||||||
|
getUserScopedKey(ANTIGRAVITY_TOKEN_KEY),
|
||||||
|
JSON.stringify(token)
|
||||||
|
)
|
||||||
|
|
||||||
|
stopPolling()
|
||||||
|
setAuthStatus('authenticated')
|
||||||
|
setError(null)
|
||||||
|
loadModels()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'denied') {
|
||||||
|
stopPolling()
|
||||||
|
setError('Access was denied. Please try again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'expired') {
|
||||||
|
stopPolling()
|
||||||
|
setError('Session expired. Please try again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.status === 'error') {
|
||||||
|
stopPolling()
|
||||||
|
setError(data.error || 'Authentication failed')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Poll error:', err)
|
||||||
|
// Don't stop polling on network errors, just log them
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = undefined
|
||||||
|
}
|
||||||
|
setIsAuthenticating(false)
|
||||||
|
setDeviceAuthSession(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelAuth = () => {
|
||||||
|
stopPolling()
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
const signOut = () => {
|
const signOut = () => {
|
||||||
window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
|
window.localStorage.removeItem(getUserScopedKey(ANTIGRAVITY_TOKEN_KEY))
|
||||||
setAuthStatus('unauthenticated')
|
setAuthStatus('unauthenticated')
|
||||||
}
|
}
|
||||||
|
|
||||||
const copyCommand = async () => {
|
const copyCode = async () => {
|
||||||
const command = 'npx opencode-antigravity-auth login'
|
const session = deviceAuthSession()
|
||||||
await navigator.clipboard.writeText(command)
|
if (session) {
|
||||||
setCopied(true)
|
await navigator.clipboard.writeText(session.userCode)
|
||||||
setTimeout(() => setCopied(false), 2000)
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatNumber = (num: number): string => {
|
const formatNumber = (num: number): string => {
|
||||||
@@ -199,7 +316,7 @@ const AntigravitySettings: Component = () => {
|
|||||||
<h3 class="font-semibold text-purple-300 mb-1">Premium AI Models</h3>
|
<h3 class="font-semibold text-purple-300 mb-1">Premium AI Models</h3>
|
||||||
<p class="text-sm text-zinc-300">
|
<p class="text-sm text-zinc-300">
|
||||||
Antigravity provides access to Gemini 3 Pro/Flash, Claude Sonnet 4.5, Claude Opus 4.5,
|
Antigravity provides access to Gemini 3 Pro/Flash, Claude Sonnet 4.5, Claude Opus 4.5,
|
||||||
and GPT-OSS 120B through Google's infrastructure.
|
and GPT-OSS 120B through Google's infrastructure. Sign in with your Google account to get started.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,11 +328,11 @@ const AntigravitySettings: Component = () => {
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Shield class="w-5 h-5 text-zinc-400" />
|
<Shield class="w-5 h-5 text-zinc-400" />
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-medium text-white">Authentication</h4>
|
<h4 class="font-medium text-white">Google Authentication</h4>
|
||||||
<p class="text-xs text-zinc-500">
|
<p class="text-xs text-zinc-500">
|
||||||
{authStatus() === 'authenticated'
|
{authStatus() === 'authenticated'
|
||||||
? 'Token configured - you can use Antigravity models'
|
? 'You are signed in and can use Antigravity models'
|
||||||
: 'Configure authentication to access premium models'}
|
: 'Sign in with Google to access premium models'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,89 +341,86 @@ const AntigravitySettings: Component = () => {
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/20 text-emerald-400 rounded-lg text-sm">
|
<span class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/20 text-emerald-400 rounded-lg text-sm">
|
||||||
<CheckCircle class="w-4 h-4" />
|
<CheckCircle class="w-4 h-4" />
|
||||||
Configured
|
Authenticated
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
|
class="flex items-center gap-2 px-3 py-1.5 text-sm text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<LogOut class="w-4 h-4" />
|
<LogOut class="w-4 h-4" />
|
||||||
Clear
|
Sign out
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Not authenticated - show login button or device auth flow */}
|
||||||
<Show when={authStatus() === 'unauthenticated'}>
|
<Show when={authStatus() === 'unauthenticated'}>
|
||||||
{/* SDK Mode Instructions */}
|
<Show when={!deviceAuthSession()}>
|
||||||
<div class="bg-blue-500/5 border border-blue-500/20 rounded-lg p-4 space-y-3">
|
<button
|
||||||
<div class="flex items-center gap-2 text-blue-400">
|
onClick={startDeviceAuth}
|
||||||
<Terminal class="w-4 h-4" />
|
disabled={isAuthenticating()}
|
||||||
<span class="font-medium text-sm">Recommended: Use OpenCode SDK Mode</span>
|
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-purple-600 hover:bg-purple-500 disabled:bg-purple-600/50 text-white rounded-lg transition-colors"
|
||||||
</div>
|
>
|
||||||
<p class="text-xs text-zinc-400">
|
{isAuthenticating() ? (
|
||||||
For the best experience, switch to OpenCode SDK mode in General settings.
|
<>
|
||||||
The Antigravity plugin handles authentication automatically via Google OAuth.
|
<Loader class="w-5 h-5 animate-spin" />
|
||||||
</p>
|
Starting authentication...
|
||||||
<div class="flex items-center gap-2">
|
</>
|
||||||
<code class="flex-1 px-3 py-2 bg-zinc-900 rounded text-xs font-mono text-zinc-300">
|
) : (
|
||||||
npx opencode-antigravity-auth login
|
<>
|
||||||
</code>
|
<LogIn class="w-5 h-5" />
|
||||||
<button
|
Sign in with Google
|
||||||
onClick={copyCommand}
|
</>
|
||||||
class="p-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded transition-colors"
|
)}
|
||||||
title="Copy command"
|
</button>
|
||||||
>
|
</Show>
|
||||||
{copied() ? <CheckCircle class="w-4 h-4 text-emerald-400" /> : <Copy class="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Manual Token Entry */}
|
{/* Device auth in progress - show code */}
|
||||||
<div class="border-t border-zinc-800 pt-4">
|
<Show when={deviceAuthSession()}>
|
||||||
<Show when={!showTokenInput()}>
|
<div class="bg-purple-500/10 border border-purple-500/30 rounded-lg p-4 space-y-4">
|
||||||
<button
|
<div class="text-center">
|
||||||
onClick={() => setShowTokenInput(true)}
|
<p class="text-sm text-zinc-300 mb-3">
|
||||||
class="flex items-center gap-2 text-sm text-zinc-400 hover:text-white transition-colors"
|
Enter this code on the Google sign-in page:
|
||||||
>
|
</p>
|
||||||
<Key class="w-4 h-4" />
|
<div class="flex items-center justify-center gap-3">
|
||||||
Or enter access token manually...
|
<code class="px-6 py-3 bg-zinc-900 rounded-lg text-2xl font-mono font-bold text-white tracking-widest">
|
||||||
</button>
|
{deviceAuthSession()?.userCode}
|
||||||
</Show>
|
</code>
|
||||||
|
|
||||||
<Show when={showTokenInput()}>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="block text-sm font-medium text-zinc-300">
|
|
||||||
Access Token
|
|
||||||
</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={tokenInput()}
|
|
||||||
onInput={(e) => setTokenInput(e.currentTarget.value)}
|
|
||||||
placeholder="Paste your Google OAuth access token..."
|
|
||||||
class="flex-1 px-3 py-2 bg-zinc-900 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-purple-500"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleTokenSubmit}
|
onClick={copyCode}
|
||||||
class="px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-sm transition-colors"
|
class="p-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
|
||||||
|
title="Copy code"
|
||||||
>
|
>
|
||||||
Save
|
{copied() ? <CheckCircle class="w-5 h-5 text-emerald-400" /> : <Copy class="w-5 h-5" />}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => { setShowTokenInput(false); setTokenInput(''); }}
|
|
||||||
class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-zinc-500">
|
|
||||||
You can get an access token by running the Antigravity auth plugin in a terminal,
|
|
||||||
or by extracting it from an authenticated Google Cloud session.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
</div>
|
<div class="flex items-center justify-center gap-2 text-sm text-purple-300">
|
||||||
|
<Loader class="w-4 h-4 animate-spin" />
|
||||||
|
Waiting for you to complete sign-in...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
href={deviceAuthSession()?.verificationUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink class="w-4 h-4" />
|
||||||
|
Open Google Sign-in
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={cancelAuth}
|
||||||
|
class="px-4 py-2 text-zinc-400 hover:text-white bg-zinc-800 hover:bg-zinc-700 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -387,7 +501,7 @@ const AntigravitySettings: Component = () => {
|
|||||||
|
|
||||||
<Show when={!isLoading() && models().length === 0}>
|
<Show when={!isLoading() && models().length === 0}>
|
||||||
<div class="text-center py-12 text-zinc-500">
|
<div class="text-center py-12 text-zinc-500">
|
||||||
<p>Models will be available after configuration.</p>
|
<p>Models will be available after signing in.</p>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,26 +510,13 @@ const AntigravitySettings: Component = () => {
|
|||||||
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4">
|
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-4">
|
||||||
<h4 class="font-medium text-white mb-2">How to Use</h4>
|
<h4 class="font-medium text-white mb-2">How to Use</h4>
|
||||||
<ul class="text-sm text-zinc-400 space-y-1">
|
<ul class="text-sm text-zinc-400 space-y-1">
|
||||||
<li>• <strong>SDK Mode (Recommended):</strong> Switch to OpenCode binary mode and install the antigravity auth plugin</li>
|
<li>• Click "Sign in with Google" and enter the code on the Google page</li>
|
||||||
<li>• <strong>Native Mode:</strong> Enter your access token manually above</li>
|
<li>• Once authenticated, select any Antigravity model from the chat model picker</li>
|
||||||
<li>• Select any Antigravity model from the model picker in chat</li>
|
|
||||||
<li>• Models include Gemini 3, Claude Sonnet/Opus 4.5, and GPT-OSS</li>
|
<li>• Models include Gemini 3, Claude Sonnet/Opus 4.5, and GPT-OSS</li>
|
||||||
<li>• Thinking-enabled models show step-by-step reasoning</li>
|
<li>• Thinking-enabled models show step-by-step reasoning</li>
|
||||||
|
<li>• Full tool use and MCP support included</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Plugin Link */}
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<a
|
|
||||||
href="https://github.com/NoeFabris/opencode-antigravity-auth"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="flex items-center gap-2 text-sm text-purple-400 hover:text-purple-300 transition-colors"
|
|
||||||
>
|
|
||||||
<ExternalLink class="w-4 h-4" />
|
|
||||||
View Antigravity Auth Plugin on GitHub
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user