diff --git a/dashboard/app/api/auth/route.ts b/dashboard/app/api/auth/route.ts index 182cae7..0a9f739 100644 --- a/dashboard/app/api/auth/route.ts +++ b/dashboard/app/api/auth/route.ts @@ -1,4 +1,4 @@ -import { NextResponse } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { cookies } from 'next/headers' import { createHash } from 'crypto' @@ -31,12 +31,19 @@ function validateSession(sessionToken: string): boolean { } // GET - Check auth status -export async function GET() { +export async function GET(request: NextRequest) { // If auth is disabled, always return authenticated if (process.env.DISABLE_AUTH === 'true') { return NextResponse.json({ authenticated: true }) } + // Check for token auth from headers (passed from URL params by client) + const headerToken = request.headers.get('X-Tinybird-Token') + const headerHost = request.headers.get('X-Tinybird-Host') + if (headerToken && headerHost) { + return NextResponse.json({ authenticated: true }) + } + const cookieStore = await cookies() const sessionCookie = cookieStore.get(SESSION_COOKIE_NAME) diff --git a/dashboard/app/api/config/route.ts b/dashboard/app/api/config/route.ts index 01eb491..28aba45 100644 --- a/dashboard/app/api/config/route.ts +++ b/dashboard/app/api/config/route.ts @@ -1,5 +1,5 @@ -import { NextResponse } from 'next/server' -import { getTinybirdConfig, getWorkspace } from '@/lib/server' +import { NextRequest, NextResponse } from 'next/server' +import { getTinybirdConfig, getWorkspace, getWorkspaceWithCredentials } from '@/lib/server' interface TinybirdRegionInfo { provider: string @@ -13,7 +13,23 @@ interface TinybirdRegionResponse { api_host: string } -// Cache for regions to avoid repeated API calls +// Try to extract workspace name from JWT token payload +function extractWorkspaceNameFromToken(token: string): string | null { + try { + const payload = token.split('.')[1] + if (!payload) return null + const base64 = + payload.replace(/-/g, '+').replace(/_/g, '/') + + '==='.slice((payload.length + 3) % 4) + const json = Buffer.from(base64, 'base64').toString() + const data = JSON.parse(json) + // Skip 'frontend_jwt' as it's just a token label, not workspace name + const name = data.workspace_name || (data.name !== 'frontend_jwt' ? data.name : null) + return name || null + } catch { + return null + } +} async function fetchTinybirdRegions(): Promise { try { @@ -64,8 +80,15 @@ async function getRegionInfoFromHost( return { provider: 'Unknown', region: 'unknown' } } -export async function GET() { - const { token, host } = getTinybirdConfig() +export async function GET(request: NextRequest) { + // Check for token from headers first (public mode), then fall back to env vars + const headerToken = request.headers.get('X-Tinybird-Token') + const headerHost = request.headers.get('X-Tinybird-Host') + const headerWorkspace = request.headers.get('X-Tinybird-Workspace') + const { token: envToken, host: envHost } = getTinybirdConfig() + + const token = headerToken || envToken + const host = headerHost || envHost const missing: string[] = [] if (!token) missing.push('TINYBIRD_TOKEN') @@ -75,11 +98,26 @@ export async function GET() { // Include workspace info if configured const regionInfo = host ? await getRegionInfoFromHost(host) : null - const tinybirdWorkspace = configured ? await getWorkspace() : null + + // Use workspace name from header if provided (avoids permission issues with scoped JWT) + // Otherwise fetch using credentials + let workspaceName = headerWorkspace + if (!workspaceName && configured) { + const tinybirdWorkspace = headerToken && headerHost + ? await getWorkspaceWithCredentials(headerToken, headerHost) + : await getWorkspace() + workspaceName = tinybirdWorkspace?.name || null + } + + // If still no workspace name and we have a token, try to decode it + if (!workspaceName && headerToken) { + workspaceName = extractWorkspaceNameFromToken(headerToken) + } + const workspace = configured && host && regionInfo ? { - name: tinybirdWorkspace?.name || 'Unknown', + name: workspaceName || 'Unknown', provider: regionInfo.provider, region: regionInfo.region, } diff --git a/dashboard/app/api/endpoints/[name]/route.ts b/dashboard/app/api/endpoints/[name]/route.ts index f0727eb..060317b 100644 --- a/dashboard/app/api/endpoints/[name]/route.ts +++ b/dashboard/app/api/endpoints/[name]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { getServerClient, getTinybirdConfig } from '@/lib/server' +import { getServerClient, getTinybirdConfig, createClientWithCredentials } from '@/lib/server' const VALID_ENDPOINTS = [ 'currentVisitors', @@ -44,7 +44,13 @@ export async function GET( ) } - const { token, host } = getTinybirdConfig() + // Check for token from headers first (public mode), then fall back to env vars + const headerToken = request.headers.get('X-Tinybird-Token') + const headerHost = request.headers.get('X-Tinybird-Host') + const { token: envToken, host: envHost } = getTinybirdConfig() + + const token = headerToken || envToken + const host = headerHost || envHost if (!token || !host) { const missing = [] @@ -60,7 +66,10 @@ export async function GET( ) } - const client = getServerClient() + // Use header credentials if provided, otherwise use server client + const client = headerToken && headerHost + ? createClientWithCredentials(headerToken, headerHost) + : getServerClient() const searchParams = request.nextUrl.searchParams const queryParams: Record = {} diff --git a/dashboard/app/api/sql/route.ts b/dashboard/app/api/sql/route.ts index 9a31c1a..a125568 100644 --- a/dashboard/app/api/sql/route.ts +++ b/dashboard/app/api/sql/route.ts @@ -14,7 +14,13 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'SQL query required' }, { status: 400 }) } - const { token, host } = getTinybirdConfig() + // Check for token from headers first (public mode), then fall back to env vars + const headerToken = request.headers.get('X-Tinybird-Token') + const headerHost = request.headers.get('X-Tinybird-Host') + const { token: envToken, host: envHost } = getTinybirdConfig() + + const token = headerToken || envToken + const host = headerHost || envHost if (!token || !host) { const missing = [] diff --git a/dashboard/app/page.tsx b/dashboard/app/page.tsx index aa5b856..3764b1f 100644 --- a/dashboard/app/page.tsx +++ b/dashboard/app/page.tsx @@ -19,7 +19,7 @@ import { useInsightsData } from '@/lib/hooks/use-insights-data' import useCurrentVisitors from '@/lib/hooks/use-current-visitors' import React from 'react' import { Header } from '@/components/Header' -import LoginDialog from '@/components/LoginDialog' +import AuthDialog from '@/components/AuthDialog' import { useLogin } from '@/lib/hooks/use-login' export default function DashboardPage() { @@ -39,7 +39,7 @@ export default function DashboardPage() { return ( {/* Show login dialog overlay when not logged in */} - {showLoginDialog && } + {showLoginDialog && } <> {process.env.NODE_ENV === 'production' && ( diff --git a/dashboard/components/AuthDialog.tsx b/dashboard/components/AuthDialog.tsx new file mode 100644 index 0000000..70c97d2 --- /dev/null +++ b/dashboard/components/AuthDialog.tsx @@ -0,0 +1,411 @@ +'use client' + +import { FormEvent, useState } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/Dialog' +import { Input } from '@/components/ui/Input' +import { Button } from '@/components/ui/Button' +import { Select } from '@/components/ui/Select' +import { Link } from '@/components/ui/Link' +import { Text } from '@/components/ui/Text' +import { Loader } from '@/components/ui/Loader' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs' +import { SignJWT } from 'jose' + +type AuthMode = 'signin' | 'token' + +const regionMap: Record = { + 'gcp-europe-west2': 'https://api.europe-west2.gcp.tinybird.co', + eu_shared: 'https://api.tinybird.co', + us_east: 'https://api.us-east.tinybird.co', + 'gcp-northamerica-northeast2': + 'https://api.northamerica-northeast2.gcp.tinybird.co', + 'us-east-aws': 'https://api.us-east.aws.tinybird.co', + 'aws-us-west-2': 'https://api.us-west-2.aws.tinybird.co', + 'aws-eu-central-1': 'https://api.eu-central-1.aws.tinybird.co', + 'aws-eu-west-1': 'https://api.eu-west-1.aws.tinybird.co', + localhost: 'http://127.0.0.1:8001', + local: 'http://localhost:7181', +} + +const regionValues = Object.values(regionMap) + +const extractHostFromToken = (token: string): string | undefined => { + try { + const payload = token.split('.')[1] + if (!payload) return undefined + const base64 = + payload.replace(/-/g, '+').replace(/_/g, '/') + + '==='.slice((payload.length + 3) % 4) + const json = atob(base64) + const data = JSON.parse(json) + return data.host + } catch { + return undefined + } +} + +export const extractWorkspaceIdFromToken = ( + token: string +): string | undefined => { + try { + const payload = token.split('.')[1] + if (!payload) return undefined + const base64 = + payload.replace(/-/g, '+').replace(/_/g, '/') + + '==='.slice((payload.length + 3) % 4) + const json = atob(base64) + const data = JSON.parse(json) + return data.u + } catch { + return undefined + } +} + +export async function createJwt( + token: string, + tenant_id: string +): Promise { + const expiration_time = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60 + const workspace_id = extractWorkspaceIdFromToken(token) + const resources = [ + 'domains', + 'top_sources', + 'top_devices', + 'kpis', + 'top_locations', + 'top_browsers', + 'top_pages', + 'trend', + 'domain', + 'current_visitors', + 'web_vitals_current', + 'web_vitals_routes', + 'web_vitals_distribution', + 'web_vitals_events', + 'web_vitals_timeseries', + 'analytics_hits', + 'actions', + ] + + const datasources_resources = [ + 'analytics_events', + 'tenant_actions_mv', + 'tenant_domains_mv', + ] + + const filter = tenant_id ? `tenant_id = '${tenant_id}'` : '' + const fixed_params = tenant_id ? { tenant_id } : {} + const datasources_scopes = datasources_resources.map(resource => ({ + type: 'DATASOURCES:READ', + resource, + filter, + })) + + const payload = { + workspace_id: workspace_id, + name: 'frontend_jwt', + exp: expiration_time, + scopes: [ + ...resources.map(resource => ({ + type: 'PIPES:READ', + resource, + fixed_params, + })), + ...datasources_scopes, + ], + } + const key = new TextEncoder().encode(token) + return await new SignJWT(payload as any) + .setProtectedHeader({ alg: 'HS256' }) + .setExpirationTime(expiration_time) + .sign(key) +} + +interface AuthDialogProps { + isLoading?: boolean + defaultMode?: AuthMode +} + +export default function AuthDialog({ + isLoading: isCheckingAuth, + defaultMode = 'signin', +}: AuthDialogProps) { + const [mode, setMode] = useState(defaultMode) + + // Sign in state + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [signInError, setSignInError] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + // Token state + const [hostUrl, setHostUrl] = useState(regionValues[0]) + const [hostName, setHostName] = useState('') + const [tenantId, setTenantId] = useState('') + const [tokenHasHost, setTokenHasHost] = useState(true) + + async function handleSignIn(e: React.FormEvent) { + e.preventDefault() + setSignInError('') + setIsSubmitting(true) + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + + if (response.ok) { + window.location.reload() + } else { + const data = await response.json() + setSignInError(data.error || 'Invalid credentials') + setIsSubmitting(false) + } + } catch { + setSignInError('An error occurred. Please try again.') + setIsSubmitting(false) + } + } + + const handleTokenChange = (e: React.ChangeEvent) => { + const token = e.target.value + const hostKey = extractHostFromToken(token) + + if (hostKey) { + setTokenHasHost(true) + if (regionMap[hostKey]) { + setHostUrl(regionMap[hostKey]) + setHostName('') + } else { + setHostUrl('other') + setHostName(hostKey) + } + } else { + setTokenHasHost(false) + setHostUrl(regionValues[0]) + setHostName('') + } + } + + const handleTokenSubmit = async (event: FormEvent) => { + event.preventDefault() + const form = event.currentTarget + const formData = new FormData(form) + const token = formData.get('token') as string + const host = hostUrl === 'other' ? hostName : hostUrl + const tenant_id = formData.get('tenant_id') as string + + if (!token || (hostUrl === 'other' && !hostName)) return + + // Fetch workspace info using the admin token before creating scoped JWT + let workspaceName: string | undefined + try { + const workspaceUrl = new URL('/v1/workspace', host) + const response = await fetch(workspaceUrl.toString(), { + headers: { Authorization: `Bearer ${token}` }, + }) + if (response.ok) { + const data = await response.json() + workspaceName = data.name + } + } catch { + // Workspace fetch failed, continue without it + } + + const jwt = await createJwt(token, tenant_id || '') + + // Navigate to URL with token params (including workspace name) + const url = new URL(window.location.href) + url.searchParams.set('token', jwt) + url.searchParams.set('host', host) + if (tenant_id) url.searchParams.set('tenant_id', tenant_id) + if (workspaceName) url.searchParams.set('workspace', workspaceName) + window.location.href = url.toString() + } + + const hostOptions = [ + ...regionValues.map(value => ({ + value, + label: value, + })), + { value: 'other', label: 'Other' }, + ] + + if (isCheckingAuth) { + return ( +
+ +
+ ) + } + + return ( + + + + Access Dashboard + + Sign in with your credentials or use a Tinybird token directly. + + + + setMode(value as AuthMode)}> + + Sign in + Tinybird Token + + + +
+
+
+ + setUsername(e.target.value)} + placeholder="Enter username" + required + autoComplete="off" + autoFocus + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter password" + required + autoComplete="new-password" + /> + {!!signInError && ( + + {signInError} + + )} +
+
+
+ +
+
+
+ +
+
+
+ + + + Get your admin token + +
+ + {!tokenHasHost && ( + <> +
+ + setHostName(e.target.value)} + /> +
+ )} + + )} + +
+ + setTenantId(e.target.value)} + /> +
+
+
+ +
+
+
+
+
+
+ ) +} diff --git a/dashboard/components/LoginDialog.tsx b/dashboard/components/LoginDialog.tsx deleted file mode 100644 index 122c672..0000000 --- a/dashboard/components/LoginDialog.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client' - -import { useState } from 'react' -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from '@/components/ui/Dialog' -import { Input } from '@/components/ui/Input' -import { Button } from '@/components/ui/Button' -import { Text } from '@/components/ui/Text' -import { Loader } from '@/components/ui/Loader' - -interface LoginDialogProps { - isLoading?: boolean -} - -export default function LoginDialog({ - isLoading: isCheckingAuth, -}: LoginDialogProps) { - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setError('') - setIsSubmitting(true) - - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }) - - if (response.ok) { - window.location.reload() - } else { - const data = await response.json() - setError(data.error || 'Invalid credentials') - setIsSubmitting(false) - } - } catch { - setError('An error occurred. Please try again.') - setIsSubmitting(false) - } - } - - // Show loading state while checking authentication - if (isCheckingAuth) { - return ( -
- -
- ) - } - - return ( - - - - Sign in to Dashboard - - Enter your credentials to access the analytics dashboard. - - - -
-
- - setUsername(e.target.value)} - placeholder="Enter username" - required - autoComplete="off" - autoFocus - /> -
- -
- - setPassword(e.target.value)} - placeholder="Enter password" - required - autoComplete="new-password" - /> - {!!error && ( - - {error} - - )} -
- -
-
-
- ) -} diff --git a/dashboard/lib/api.ts b/dashboard/lib/api.ts index 655438e..e3be334 100644 --- a/dashboard/lib/api.ts +++ b/dashboard/lib/api.ts @@ -1,5 +1,17 @@ import fetch from 'cross-fetch' import { PipeParams, QueryPipe, QuerySQL, QueryError } from './types/api' +import { getStoredCredentials } from './hooks/use-login' + +function getAuthHeaders(): HeadersInit { + const credentials = getStoredCredentials() + if (credentials?.token && credentials?.host) { + return { + 'X-Tinybird-Token': credentials.token, + 'X-Tinybird-Host': credentials.host, + } + } + return {} +} export async function queryPipe( name: string, @@ -11,7 +23,9 @@ export async function queryPipe( searchParams.set(key, value as string) }) - const response = await fetch(`/api/endpoints/${name}?${searchParams}`) + const response = await fetch(`/api/endpoints/${name}?${searchParams}`, { + headers: getAuthHeaders(), + }) const data = await response.json() if (!response.ok) { @@ -24,7 +38,10 @@ export async function queryPipe( export async function querySQL(sql: string): Promise> { const response = await fetch('/api/sql', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders(), + }, body: JSON.stringify({ sql }), }) diff --git a/dashboard/lib/hooks/use-login.ts b/dashboard/lib/hooks/use-login.ts index fabc0f1..c347882 100644 --- a/dashboard/lib/hooks/use-login.ts +++ b/dashboard/lib/hooks/use-login.ts @@ -1,24 +1,90 @@ 'use client' import useSWR from 'swr' +import { useSearchParams } from 'next/navigation' import { useCallback } from 'react' -const fetcher = (url: string) => fetch(url).then(res => res.json()) +export interface StoredCredentials { + token: string + host: string + tenantId?: string +} + +export function getStoredCredentials(): StoredCredentials | null { + if (typeof window === 'undefined') return null + try { + const searchParams = new URLSearchParams(window.location.search) + const token = searchParams.get('token') + const host = searchParams.get('host') + if (token && host) { + return { + token, + host, + tenantId: searchParams.get('tenant_id') || undefined, + } + } + return null + } catch { + return null + } +} + +function createFetcher(token: string | null, host: string | null) { + return (url: string) => { + const headers: HeadersInit = {} + if (token && host) { + headers['X-Tinybird-Token'] = token + headers['X-Tinybird-Host'] = host + } + return fetch(url, { headers }).then(res => res.json()) + } +} export function useLogin() { - const { data, error, isLoading, mutate } = useSWR('/api/auth', fetcher, { - revalidateOnFocus: false, - revalidateOnReconnect: false, - }) + const searchParams = useSearchParams() + + // Check URL params for token auth + const token = searchParams?.get('token') + const host = searchParams?.get('host') + const hasTokenAuth = !!token && !!host + + // Create fetcher with token/host to pass as headers + const fetcher = createFetcher(token, host) + + // Check server session auth + const { data, error, isLoading: isSessionLoading, mutate } = useSWR( + '/api/auth', + fetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ) const logout = useCallback(async () => { - await fetch('/api/auth/logout', { method: 'POST' }) - mutate({ authenticated: false }, false) - }, [mutate]) + // Clear URL params + const url = new URL(window.location.href) + url.searchParams.delete('token') + url.searchParams.delete('host') + url.searchParams.delete('tenant_id') + + // Also logout from server session if authenticated + if (data?.authenticated) { + await fetch('/api/auth/logout', { method: 'POST' }) + mutate({ authenticated: false }, false) + } + + // Navigate to URL without token params + window.location.href = url.toString() + }, [data?.authenticated, mutate]) + + const isSessionAuth = data?.authenticated ?? false return { - isLoggedIn: data?.authenticated ?? false, - isLoading, + isLoggedIn: isSessionAuth || hasTokenAuth, + isSessionAuth, + isTokenAuth: hasTokenAuth, + isLoading: isSessionLoading, error, logout, } diff --git a/dashboard/lib/hooks/use-workspace.ts b/dashboard/lib/hooks/use-workspace.ts index 82b9d07..4586c72 100644 --- a/dashboard/lib/hooks/use-workspace.ts +++ b/dashboard/lib/hooks/use-workspace.ts @@ -1,6 +1,8 @@ 'use client' import useSWR from 'swr' +import { useSearchParams } from 'next/navigation' +import { getStoredCredentials } from './use-login' interface WorkspaceInfo { name: string @@ -14,13 +16,94 @@ interface ConfigResponse { workspace: WorkspaceInfo | null } -const fetcher = (url: string) => fetch(url).then(res => res.json()) +// Extract region info from host URL +function getRegionFromHost(host: string): { provider: string; region: string } { + if (host.includes('localhost') || host.includes('127.0.0.1')) { + return { provider: 'Local', region: 'localhost' } + } + + // Map common hosts to provider/region + if (host.includes('europe-west2')) { + return { provider: 'GCP', region: 'europe-west2' } + } + if (host.includes('api.tinybird.co') && !host.includes('us-east')) { + return { provider: 'GCP', region: 'eu_shared' } + } + if (host.includes('us-east.tinybird.co')) { + return { provider: 'GCP', region: 'us-east' } + } + if (host.includes('northamerica-northeast2')) { + return { provider: 'GCP', region: 'northamerica-northeast2' } + } + if (host.includes('us-east.aws')) { + return { provider: 'AWS', region: 'us-east' } + } + if (host.includes('us-west-2.aws')) { + return { provider: 'AWS', region: 'us-west-2' } + } + if (host.includes('eu-central-1.aws')) { + return { provider: 'AWS', region: 'eu-central-1' } + } + if (host.includes('eu-west-1.aws')) { + return { provider: 'AWS', region: 'eu-west-1' } + } + + return { provider: 'Unknown', region: 'unknown' } +} + +function createFetcher(workspaceName: string | null) { + return (url: string) => { + const credentials = getStoredCredentials() + + const headers: HeadersInit = {} + if (credentials?.token && credentials?.host) { + headers['X-Tinybird-Token'] = credentials.token + headers['X-Tinybird-Host'] = credentials.host + } + // Pass workspace name from URL params to avoid permission issues with scoped JWT + if (workspaceName) { + headers['X-Tinybird-Workspace'] = workspaceName + } + + return fetch(url, { headers }).then(res => res.json()) + } +} export function useWorkspace() { - const { data, error, isLoading } = useSWR('/api/config', fetcher, { - revalidateOnFocus: false, - revalidateOnReconnect: false, - }) + const searchParams = useSearchParams() + + // Check URL params for workspace info (from token mode) + const workspaceFromUrl = searchParams?.get('workspace') + const hostFromUrl = searchParams?.get('host') + const hasUrlWorkspace = !!workspaceFromUrl && !!hostFromUrl + + // Create fetcher with workspace name from URL (to pass as header) + const fetcher = createFetcher(workspaceFromUrl) + + // Always call useSWR but skip fetch if we have URL params + const { data, error, isLoading } = useSWR( + hasUrlWorkspace ? null : '/api/config', + fetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ) + + // If we have workspace name in URL params, use that directly + if (hasUrlWorkspace) { + const regionInfo = getRegionFromHost(hostFromUrl) + return { + workspace: { + name: workspaceFromUrl, + provider: regionInfo.provider, + region: regionInfo.region, + }, + isConfigured: true, + isLoading: false, + error: null, + } + } return { workspace: data?.workspace ?? null, diff --git a/dashboard/lib/server.ts b/dashboard/lib/server.ts index 4856b4b..fb7442e 100644 --- a/dashboard/lib/server.ts +++ b/dashboard/lib/server.ts @@ -43,6 +43,10 @@ export function getServerClient() { return createAnalyticsClient() } +export function createClientWithCredentials(token: string, host: string) { + return createAnalyticsClient({ token, baseUrl: host, devMode: false }) +} + export async function getWorkspace(): Promise { const { token, host } = getTinybirdConfig() @@ -50,6 +54,13 @@ export async function getWorkspace(): Promise { return null } + return getWorkspaceWithCredentials(token, host) +} + +export async function getWorkspaceWithCredentials( + token: string, + host: string +): Promise { const url = new URL('/v1/workspace', host) const response = await fetch(url.toString(), { diff --git a/dashboard/middleware.ts b/dashboard/middleware.ts index 59d7377..35ad48b 100644 --- a/dashboard/middleware.ts +++ b/dashboard/middleware.ts @@ -24,6 +24,13 @@ export function middleware(request: NextRequest) { return NextResponse.next() } + // Check for token auth from headers (public mode) + const headerToken = request.headers.get('X-Tinybird-Token') + const headerHost = request.headers.get('X-Tinybird-Host') + if (headerToken && headerHost) { + return NextResponse.next() + } + // Check for session cookie const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME) diff --git a/dashboard/package.json b/dashboard/package.json index db714a0..8cbdce6 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -39,7 +39,7 @@ "@radix-ui/react-toast": "^1.2.5", "@radix-ui/react-tooltip": "^1.1.7", "@tinybirdco/analytics-client": "workspace:*", - "@tinybirdco/sdk": "^0.0.25", + "@tinybirdco/sdk": "^0.0.27", "ai": "^4.3.17", "class-variance-authority": "^0.7.1", "country-flag-icons": "^1.5.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fdcf6c..edc5861 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: workspace:* version: link:../tinybird '@tinybirdco/sdk': - specifier: ^0.0.25 - version: 0.0.25 + specifier: ^0.0.27 + version: 0.0.27 ai: specifier: ^4.3.17 version: 4.3.19(react@18.3.1)(zod@3.25.76) @@ -196,8 +196,8 @@ importers: tinybird: dependencies: '@tinybirdco/sdk': - specifier: ^0.0.25 - version: 0.0.25 + specifier: ^0.0.27 + version: 0.0.27 devDependencies: '@types/node': specifier: ^22.13.10 @@ -2085,8 +2085,8 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@tinybirdco/sdk@0.0.25': - resolution: {integrity: sha512-PYJcwEgv2/gOAPcHHgaimoTMy4hFuy3UyT6tSBNznEcd+HJms0W4DA/y68yB3m8A9O+zYKztejNtK/U/8lNg7Q==} + '@tinybirdco/sdk@0.0.27': + resolution: {integrity: sha512-cDWJSSULHf1l8dWW7WTpMupcmOPFHdXDQIJ5kUhoL/y8QZ2NDUdheOZyC8y6u3VebpNXpPXPtiVAFA4JCnUFkg==} engines: {node: '>=20.0.0'} hasBin: true @@ -7145,7 +7145,7 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tinybirdco/sdk@0.0.25': + '@tinybirdco/sdk@0.0.27': dependencies: '@clack/prompts': 1.0.0 chokidar: 4.0.3 diff --git a/tinybird/package.json b/tinybird/package.json index 3fea943..208c1c5 100644 --- a/tinybird/package.json +++ b/tinybird/package.json @@ -13,7 +13,7 @@ "preview": "tinybird preview" }, "dependencies": { - "@tinybirdco/sdk": "^0.0.25" + "@tinybirdco/sdk": "^0.0.27" }, "devDependencies": { "@types/node": "^22.13.10", diff --git a/tinybird/src/client.ts b/tinybird/src/client.ts index 8f63f16..8ee5e48 100644 --- a/tinybird/src/client.ts +++ b/tinybird/src/client.ts @@ -106,17 +106,26 @@ export const pipes = { // This ensures tinybird.json is found regardless of where the app runs from const __configDir = dirname(fileURLToPath(import.meta.url)); +interface CreateAnalyticsClientOptions { + token?: string; + baseUrl?: string; + devMode?: boolean; +} + /** * Create a Tinybird client with custom configuration */ -export function createAnalyticsClient() { - return createTinybirdClient({ +export function createAnalyticsClient(options?: CreateAnalyticsClientOptions) { + const clientOptions: Parameters[0] = { datasources, pipes, configDir: __configDir, - baseUrl: process.env.TINYBIRD_HOST, - token: process.env.TINYBIRD_TOKEN, - }); + baseUrl: options?.baseUrl ?? process.env.TINYBIRD_HOST, + token: options?.token ?? process.env.TINYBIRD_TOKEN, + devMode: options?.devMode ?? process.env.NODE_ENV === "development", + }; + + return createTinybirdClient(clientOptions); } /**