diff --git a/recipe-portal/.gitignore b/recipe-portal/.gitignore
index 8d8b4022..e659078e 100644
--- a/recipe-portal/.gitignore
+++ b/recipe-portal/.gitignore
@@ -1,35 +1,42 @@
-# Dependencies
-node_modules/
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
/.pnp
.pnp.js
-# Testing
+# testing
/coverage
-# Next.js
+# next.js
/.next/
/out/
-# Production
+# production
/build
-# Misc
+# misc
.DS_Store
-*.tsbuildinfo
-next-env.d.ts
+*.pem
-# Debug
+# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-# Local env files
+# local env files
.env*.local
-# IDE
-.vscode/
-.idea/
+# vercel
+.vercel
-# OS
-.DS_Store
-Thumbs.db
\ No newline at end of file
+# typescript
+*.tsbuildinfo
+next-env.d.ts
+
+# Sigma API encrypted credentials (security)
+.sigma-portal/
+sigma-portal-keys.json
+
+# Environment files
+.env
\ No newline at end of file
diff --git a/recipe-portal/README.md b/recipe-portal/README.md
new file mode 100644
index 00000000..ce05f86e
--- /dev/null
+++ b/recipe-portal/README.md
@@ -0,0 +1,129 @@
+# QuickStarts API Toolkit
+Experiment with Sigma API calls and learn common request flows
+
+## Features
+
+### Recipes:
+- **Smart Parameter Detection**: Automatically detects and provides dropdown selection for Sigma resources (teams, members, workbooks, etc.)
+- **Interactive Execution**: Run recipes directly in the browser with real-time results
+- **Parameter Summary**: View which parameters were used in each request
+- **Code Viewing**: Browse the actual JavaScript code for each recipe
+
+### Quick API Explorer:
+- **Common Endpoints**: Curated list of the most useful Sigma API endpoints
+- **Zero Setup**: List endpoints require no parameters - perfect for quick exploration
+- **One Parameter**: Detail endpoints need just one ID to get specific resource information
+- **Alphabetical Organization**: Easy to find the endpoint you need
+
+## Authentication & Config Management
+
+### Smart Config System:
+- **Complete Configuration Storage**: Server endpoints + API credentials stored together as named "configs"
+- **Multi-Environment Support**: Easily switch between Production, Staging, Development environments
+- **One-Click Environment Switching**: Load complete configurations instantly
+- **Encrypted Local Storage**: AES-256 encryption for credential security
+
+### Config Management Features:
+- **Quick Start**: Load saved configs with one click - no manual entry needed
+- **Create New Configs**: Mix and match server endpoints with credentials
+- **Update Existing Configs**: Modify and save changes to existing configurations
+- **Delete Configs**: Remove configs you no longer need
+- **Auto-Save**: Configs saved automatically during authentication when enabled
+- **Manual Save**: Explicit save button for immediate config storage
+
+### Token Management:
+- **File-Based Storage**: Authentication tokens cached in system temp directory
+- **Persistent Sessions**: Tokens survive browser/server restarts for the full hour
+- **Automatic Expiration**: Tokens expire after 1 hour (Sigma's standard lifetime)
+- **Auto-Cleanup**: Expired tokens automatically detected and removed
+- **Manual Session End**: Clear authentication anytime with šŖ End Session button
+
+### Storage Locations
+
+**Config Storage (encrypted)**:
+- **macOS**: `~/Library/Application Support/.sigma-portal/encrypted-keys.json`
+- **Windows**: `%APPDATA%\.sigma-portal\encrypted-keys.json`
+- **Linux**: `~/.config/.sigma-portal/encrypted-keys.json`
+
+**Token Cache (temporary)**:
+- **macOS**: `/var/folders/.../sigma-portal-token.json`
+- **Windows**: `%TEMP%\sigma-portal-token.json`
+- **Linux**: `/tmp/sigma-portal-token.json`
+
+### Developer Experience Benefits
+- **Environment Switching**: Instant switch between Production ā Staging ā Development
+- **Zero Re-entry**: Load complete configs without typing credentials repeatedly
+- **Secure Storage**: Military-grade AES-256 encryption for stored credentials
+- **Clean Separation**: Configs stored outside project directory (never committed to git)
+- **Visual Feedback**: Clear indicators show saved/unsaved state and notifications
+- **Flexible Workflow**: Session-only credentials OR persistent named configs
+
+### Config Workflow
+1. **First Time**: Enter server endpoint + credentials ā Save as named config (e.g., "Production")
+2. **Daily Use**: Quick Start ā Select "Production" ā Instantly loaded and ready
+3. **Environment Switch**: Quick Start ā Select "Staging" ā Switched in one click
+4. **New Environment**: "⨠New Config" ā Enter details ā Save with new name
+
+## Getting Started
+Sigma_QuickStart_Public_Repo
+
+
+1. **Setup**: `npm install && npm run dev`
+2. **First-Time Config**: Open any recipe ā **Config** tab ā Enter server endpoint + credentials ā Save as named config
+3. **Daily Use**: **Quick Start** section ā Select your saved config ā Ready to go!
+4. **Explore**: Use the ā” Quick API tab to explore common endpoints with smart parameters
+5. **Run Recipes**: Browse recipes by category and execute them with real-time results
+
+### Config Tab Features
+- **Quick Start**: Load saved configs instantly (appears when configs exist)
+- **Server Endpoint**: Choose your Sigma organization's server location
+- **API Credentials**: Enter Client ID and Client Secret
+- **Config Storage**: Save complete configurations with names like "Production", "Staging"
+- **Save Config**: Manual save button for immediate storage
+- **New Config**: Clear form to create fresh configurations
+- **Delete**: Remove configs you no longer need (šļø button when config selected)
+
+## Requirements
+- Node.js 18+
+- Sigma API credentials (Client ID and Secret)
+- Valid Sigma organization access
+
+## Development
+```bash
+npm install
+npm run dev
+```
+
+Navigate to `http://localhost:3001` to start exploring the Sigma API.
+
+## Project Structure
+```
+recipe-portal/
+āāā app/ # Next.js app directory
+ā āāā api/ # API routes
+ā ā āāā execute/ # Recipe execution
+ā ā āāā resources/ # Resource fetching for dropdowns
+ā ā āāā keys/ # Config management (CRUD operations)
+ā ā āāā token/ # Token management & caching
+ā ā āāā call/ # Quick API endpoint calls
+āāā components/ # React components
+ā āāā QuickApiExplorer.tsx # Quick API exploration interface
+ā āāā QuickApiModal.tsx # API endpoint execution modal
+ā āāā SmartParameterForm.tsx # Smart parameter detection & forms
+ā āāā CodeViewer.tsx # Recipe viewer with Config tab
+ā āāā AuthRecipeCard.tsx # Authentication recipe card
+ā āāā RecipeCard.tsx # Standard recipe cards
+āāā lib/ # Utilities
+ā āāā smartParameters.ts # Parameter detection logic
+ā āāā keyStorage.ts # Encrypted config storage
+ā āāā recipeScanner.ts # Recipe discovery & analysis
+āāā recipes/ # Self-contained recipe files (copied from sigma-api-recipes)
+ āāā connections/ # Connection-related recipes
+ āāā members/ # Member management recipes
+ āāā teams/ # Team management recipes
+ āāā workbooks/ # Workbook operations
+ āāā embedding/ # Embedding examples
+ āāā get-access-token.js # Authentication helper
+```
+
+For setup instructions and API credential creation, visit the QuickStart: [Sigma REST API Recipes](https://quickstarts.sigmacomputing.com/guide/developers_api_code_samples/index.html?index=..%2F..index#0)
\ No newline at end of file
diff --git a/recipe-portal/app/api/call/route.ts b/recipe-portal/app/api/call/route.ts
new file mode 100644
index 00000000..ca483b8e
--- /dev/null
+++ b/recipe-portal/app/api/call/route.ts
@@ -0,0 +1,141 @@
+import { NextResponse } from 'next/server';
+import axios from 'axios';
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+
+const TOKEN_CACHE_FILE = path.join(os.tmpdir(), 'sigma-portal-token.json');
+
+function getCachedToken(): string | null {
+ try {
+ if (fs.existsSync(TOKEN_CACHE_FILE)) {
+ const tokenData = JSON.parse(fs.readFileSync(TOKEN_CACHE_FILE, 'utf8'));
+ const now = Date.now();
+
+ // Check if token is still valid (not expired)
+ if (tokenData.expiresAt && now < tokenData.expiresAt) {
+ return tokenData.token;
+ } else {
+ // Token expired, remove file
+ fs.unlinkSync(TOKEN_CACHE_FILE);
+ }
+ }
+ } catch (error) {
+ // Ignore errors, just return null
+ }
+ return null;
+}
+
+export async function POST(request: Request) {
+ try {
+ const { endpoint, method, parameters = {}, requestBody } = await request.json();
+
+ if (!endpoint) {
+ return NextResponse.json(
+ { error: 'Endpoint is required' },
+ { status: 400 }
+ );
+ }
+
+ // Get cached token
+ const token = getCachedToken();
+ if (!token) {
+ return NextResponse.json(
+ {
+ error: 'Authentication required',
+ message: 'No valid authentication token found. Please authenticate first.'
+ },
+ { status: 401 }
+ );
+ }
+
+ // Build the full URL
+ const baseURL = process.env.SIGMA_BASE_URL || 'https://aws-api.sigmacomputing.com/v2';
+ let url = `${baseURL}${endpoint}`;
+
+ // Add query parameters
+ if (parameters.query && Object.keys(parameters.query).length > 0) {
+ const queryParams = new URLSearchParams();
+ Object.entries(parameters.query).forEach(([key, value]) => {
+ if (value !== undefined && value !== '') {
+ queryParams.append(key, String(value));
+ }
+ });
+ if (queryParams.toString()) {
+ url += `?${queryParams.toString()}`;
+ }
+ }
+
+ // Prepare headers
+ const headers: Record
`;
+ })
+ // Headers
+ .replace(/^### (.+)$/gm, '${code.replace(//g, '>')}$1
')
+ .replace(/^## (.+)$/gm, '$1
')
+ .replace(/^# (.+)$/gm, '$1
')
+ // Inline code
+ .replace(/`([^`]+)`/g, '$1')
+ // Links
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1')
+ // Bold text
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ // Lists
+ .replace(/^- (.+)$/gm, '${items}
`;
+ })
+ // Convert double line breaks to paragraph breaks
+ .replace(/\n\s*\n/g, '
') + // Wrap remaining content in paragraphs + .replace(/^(?![<])/gm, '
') + // Clean up paragraph wrapping around headers and other elements + .replace(/
(<[h123]|
|<\/pre>|<\/ul>)/g, '$1') + // Remove trailing paragraph tags + .replace(/<\/p>$/g, '') + // Remove empty paragraphs + .replace(/
<\/p>/g, ''); +} + export async function GET(request: Request) { try { const { searchParams } = new URL(request.url); const readmePath = searchParams.get('path'); + const format = searchParams.get('format'); // Check if HTML format is requested if (!readmePath) { return NextResponse.json( @@ -14,14 +65,16 @@ export async function GET(request: Request) { ); } - // Security check: ensure the file is within the sigma-api-recipes directory - const recipesPath = path.join(process.cwd(), '..', 'sigma-api-recipes'); + // Security check: ensure the file is within the recipes directory or is the main README + const recipesPath = path.join(process.cwd(), 'recipes'); + const mainReadmePath = path.join(process.cwd(), 'README.md'); const resolvedPath = path.resolve(readmePath); const resolvedRecipesPath = path.resolve(recipesPath); + const resolvedMainReadmePath = path.resolve(mainReadmePath); - if (!resolvedPath.startsWith(resolvedRecipesPath)) { + if (!resolvedPath.startsWith(resolvedRecipesPath) && resolvedPath !== resolvedMainReadmePath) { return NextResponse.json( - { error: 'Access denied: File must be within sigma-api-recipes directory' }, + { error: 'Access denied: File must be within recipes directory or be the main README' }, { status: 403 } ); } @@ -36,6 +89,70 @@ export async function GET(request: Request) { const content = fs.readFileSync(resolvedPath, 'utf-8'); + // If accessed directly in browser (no explicit JSON format requested), return HTML + if (format !== 'json') { + const htmlContent = ` + +
+Recipe Instructions + + + + + ā Close +${convertMarkdownToHtml(content)}+ +`; + + return new NextResponse(htmlContent, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + }, + }); + } + + // Return JSON for API calls return NextResponse.json({ content, success: true diff --git a/recipe-portal/app/api/resources/route.ts b/recipe-portal/app/api/resources/route.ts new file mode 100644 index 00000000..ac45f521 --- /dev/null +++ b/recipe-portal/app/api/resources/route.ts @@ -0,0 +1,281 @@ +import { NextResponse } from 'next/server'; +import axios from 'axios'; + +// Base resource fetching function +async function fetchWithAuth(endpoint: string, token: string) { + try { + const baseURL = process.env.SIGMA_BASE_URL || 'https://aws-api.sigmacomputing.com/v2'; + const url = `${baseURL}${endpoint}`; + console.log(`Fetching: ${url}`); + const response = await axios.get(url, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json' + } + }); + console.log(`Response status for ${endpoint}:`, response.status); + return response.data; + } catch (error) { + console.error(`Error fetching ${endpoint}:`, (error as any).response?.data || (error as any).message); + throw error; + } +} + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const type = searchParams.get('type'); + const token = searchParams.get('token'); + + if (!token) { + return NextResponse.json( + { error: 'Authentication token is required' }, + { status: 401 } + ); + } + + if (!type) { + return NextResponse.json( + { error: 'Resource type is required. Use: teams, members, workbooks, connections, workspaces, bookmarks, templates, datasets, dataModels, accountTypes, workbookElements, materializationSchedules' }, + { status: 400 } + ); + } + + let data: any; + let transformedData: any[]; + + switch (type) { + case 'teams': + data = await fetchWithAuth('/teams', token); + transformedData = (data.entries || data).map((team: any) => ({ + id: team.teamId, + name: team.name, + description: team.description || '', + memberCount: team.memberCount || 0 + })); + break; + + case 'members': + data = await fetchWithAuth('/members', token); + // Filter out potentially inactive members and map to display format + const activeMembers = (data.entries || data).filter((member: any) => { + // Add filters for inactive members based on patterns you identify + // For now, keeping all members - you can modify this filter + return true; + }); + + transformedData = activeMembers.map((member: any) => ({ + id: member.memberId, + name: `${member.firstName} ${member.lastName}`.trim(), + email: member.email, + firstName: member.firstName, + lastName: member.lastName, + type: member.memberType + })); + break; + + case 'workbooks': + data = await fetchWithAuth('/workbooks', token); + transformedData = (data.entries || data).map((workbook: any) => ({ + id: workbook.workbookId, + name: workbook.name, + path: workbook.path, + ownerId: workbook.ownerId, + createdBy: workbook.createdBy, + url: workbook.url + })); + break; + + case 'connections': + data = await fetchWithAuth('/connections', token); + transformedData = (data.entries || data).map((connection: any) => ({ + id: connection.connectionId, + name: connection.name, + type: connection.type, + description: connection.description || '' + })); + break; + + case 'workspaces': + data = await fetchWithAuth('/workspaces', token); + transformedData = (data.entries || data).map((workspace: any) => ({ + id: workspace.workspaceId, + name: workspace.name, + description: workspace.description || '' + })); + break; + + case 'bookmarks': + // Using favorites endpoint since bookmarks API maps to favorites + data = await fetchWithAuth('/favorites', token); + transformedData = (data.entries || data).map((favorite: any) => ({ + id: favorite.favoriteId || favorite.inodeId, + name: favorite.name || favorite.title, + description: favorite.description || '', + type: favorite.type || 'favorite', + url: favorite.url + })); + break; + + case 'templates': + data = await fetchWithAuth('/templates', token); + transformedData = (data.entries || data).map((template: any) => ({ + id: template.templateId, + name: template.name, + description: template.description || '', + type: template.type + })); + break; + + case 'datasets': + data = await fetchWithAuth('/datasets', token); + transformedData = (data.entries || data).map((dataset: any) => ({ + id: dataset.datasetId, + name: dataset.name, + description: dataset.description || '', + type: dataset.type + })); + break; + + case 'dataModels': + data = await fetchWithAuth('/dataModels', token); + transformedData = (data.entries || data).map((dataModel: any) => ({ + id: dataModel.dataModelId, + name: dataModel.name, + description: dataModel.description || '', + type: dataModel.type || 'dataModel' + })); + break; + + case 'accountTypes': + data = await fetchWithAuth('/accountTypes', token); + console.log('AccountTypes raw data:', JSON.stringify(data, null, 2)); + transformedData = (data.entries || data).map((accountType: any) => ({ + id: accountType.accountTypeName, + name: accountType.accountTypeName, + description: accountType.description || '', + type: accountType.isCustom ? 'custom' : 'built-in', + isCustom: accountType.isCustom + })); + break; + + case 'workbookElements': + const workbookId = searchParams.get('workbookId'); + if (!workbookId) { + return NextResponse.json( + { error: 'workbookId parameter is required for workbookElements' }, + { status: 400 } + ); + } + + try { + // First, get all pages from the workbook + console.log(`Fetching pages for workbook: ${workbookId}`); + const pagesData = await fetchWithAuth(`/workbooks/${workbookId}/pages`, token); + console.log('Pages data:', JSON.stringify(pagesData, null, 2)); + + const pages = pagesData.entries || pagesData || []; + let allElements: any[] = []; + + // For each page, get its elements + for (const page of pages) { + const pageId = page.pageId || page.id; + if (pageId) { + try { + console.log(`Fetching elements for page: ${pageId}`); + const elementsData = await fetchWithAuth(`/workbooks/${workbookId}/pages/${pageId}/elements`, token); + console.log(`Elements data for page ${pageId}:`, JSON.stringify(elementsData, null, 2)); + + const pageElements = elementsData.entries || elementsData || []; + + // Add page information to each element + const elementsWithPageInfo = pageElements.map((element: any) => ({ + ...element, + pageId: pageId, + pageName: page.name || page.title || `Page ${pageId}` + })); + + allElements = allElements.concat(elementsWithPageInfo); + } catch (pageError) { + console.warn(`Failed to fetch elements for page ${pageId}:`, pageError); + // Continue with other pages even if one fails + } + } + } + + console.log('All extracted elements:', allElements); + + transformedData = allElements.map((element: any) => ({ + id: element.elementId || element.id || element.elementUid, + name: element.name || element.title || element.displayName || `${element.pageName} - ${element.name || element.title || element.displayName || 'Unnamed Element'}`, + type: element.type || element.elementType || 'element', + description: element.description || `Element on page: ${element.pageName}`, + pageId: element.pageId, + pageName: element.pageName + })); + + } catch (error) { + console.error('Error fetching workbook elements:', error); + transformedData = []; + } + + console.log('Final transformed elements data:', transformedData); + break; + + case 'materializationSchedules': + const workbookIdForMat = searchParams.get('workbookId'); + if (!workbookIdForMat) { + return NextResponse.json( + { error: 'workbookId parameter is required for materializationSchedules' }, + { status: 400 } + ); + } + + try { + console.log(`Fetching materialization schedules for workbook: ${workbookIdForMat}`); + const schedulesData = await fetchWithAuth(`/workbooks/${workbookIdForMat}/materialization-schedules`, token); + console.log('Materialization schedules data:', JSON.stringify(schedulesData, null, 2)); + + const schedules = schedulesData.entries || schedulesData || []; + + transformedData = schedules.map((schedule: any) => ({ + id: schedule.sheetId, // Use sheetId as the value that will be sent to the script + name: schedule.elementName, // Display the element name to the user + description: `${schedule.schedule.cronSpec} ${schedule.schedule.timezone}${schedule.paused ? ' - PAUSED' : ''}`, + type: 'materializationSchedule', + sheetId: schedule.sheetId, + elementName: schedule.elementName, + cronSpec: schedule.schedule.cronSpec, + timezone: schedule.schedule.timezone, + paused: schedule.paused + })); + + } catch (error) { + console.error('Error fetching materialization schedules:', error); + transformedData = []; + } + + console.log('Final transformed schedules data:', transformedData); + break; + + default: + return NextResponse.json( + { error: `Unsupported resource type: ${type}` }, + { status: 400 } + ); + } + + return NextResponse.json({ + type, + count: transformedData.length, + data: transformedData.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '')) + }); + + } catch (error) { + console.error('Error in resources API:', error); + return NextResponse.json( + { error: 'Failed to fetch resources' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/token/clear/route.ts b/recipe-portal/app/api/token/clear/route.ts new file mode 100644 index 00000000..4c0f4b01 --- /dev/null +++ b/recipe-portal/app/api/token/clear/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Configuration-specific token caching +function getTokenCacheFile(clientId) { + // Create a safe filename using first 8 chars of clientId + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +export async function POST(request: Request) { + try { + const { clientId, clearAll } = await request.json(); + + console.log('Token clear request:', { clientId, clearAll }); + + if (clearAll) { + // Clear all token cache files + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + console.log('Clearing all tokens:', tokenFiles); + + let clearedCount = 0; + for (const file of tokenFiles) { + try { + fs.unlinkSync(path.join(tempDir, file)); + clearedCount++; + console.log(`Cleared token file: ${file}`); + } catch (err) { + console.warn(`Failed to delete token file ${file}:`, err); + } + } + + return NextResponse.json({ + success: true, + message: `Cleared ${clearedCount} authentication token(s)` + }); + } else { + // Clear specific configuration's token + const TOKEN_CACHE_FILE = getTokenCacheFile(clientId); + + if (fs.existsSync(TOKEN_CACHE_FILE)) { + fs.unlinkSync(TOKEN_CACHE_FILE); + } + + return NextResponse.json({ + success: true, + message: 'Authentication token cleared successfully' + }); + } + } catch (error) { + console.error('Error clearing token:', error); + return NextResponse.json( + { + success: false, + error: 'Failed to clear authentication token' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/recipe-portal/app/api/token/route.ts b/recipe-portal/app/api/token/route.ts new file mode 100644 index 00000000..01e78b35 --- /dev/null +++ b/recipe-portal/app/api/token/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +// Configuration-specific token caching +function getTokenCacheFile(clientId: string) { + // Create a safe filename using first 8 chars of clientId + const configHash = clientId ? clientId.substring(0, 8) : 'default'; + return path.join(os.tmpdir(), `sigma-portal-token-${configHash}.json`); +} + +export async function GET() { + try { + // Look for the most recent valid token across all configurations + const tempDir = os.tmpdir(); + const files = fs.readdirSync(tempDir); + const tokenFiles = files.filter(file => file.startsWith('sigma-portal-token-') && file.endsWith('.json')); + + let mostRecentToken = null; + let mostRecentTime = 0; + + console.log('Found token files:', tokenFiles); + + for (const file of tokenFiles) { + try { + const filePath = path.join(tempDir, file); + const tokenData = JSON.parse(fs.readFileSync(filePath, 'utf8')); + const now = Date.now(); + + // Check if token is still valid (not expired) + if (tokenData.expiresAt && now < tokenData.expiresAt) { + // Use the most recently created/accessed token + const lastAccessTime = tokenData.lastAccessed || tokenData.createdAt; + console.log(`Token ${file}: clientId=${tokenData.clientId?.substring(0,8)}, createdAt=${new Date(tokenData.createdAt)}, lastAccessed=${tokenData.lastAccessed ? new Date(tokenData.lastAccessed) : 'none'}, lastAccessTime=${lastAccessTime}`); + + if (lastAccessTime > mostRecentTime) { + console.log(` -> This is the most recent token so far`); + mostRecentTime = lastAccessTime; + mostRecentToken = { + hasValidToken: true, + token: tokenData.token, + expiresAt: tokenData.expiresAt, + timeRemaining: Math.round((tokenData.expiresAt - now) / 1000 / 60), // minutes + clientId: tokenData.clientId, + filePath: filePath // Keep track of which file this came from + }; + } + } else { + // Token expired, remove file + fs.unlinkSync(filePath); + } + } catch (err) { + // Skip invalid token files + console.warn(`Failed to read token file ${file}:`, err); + } + } + + if (mostRecentToken) { + console.log(`Selected token: clientId=${mostRecentToken.clientId?.substring(0,8)}`); + + // Update the last accessed time for this token + try { + const tokenData = JSON.parse(fs.readFileSync(mostRecentToken.filePath, 'utf8')); + tokenData.lastAccessed = Date.now(); + fs.writeFileSync(mostRecentToken.filePath, JSON.stringify(tokenData)); + } catch (err) { + console.warn('Failed to update token access time:', err); + } + + // Remove filePath from response + const { filePath, ...responseData } = mostRecentToken; + return NextResponse.json(responseData); + } + + return NextResponse.json({ + hasValidToken: false, + token: null + }); + } catch (error) { + console.error('Error checking token:', error); + return NextResponse.json({ + hasValidToken: false, + token: null + }); + } +} \ No newline at end of file diff --git a/recipe-portal/app/layout.tsx b/recipe-portal/app/layout.tsx index c89b7deb..6b24a7ca 100644 --- a/recipe-portal/app/layout.tsx +++ b/recipe-portal/app/layout.tsx @@ -2,8 +2,13 @@ import type { Metadata } from 'next' import './globals.css' export const metadata: Metadata = { - title: 'Sigma API Recipe Portal', - description: 'Interactive portal for Sigma API recipes and examples', + title: 'QuickStarts API Toolkit', + description: 'Experiment with Sigma API calls and learn common request flows', + icons: { + icon: '/crane.png', + shortcut: '/crane.png', + apple: '/crane.png', + }, } export default function RootLayout({ diff --git a/recipe-portal/app/page.tsx b/recipe-portal/app/page.tsx index 52b5d35c..101a566a 100644 --- a/recipe-portal/app/page.tsx +++ b/recipe-portal/app/page.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { RecipeCard } from '../components/RecipeCard'; import { CodeViewer } from '../components/CodeViewer'; +import { QuickApiExplorer } from '../components/QuickApiExplorer'; interface Recipe { id: string; @@ -29,9 +30,36 @@ export default function Home() { const [recipeData, setRecipeData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState (null); + const [activeTopTab, setActiveTopTab] = useState<'recipes' | 'quickapi'>('recipes'); const [activeCategoryTab, setActiveCategoryTab] = useState (''); + const [authToken, setAuthToken] = useState (null); const [hasValidToken, setHasValidToken] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false); + const [clearingToken, setClearingToken] = useState(false); + const [quickApiKey, setQuickApiKey] = useState(0); + + // Function to check auth status (reusable) + const checkAuthStatus = useCallback(async () => { + try { + const response = await fetch('/api/token'); + if (response.ok) { + const data = await response.json(); + if (data.hasValidToken) { + setHasValidToken(true); + setAuthToken(data.token); + } else { + setHasValidToken(false); + setAuthToken(null); + } + } else { + setHasValidToken(false); + setAuthToken(null); + } + } catch (error) { + setHasValidToken(false); + setAuthToken(null); + } + }, []); useEffect(() => { async function fetchRecipes() { @@ -53,23 +81,43 @@ export default function Home() { } } - async function checkAuthStatus() { - try { - const response = await fetch('/api/token'); - if (response.ok) { - const data = await response.json(); - if (data.hasValidToken) { - setHasValidToken(true); - } + fetchRecipes(); + checkAuthStatus(); + }, [checkAuthStatus]); + + // Periodically check auth status every 30 seconds + useEffect(() => { + const interval = setInterval(checkAuthStatus, 30000); + return () => clearInterval(interval); + }, [checkAuthStatus]); + + const clearToken = async () => { + setClearingToken(true); + try { + const response = await fetch('/api/token/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ clearAll: true }) + }); + + if (response.ok) { + setHasValidToken(false); + setAuthToken(null); + // If auth modal is open, close it to trigger form reset on next open + if (showAuthModal) { + setShowAuthModal(false); } - } catch (error) { - // Ignore errors - just means no token is cached + } else { + console.error('Failed to clear token'); } + } catch (error) { + console.error('Error clearing token:', error); + } finally { + setClearingToken(false); } - - fetchRecipes(); - checkAuthStatus(); - }, []); + }; if (loading) { return ( @@ -109,74 +157,138 @@ export default function Home() { -- {/* Auth Button */} -- Sigma API Recipe Portal -
++-+
+ QuickStarts API Toolkit +
+- Interactive collection of JavaScript samples demonstrating how to use the Sigma API for specific use cases. +
+ Experiment with Sigma API calls and learn common request flows
+ {/* Action Buttons */} +{/* Main Content Container */}+ + {hasValidToken ? ( ++ + ++ ) : ( + + )}- {/* Category Tabs */} --); -} \ No newline at end of file +} diff --git a/recipe-portal/app/test/page.tsx b/recipe-portal/app/test/page.tsx new file mode 100644 index 00000000..5f2bebef --- /dev/null +++ b/recipe-portal/app/test/page.tsx @@ -0,0 +1,8 @@ +export default function TestPage() { + return ( +++ ); +} \ No newline at end of file diff --git a/recipe-portal/bug.png b/recipe-portal/bug.png new file mode 100644 index 00000000..9bfc2286 Binary files /dev/null and b/recipe-portal/bug.png differ diff --git a/recipe-portal/components/CodeViewer.tsx b/recipe-portal/components/CodeViewer.tsx index 15b64534..cf889b90 100644 --- a/recipe-portal/components/CodeViewer.tsx +++ b/recipe-portal/components/CodeViewer.tsx @@ -1,6 +1,8 @@ 'use client'; import { useState, useEffect } from 'react'; +import { detectSmartParameters, SmartParameter, analyzeRecipeCode } from '../lib/smartParameters'; +import { SmartParameterForm } from './SmartParameterForm'; interface CodeViewerProps { isOpen: boolean; @@ -10,19 +12,40 @@ interface CodeViewerProps { envVariables?: string[]; useEnvFile?: boolean; onTokenObtained?: () => void; + onTokenCleared?: () => void; defaultTab?: 'params' | 'run' | 'code' | 'readme'; + hasValidToken?: boolean; + readmePath?: string; } interface ExecutionResult { output: string; error: string; - success: boolean; + success: boolean | null; timestamp: string; httpStatus?: number; httpStatusText?: string; + downloadInfo?: { + filename: string; + localPath: string; + size: number; + }; } -export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = [], useEnvFile = false, onTokenObtained, defaultTab = 'params' }: CodeViewerProps) { +// Function to open the downloads folder via API +const openDownloadsFolder = async () => { + try { + await fetch('/api/open-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder: 'downloaded-files' }) + }); + } catch (error) { + console.log('Could not open folder automatically. Please navigate to the downloaded-files folder manually.'); + } +}; + +export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = [], useEnvFile = false, onTokenObtained, onTokenCleared, defaultTab = 'params', hasValidToken = false, readmePath }: CodeViewerProps) { const [code, setCode] = useStateTest Page
+If you can see this, routing is working.
+(''); const [loading, setLoading] = useState(false); const [error, setError] = useState (null); @@ -31,7 +54,33 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = const [envFileValues, setEnvFileValues] = useState >({}); const [executing, setExecuting] = useState(false); const [executionResult, setExecutionResult] = useState (null); - const [detectedParameters, setDetectedParameters] = useState ([]); + const [smartParameters, setSmartParameters] = useState ([]); + const [authToken, setAuthToken] = useState (null); + const [clearingToken, setClearingToken] = useState(false); + const [storeKeysLocally, setStoreKeysLocally] = useState(false); + const [hasStoredKeys, setHasStoredKeys] = useState(false); + const [currentFormIsStored, setCurrentFormIsStored] = useState(false); + + // Reset form when modal is closed + useEffect(() => { + if (!isOpen) { + setEnvValues({}); + setExecutionResult(null); + setActiveTab(defaultTab); + setError(null); + setSetAsDefault(false); + setCopyButtonText('Copy Output'); + } + }, [isOpen, defaultTab]); + const [saveNotification, setSaveNotification] = useState (null); + const [credentialSetName, setCredentialSetName] = useState(''); + const [availableCredentialSets, setAvailableCredentialSets] = useState ([]); + const [selectedCredentialSet, setSelectedCredentialSet] = useState(''); + const [setAsDefault, setSetAsDefault] = useState(false); + const [defaultCredentialSet, setDefaultCredentialSet] = useState (null); + const [copyButtonText, setCopyButtonText] = useState('Copy Output'); + const [customReadme, setCustomReadme] = useState (null); + const [readmeLoading, setReadmeLoading] = useState(false); useEffect(() => { if (isOpen && filePath) { @@ -40,20 +89,37 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = if (fileName === 'get-access-token.js') { // Auth script: README first smartDefaultTab = 'readme'; - } else if (detectedParameters.length > 0) { - // Has parameters: Parameters first + } else if (smartParameters.length > 0) { + // Has parameters: Request first smartDefaultTab = 'params'; } else { // No parameters: Run Script (Response) first smartDefaultTab = 'run'; } - setActiveTab(smartDefaultTab); + + // Only set the tab if it's not already set to avoid switching during execution + // Don't switch tabs if we're currently executing or if we have results to show + if (!executing && !executionResult && (activeTab === defaultTab || (activeTab === 'run' && smartParameters.length > 0))) { + setActiveTab(smartDefaultTab); + } fetchCode(); + checkAuthToken(); if (useEnvFile) { fetchEnvFile(); } + } else if (!isOpen) { + // Reset form when modal is closed + if (fileName === 'get-access-token.js') { + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + setExecutionResult(null); } - }, [isOpen, filePath, useEnvFile, fileName, detectedParameters.length]); + }, [isOpen, filePath, useEnvFile, fileName, executing, smartParameters.length, executionResult]); // Set default auth values for authentication script useEffect(() => { @@ -64,24 +130,156 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = } }, [fileName, envValues]); - // Detect parameters when code changes + // Sync internal auth state with parent useEffect(() => { - if (code) { - const envVarPattern = /process\.env\.([A-Z_]+)/g; - const matches = code.match(envVarPattern) || []; + if (!hasValidToken) { + setAuthToken(null); - const parameters = new Set (); - matches.forEach(match => { - const paramName = match.replace('process.env.', ''); - // Filter out auth parameters since they're handled centrally - if (!['CLIENT_ID', 'SECRET', 'authURL', 'baseURL'].includes(paramName)) { - parameters.add(paramName); + // Clear form fields when session is ended from main page + if (fileName === 'get-access-token.js') { + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + } + }, [hasValidToken, fileName]); + + // Load custom README if available + useEffect(() => { + if (readmePath && isOpen) { + setReadmeLoading(true); + fetch(`/api/readme?path=${encodeURIComponent(readmePath)}&format=json`) + .then(response => response.json()) + .then(data => { + if (data.success) { + setCustomReadme(data.content); + } + }) + .catch(error => { + console.error('Failed to load custom README:', error); + }) + .finally(() => { + setReadmeLoading(false); + }); + } else { + setCustomReadme(null); + } + }, [readmePath, isOpen]); + + // Detect smart parameters when code changes + useEffect(() => { + if (code) { + // Analyze code to find parameters + const analysis = analyzeRecipeCode(code, { filePath }); + const detected = detectSmartParameters(analysis.suggestedParameters, { filePath }); + setSmartParameters(detected); + } + }, [code, filePath]); + + // Check for stored credentials when auth modal opens + // Only auto-populate if form is empty (app startup scenario) + useEffect(() => { + const checkStoredCredentials = async () => { + if (isOpen && fileName === 'get-access-token.js') { + try { + const response = await fetch('/api/keys?retrieve=true'); + if (response.ok) { + const data = await response.json(); + setHasStoredKeys(data.hasStoredKeys); + setAvailableCredentialSets(data.credentialSets || []); + setDefaultCredentialSet(data.defaultSet || null); + + // Only auto-populate if fields are empty AND we have a valid token + // This prevents re-population after "End Session" is clicked + const hasEmptyFields = !envValues['CLIENT_ID'] && !envValues['SECRET']; + + if (data.hasStoredKeys && data.credentials && hasEmptyFields && hasValidToken) { + // Auto-populate form with complete config on startup + handleEnvChange('CLIENT_ID', data.credentials.clientId); + handleEnvChange('SECRET', data.credentials.clientSecret); + handleEnvChange('baseURL', data.credentials.baseURL); + handleEnvChange('authURL', data.credentials.authURL); + setStoreKeysLocally(true); // Check the checkbox since keys are stored + setSelectedCredentialSet(data.defaultSet || ''); + setCurrentFormIsStored(true); // Mark current form as representing stored data + } + } + } catch (error) { + console.log('Error checking stored credentials:', error); } + } + }; + + checkStoredCredentials(); + }, [isOpen, fileName]); + + const checkAuthToken = async () => { + try { + console.log('checkAuthToken: Fetching current token from /api/token'); + const response = await fetch('/api/token'); + if (response.ok) { + const data = await response.json(); + console.log('checkAuthToken: Response from /api/token:', { hasValidToken: data.hasValidToken, clientId: data.clientId?.substring(0,8) }); + if (data.hasValidToken && data.token) { + console.log('checkAuthToken: Updating authToken state'); + setAuthToken(data.token); + } + } + } catch (error) { + console.log('No cached token available'); + } + }; + + const clearToken = async () => { + setClearingToken(true); + try { + // Clear the session token + const response = await fetch('/api/token/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ clearAll: true }) }); - setDetectedParameters(Array.from(parameters)); + if (response.ok) { + setAuthToken(null); + + // Handle stored keys logic for auth script + if (fileName === 'get-access-token.js') { + if (!storeKeysLocally && hasStoredKeys) { + // User unchecked the box - clear stored keys + await fetch('/api/keys', { method: 'DELETE' }); + setHasStoredKeys(false); + } + + // Always clear form fields on End Session + // This implements the new UX flow: + // - Session-only: fields cleared + // - Storage enabled: fields cleared (will be restored on next startup) + setEnvValues({ + 'baseURL': 'https://aws-api.sigmacomputing.com/v2', + 'authURL': 'https://aws-api.sigmacomputing.com/v2/auth/token', + 'CLIENT_ID': '', + 'SECRET': '' + }); + } + + if (onTokenCleared) { + onTokenCleared(); + } + } else { + console.error('Failed to clear token'); + } + } catch (error) { + console.error('Error clearing token:', error); + } finally { + setClearingToken(false); } - }, [code]); + }; const fetchCode = async () => { @@ -128,7 +326,240 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = } }; + const getDownloadFilename = (fileName: string, envValues: Record ) => { + switch (fileName) { + case 'export-workbook-element-csv.js': + return envValues['EXPORT_FILENAME'] || 'export.csv'; + case 'export-workbook-pdf.js': + return 'workbook-export.pdf'; + default: + return 'download'; + } + }; + + const getDownloadContentType = (fileName: string) => { + switch (fileName) { + case 'export-workbook-element-csv.js': + return 'text/csv'; + case 'export-workbook-pdf.js': + return 'application/pdf'; + default: + return 'application/octet-stream'; + } + }; + + const createBlobFromContent = (content: string, contentType: string) => { + // All content from DOWNLOAD_RESULT protocol is base64 encoded + try { + const byteCharacters = atob(content); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray], { type: contentType }); + } catch (error) { + // Fallback for non-base64 content (shouldn't happen with new protocol) + console.warn('Failed to decode base64 content, treating as text:', error); + return new Blob([content], { type: contentType }); + } + }; + + const handleStreamingDownload = async (filePath: string, envVariables: Record , filename: string, contentType: string) => { + try { + const response = await fetch('/api/download-stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filePath, + envVariables, + filename, + contentType + }) + }); + + if (!response.ok) { + throw new Error('Failed to start download stream'); + } + + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('No response body'); + } + + let outputMessages: string[] = []; + let jsonBuffer = ''; // Persistent buffer for handling split JSON messages + + // Initialize with starting message + const startingMessage = `${new Date().toLocaleTimeString()} - Starting export process...`; + outputMessages.push(startingMessage); + + setExecutionResult({ + output: startingMessage + '\n', + error: '', + success: null, // null indicates "in progress" + timestamp: new Date().toISOString() + }); + + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ') && line.trim() !== 'data: ') { + const jsonPart = line.substring(6); + jsonBuffer += jsonPart; + + // Try to parse the accumulated JSON + try { + const data = JSON.parse(jsonBuffer); + // Success! Reset buffer and process the data + jsonBuffer = ''; + const timestamp = new Date(data.timestamp).toLocaleTimeString(); + + // Add message to beginning of array (newest first) + // Show debug messages during development + const prefix = ''; + const newMessage = `${timestamp} - ${prefix}${data.message}`; + outputMessages.unshift(newMessage); + + // Keep only last 100 messages to see debug info + if (outputMessages.length > 100) { + outputMessages = outputMessages.slice(0, 100); + } + + // Update the execution result with progressive output (newest first) + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: null, // Keep as "in progress" until completion + timestamp: data.timestamp + }); + + // Handle download completion with folder link + if (data.type === 'success' && data.data && data.data.filename) { + // Create clickable message to open downloads folder + const folderMessage = `${timestamp} - š File saved! Click here to open downloads folder`; + const fileInfo = `${timestamp} - ā ${data.data.filename} (${Math.round(data.data.size / 1024)}KB) saved to downloaded-files/`; + outputMessages.unshift(folderMessage); + outputMessages.unshift(fileInfo); + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: true, + timestamp: data.timestamp, + downloadInfo: { + filename: data.data.filename, + localPath: data.data.localPath, + size: data.data.size + } + }); + + // Switch to Response tab to show the completion message + setActiveTab('run'); + } + + // Handle errors + if (data.type === 'error') { + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: data.message, + success: false, + timestamp: data.timestamp + }); + break; + } + + } catch (e) { + // JSON parsing failed - this might be a partial message + // Keep the buffer and wait for more data, but limit buffer size to prevent memory issues + if (jsonBuffer.length > 500000) { // 500KB limit + console.error('JSON buffer too large, discarding:', jsonBuffer.substring(0, 100) + '...'); + jsonBuffer = ''; + } + // Don't log every parse error as they're expected for partial messages + } + } else if (line.trim() === '' && jsonBuffer) { + // Empty line might indicate end of an SSE message - try to parse what we have + try { + const data = JSON.parse(jsonBuffer); + jsonBuffer = ''; // Reset on successful parse + + const timestamp = new Date(data.timestamp).toLocaleTimeString(); + const newMessage = `${timestamp} - ${data.message}`; + outputMessages.unshift(newMessage); + + if (outputMessages.length > 100) { + outputMessages = outputMessages.slice(0, 100); + } + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: null, + timestamp: data.timestamp + }); + + // Handle download completion (same logic as above) + if (data.type === 'success' && data.data && data.data.filename) { + const folderMessage = `${timestamp} - š File saved! Click here to open downloads folder`; + const fileInfo = `${timestamp} - ā ${data.data.filename} (${Math.round(data.data.size / 1024)}KB) saved to downloaded-files/`; + outputMessages.unshift(folderMessage); + outputMessages.unshift(fileInfo); + + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: '', + success: true, + timestamp: data.timestamp, + downloadInfo: { + filename: data.data.filename, + localPath: data.data.localPath, + size: data.data.size + } + }); + + setActiveTab('run'); + } + + if (data.type === 'error') { + setExecutionResult({ + output: outputMessages.join('\n') + '\n', + error: data.message, + success: false, + timestamp: data.timestamp + }); + return; // Exit the stream processing + } + + } catch (e) { + // Still couldn't parse - keep waiting for more data + } + } + } + } + + } catch (error) { + setExecutionResult({ + output: '', + error: error instanceof Error ? error.message : 'Unknown streaming error', + success: false, + timestamp: new Date().toISOString() + }); + } + }; + const executeScript = async () => { + console.log('executeScript called'); setExecuting(true); setExecutionResult(null); @@ -156,10 +587,12 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = }; // Validate that required auth credentials are provided (for auth script only) + console.log('Validating auth credentials:', { fileName, coreAuthVars }); if (fileName === 'get-access-token.js' && (!coreAuthVars.CLIENT_ID || !coreAuthVars.SECRET)) { + console.log('Validation failed - missing credentials'); setExecutionResult({ output: '', - error: 'Authentication required: Please provide CLIENT_ID and SECRET credentials in the Parameters tab.', + error: 'Authentication required: Please provide CLIENT_ID and SECRET credentials in the Config tab.', success: false, timestamp: new Date().toISOString(), httpStatus: 401, @@ -169,25 +602,94 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = return; } + console.log('Validation passed, continuing execution...'); + const allEnvVariables = { ...coreAuthVars, ...currentEnvValues }; + console.log('About to make API request with variables:', Object.keys(allEnvVariables)); + + - const response = await fetch('/api/execute', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - filePath, - envVariables: allEnvVariables - }) - }); + // Check if this is a download recipe + const isDownloadRecipe = ['export-workbook-element-csv.js', 'export-workbook-pdf.js'].includes(fileName); - const result = await response.json(); - setExecutionResult(result); + let result; + let response; - // If this is an auth script and execution was successful, notify parent + if (isDownloadRecipe) { + // Handle download recipes with streaming progress + await handleStreamingDownload(filePath, allEnvVariables, getDownloadFilename(fileName, currentEnvValues), getDownloadContentType(fileName)); + return; // Exit early since streaming handles everything + } else { + // Handle regular recipes + response = await fetch('/api/execute', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filePath, + envVariables: allEnvVariables + }) + }); + + console.log('API response received:', response.status, response.statusText); + + result = await response.json(); + console.log('API result:', result); + setExecutionResult(result); + console.log('ExecutionResult set, switching to run tab'); + setActiveTab('run'); + } + + // If this is an auth script and execution was successful, notify parent and refresh token if (result.success && fileName === 'get-access-token.js' && onTokenObtained) { onTokenObtained(); + + // Switch to Response tab to show authentication result + setActiveTab('run'); + + // Store complete config (credentials + server settings) if user checked the box + if (storeKeysLocally && allEnvVariables['CLIENT_ID'] && allEnvVariables['SECRET']) { + try { + const setName = credentialSetName.trim(); + if (!setName) { + console.warn('Cannot save credentials without a name during authentication'); + // Continue with authentication but don't save + setTimeout(() => checkAuthToken(), 1000); + return; + } + await fetch('/api/keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + clientId: allEnvVariables['CLIENT_ID'], + clientSecret: allEnvVariables['SECRET'], + baseURL: allEnvVariables['baseURL'], + authURL: allEnvVariables['authURL'], + name: setName, + setAsDefault: setAsDefault + }) + }); + setHasStoredKeys(true); + setCurrentFormIsStored(true); // Mark current form as stored + + // Show success notification for auto-save during authentication + showSaveNotification(`Config "${setName}" saved during authentication!`); + + // Update available sets + const updatedResponse = await fetch('/api/keys?list=true'); + if (updatedResponse.ok) { + const updatedData = await updatedResponse.json(); + setAvailableCredentialSets(updatedData.credentialSets || []); + setDefaultCredentialSet(updatedData.defaultSet || null); + } + } catch (error) { + console.error('Failed to store credentials:', error); + } + } + + // Refresh the auth token for smart parameter dropdowns + setTimeout(() => checkAuthToken(), 1000); } if (!response.ok) { @@ -205,11 +707,74 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = } }; + const loadCredentialSet = async (setName: string) => { + try { + const response = await fetch(`/api/keys?retrieve=true&set=${encodeURIComponent(setName)}`); + if (response.ok) { + const data = await response.json(); + if (data.credentials) { + // Load complete config: credentials + server settings + handleEnvChange('CLIENT_ID', data.credentials.clientId); + handleEnvChange('SECRET', data.credentials.clientSecret); + handleEnvChange('baseURL', data.credentials.baseURL); + handleEnvChange('authURL', data.credentials.authURL); + setCredentialSetName(setName); + setCurrentFormIsStored(true); // Mark current form as representing stored data + } + } + } catch (error) { + console.error('Failed to load credential set:', error); + } + }; + const handleEnvChange = (key: string, value: string) => { setEnvValues(prev => ({ ...prev, [key]: value })); + + // Mark form as unsaved when credentials or server settings change + if (['CLIENT_ID', 'SECRET', 'baseURL', 'authURL'].includes(key)) { + setCurrentFormIsStored(false); + } + }; + + const showSaveNotification = (message: string) => { + setSaveNotification(message); + setTimeout(() => setSaveNotification(null), 3000); // Auto-hide after 3 seconds + }; + + const deleteConfig = async (configName: string) => { + try { + await fetch(`/api/keys?config=${encodeURIComponent(configName)}`, { + method: 'DELETE' + }); + + // Clear form if we deleted the currently selected config + if (selectedCredentialSet === configName) { + setSelectedCredentialSet(''); + setCredentialSetName(''); + handleEnvChange('CLIENT_ID', ''); + handleEnvChange('SECRET', ''); + handleEnvChange('baseURL', 'https://aws-api.sigmacomputing.com/v2'); + handleEnvChange('authURL', 'https://aws-api.sigmacomputing.com/v2/auth/token'); + setCurrentFormIsStored(false); + } + + // Update available sets + const updatedResponse = await fetch('/api/keys?list=true'); + if (updatedResponse.ok) { + const updatedData = await updatedResponse.json(); + setAvailableCredentialSets(updatedData.credentialSets || []); + setDefaultCredentialSet(updatedData.defaultSet || null); + setHasStoredKeys(updatedData.credentialSets?.length > 0); + } + + showSaveNotification(`Config "${configName}" deleted successfully!`); + } catch (error) { + console.error('Failed to delete config:', error); + showSaveNotification('Failed to delete config. Please try again.'); + } }; if (!isOpen) return null; @@ -221,7 +786,6 @@ export function CodeViewer({ isOpen, onClose, filePath, fileName, envVariables = ) : ({fileName}
-{filePath}
-) : activeTab === 'params' ? (Recipe Information
-- This recipe demonstrates how to use the Sigma API for specific use cases. - Refer to the code and run the script to see the results. -
+ {readmeLoading ? ( ++ ++ ) : customReadme ? ( +Loading README...
++)}{ + let html = customReadme; + + // Handle headers + html = html.replace(/^# (.+)$/gm, '+ ) : ( +$1
'); + html = html.replace(/^## (.+)$/gm, '$1
'); + html = html.replace(/^### (.+)$/gm, '$1
'); + + // Handle inline code + html = html.replace(/`([^`]+)`/g, '$1'); + + // Handle bold text + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + + // Handle links + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Process lists line by line + const lines = html.split('\n'); + const processed = []; + let inBulletList = false; + let inNumberList = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (trimmed.startsWith('- ')) { + if (!inBulletList) { + processed.push(''); + inBulletList = true; + } + if (inNumberList) { + processed.push(''); + inNumberList = false; + } + processed.push(`
'); + inBulletList = false; + } + processed.push(`- ${trimmed.substring(2)}
`); + } else if (/^\d+\. /.test(trimmed)) { + if (!inNumberList) { + processed.push(''); + inNumberList = true; + } + if (inBulletList) { + processed.push('
${trimmed.replace(/^\d+\. /, '')} `); + } else { + if (inBulletList) { + processed.push(''); + inBulletList = false; + } + if (inNumberList) { + processed.push(''); + inNumberList = false; + } + if (trimmed === '') { + // Only add break if we're not between sections + const nextLine = lines[i + 1]?.trim(); + if (nextLine && !nextLine.startsWith('#')) { + processed.push(''); + } + } else if (trimmed.startsWith('#')) { + // Headers are already processed, just add the line + processed.push(line); + } else { + // Regular text - just add it with minimal spacing + processed.push(`${line}`); + } + } + } + + // Close any open lists + if (inBulletList) processed.push(''); + if (inNumberList) processed.push(''); + + return processed.join('\n'); + })() + }} + /> +++ )}Recipe Information
++ This recipe demonstrates how to use the Sigma API for specific use cases. + Refer to the code and run the script to see the results. +
+{fileName === 'get-access-token.js' ? ( ---š Authentication Credentials
-- Enter your Sigma API credentials to authenticate -
++ {/* Header with Setup Guide in top-right corner */} ++- -++ + š Setup Guide +š Authentication Request
++ Configure your Sigma API credentials to access the platform +
++ Once authenticated, use the "End Session" button in the header to clear your authentication +
+-