diff --git a/.nvmrc b/.nvmrc index deed13c0..603606bc 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/jod +18.17.0 diff --git a/dev.sh b/dev.sh new file mode 100755 index 00000000..cad869ff --- /dev/null +++ b/dev.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# Auto-switch to correct Node.js version and start dev server +echo "๐Ÿš€ Starting Tableau Embedding Playbook Development Server" +echo "========================================================" + +# Check if nvm is available +if command -v nvm &> /dev/null; then + echo "๐Ÿ“ฆ Switching to Node.js version specified in .nvmrc..." + nvm use +else + echo "โš ๏ธ nvm not found. Please ensure you're using Node.js 18.17.0+" +fi + +echo "๐Ÿ”ง Starting development server on port 3001..." +npm run dev diff --git a/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 b/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 new file mode 100644 index 00000000..2b7778a8 --- /dev/null +++ b/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 @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDrWpY2DG +LS76oMng5F90DBAAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIKTlmV/tcXfrlxWG +UH+xdR0uofwQNjJczPEoc1hr9GHPAAAAoMgND3S4JelppliKkIVPGxVmdYm0BsLZofP+NT +W5i4Arv/y741ji++C8m55aKepwXP86Q+pvMOVq4PkLwsDce+1WhGlbOrzi6PXSLjjguwDp +CmPLzuqVmKbLodAdFAWPAvGD7EekpshoX2WcmHNTyHuoSKCVvzH6OFkX8jtepbPyECU0Qp +o/X+YzhZ2UJ/g7WEk3oFqm4ax0QV8uQx6FbS8= +-----END OPENSSH PRIVATE KEY----- diff --git a/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.pub b/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.pub new file mode 100644 index 00000000..052e0278 --- /dev/null +++ b/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.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKTlmV/tcXfrlxWGUH+xdR0uofwQNjJczPEoc1hr9GHP allisonreynoldsc@gmail.com diff --git a/next.config.mjs b/next.config.mjs index e814877c..de0a446a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -8,6 +8,12 @@ const withNextra = nextra({ }) export default withNextra({ + // Performance optimizations + swcMinify: true, + compress: true, + poweredByHeader: false, + generateEtags: false, + images: { remotePatterns: [ { @@ -23,6 +29,9 @@ export default withNextra({ pathname: '/**', } ], + // Optimize images + formats: ['image/webp', 'image/avif'], + minimumCacheTTL: 60, }, webpack(config) { // config.optimization.minimize = false; diff --git a/package.json b/package.json index f1f02069..38e0fdc7 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,14 @@ "version": "0.0.1", "description": "Tableau Embedded Playbook", "scripts": { - "dev": "next lint && next dev", + "dev": "next dev --port 3000", + "dev:fast": "next dev --port 3000 --turbo", + "dev:lint": "next lint && next dev --port 3000", "build": "next build", "export": "next export", "start": "next start", "lint": "next lint", + "lint:fix": "next lint --fix", "info": "next info", "demo": "next lint && next build && next start", "save": "git add pages public && git commit -m 'saving content (/public & /pages)'", diff --git a/public/img/demos/servicedesk.png b/public/img/demos/servicedesk.png new file mode 100644 index 00000000..3bcd68d8 Binary files /dev/null and b/public/img/demos/servicedesk.png differ diff --git a/public/img/themes/servicedesk/dataicon.png b/public/img/themes/servicedesk/dataicon.png new file mode 100644 index 00000000..e7df1ca5 Binary files /dev/null and b/public/img/themes/servicedesk/dataicon.png differ diff --git a/src/app/api/auth/[...nextauth]/options.ts b/src/app/api/auth/[...nextauth]/options.ts index 4710d2dc..2e54d305 100644 --- a/src/app/api/auth/[...nextauth]/options.ts +++ b/src/app/api/auth/[...nextauth]/options.ts @@ -40,21 +40,27 @@ export const authOptions: AuthOptions = { demo: { label: "Demo", type: "text" } }, async authorize(credentials: any, req) { + console.log('๐Ÿ” NextAuth Authorize Debug:'); + console.log('Credentials:', credentials); + let user: any = null; const demoManager = new UserModel(); const currentDemo = demoManager.getDemoByName(credentials.demo); + console.log('Current Demo:', currentDemo); if (currentDemo) { // Find the user in the users array of the matched demo object const matchedUser = currentDemo.users.find( (user) => user.id.toUpperCase() === credentials.ID.toUpperCase() ); + console.log('Matched User:', matchedUser); if (matchedUser) { user = { ...matchedUser }; // Clone the matched user object user.demo = credentials.demo; user.uaf = user.uaf || {}; + console.log('User UAF:', user.uaf); const jwt_client_id = process.env.TABLEAU_JWT_CLIENT_ID; const embed_secret = process.env.TABLEAU_EMBED_JWT_SECRET; @@ -62,6 +68,13 @@ export const authOptions: AuthOptions = { const rest_secret = process.env.TABLEAU_REST_JWT_SECRET; const rest_secret_id = process.env.TABLEAU_REST_JWT_SECRET_ID; + console.log('JWT Environment Variables:'); + console.log('JWT Client ID:', jwt_client_id ? 'SET' : 'NOT SET'); + console.log('Embed Secret:', embed_secret ? 'SET' : 'NOT SET'); + console.log('Embed Secret ID:', embed_secret_id ? 'SET' : 'NOT SET'); + console.log('Rest Secret:', rest_secret ? 'SET' : 'NOT SET'); + console.log('Rest Secret ID:', rest_secret_id ? 'SET' : 'NOT SET'); + // Client-safe Connected App scopes const embed_scopes = [ "tableau:views:embed", @@ -91,8 +104,10 @@ export const authOptions: AuthOptions = { jwt_client_id }; + console.log('๐Ÿš€ Creating Tableau session...'); const session = new SessionModel(user.name); await session.jwt(user.email, embed_options, embed_scopes, rest_options, rest_scopes, user.uaf); + console.log('Session Authorized:', session.authorized); if (session.authorized) { const { @@ -118,6 +133,9 @@ export const authOptions: AuthOptions = { created, expires }; + console.log('โœ… User tableau data set:', user.tableau); + } else { + console.error('โŒ Session not authorized!'); } return user.tableau ? user : null; @@ -160,13 +178,97 @@ export const authOptions: AuthOptions = { token.uaf = user.uaf || {}; token.tableau = user.tableau; token.rest_token = user.rest_token; + token.token_created = Date.now(); + } + + // Check if tokens need refresh (JWT tokens expire in 9 minutes) + if (token.tableau && token.token_created) { + const tokenAge = Date.now() - (token.token_created as number); + const refreshThreshold = 8 * 60 * 1000; // 8 minutes in milliseconds + + if (tokenAge > refreshThreshold) { + console.log('๐Ÿ”„ JWT tokens are old, refreshing...'); + try { + // Import the refresh function here to avoid circular dependencies + const { handleJWT } = await import('@/models/Session/controller'); + + const jwt_client_id = process.env.TABLEAU_JWT_CLIENT_ID; + const embed_secret = process.env.TABLEAU_EMBED_JWT_SECRET; + const embed_secret_id = process.env.TABLEAU_EMBED_JWT_SECRET_ID; + const rest_secret = process.env.TABLEAU_REST_JWT_SECRET; + const rest_secret_id = process.env.TABLEAU_REST_JWT_SECRET_ID; + + const embed_scopes = [ + "tableau:views:embed", + "tableau:views:embed_authoring", + "tableau:insights:embed", + ]; + const embed_options = { + jwt_secret: embed_secret, + jwt_secret_id: embed_secret_id, + jwt_client_id + }; + + const rest_scopes = [ + "tableau:content:read", + "tableau:datasources:read", + "tableau:workbooks:read", + "tableau:projects:read", + "tableau:insights:read", + "tableau:metric_subscriptions:read", + "tableau:insight_definitions_metrics:read", + "tableau:insight_metrics:read", + "tableau:metrics:download", + ]; + const rest_options = { + jwt_secret: rest_secret, + jwt_secret_id: rest_secret_id, + jwt_client_id + }; + + const { credentials, rest_token, embed_token } = await handleJWT( + token.email as string, + embed_options, + embed_scopes, + rest_options, + rest_scopes, + (token.uaf as any) || {} + ); + + // Update token with fresh data + token.tableau = { + ...token.tableau, + username: credentials.username, + user_id: credentials.user_id, + embed_token, + rest_token, + rest_key: credentials.rest_key, + site_id: credentials.site_id, + site: credentials.site, + created: credentials.created, + expires: credentials.expiration + }; + token.rest_token = rest_token; + token.token_created = Date.now(); + + console.log('โœ… JWT tokens refreshed successfully'); + } catch (error) { + console.error('โŒ Failed to refresh JWT tokens:', error); + // Don't throw error, just log it and continue with existing tokens + } + } } + return token; }, async session({ session, token }: { session: Session; token: JWT; }) { const customSession = session as CustomSession; if (customSession.user) { customSession.user.demo = token.demo as string; + // Add tableau data to session for client-side access + (customSession as any).tableau = token.tableau; + (customSession as any).rest_token = token.rest_token; + (customSession as any).embed_token = (token.tableau as any)?.embed_token; } return session; } diff --git a/src/app/api/auth/refresh-tokens/route.ts b/src/app/api/auth/refresh-tokens/route.ts new file mode 100644 index 00000000..612ba3fc --- /dev/null +++ b/src/app/api/auth/refresh-tokens/route.ts @@ -0,0 +1,124 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getToken } from 'next-auth/jwt'; +import { SessionModel } from '@/models'; +import { UAF } from '@/models/Session/controller'; + +interface TableauToken { + username?: string; + user_id?: string; + embed_token?: string; + rest_token?: string; + rest_key?: string; + site_id?: string; + site?: string; + created?: Date; + expires?: Date; + uaf?: UAF; +} + +export async function POST(request: NextRequest) { + try { + // Get the current JWT token from the session + const token = await getToken({ req: request }); + + if (!token?.tableau) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const tableau = token.tableau as TableauToken; + const userEmail = token.email; + + if (!userEmail) { + return NextResponse.json({ error: 'User email not found' }, { status: 400 }); + } + + console.log('๐Ÿ”„ Refreshing tokens for user:', userEmail); + + // Get the JWT configuration from environment variables + const jwt_client_id = process.env.TABLEAU_JWT_CLIENT_ID; + const embed_secret = process.env.TABLEAU_EMBED_JWT_SECRET; + const embed_secret_id = process.env.TABLEAU_EMBED_JWT_SECRET_ID; + const rest_secret = process.env.TABLEAU_REST_JWT_SECRET; + const rest_secret_id = process.env.TABLEAU_REST_JWT_SECRET_ID; + + if (!jwt_client_id || !embed_secret || !embed_secret_id || !rest_secret || !rest_secret_id) { + return NextResponse.json({ error: 'JWT configuration missing' }, { status: 500 }); + } + + // Client-safe Connected App scopes + const embed_scopes = [ + "tableau:views:embed", + "tableau:views:embed_authoring", + "tableau:insights:embed", + ]; + const embed_options = { + jwt_secret: embed_secret, + jwt_secret_id: embed_secret_id, + jwt_client_id + }; + + // Backend secured Connected App scopes + const rest_scopes = [ + "tableau:content:read", + "tableau:datasources:read", + "tableau:workbooks:read", + "tableau:projects:read", + "tableau:insights:read", + "tableau:metric_subscriptions:read", + "tableau:insight_definitions_metrics:read", + "tableau:insight_metrics:read", + "tableau:metrics:download", + ]; + const rest_options = { + jwt_secret: rest_secret, + jwt_secret_id: rest_secret_id, + jwt_client_id + }; + + // Create a new session with fresh tokens + const session = new SessionModel(userEmail); + await session.jwt(userEmail, embed_options, embed_scopes, rest_options, rest_scopes, tableau.uaf || {}); + + if (!session.authorized) { + return NextResponse.json({ error: 'Failed to refresh tokens' }, { status: 500 }); + } + + const { + username, + user_id, + embed_token, + rest_token, + rest_key, + site_id, + site, + created, + expires + } = session; + + // Return the refreshed token data + const refreshedTokens = { + tableau: { + username, + user_id, + embed_token, + rest_token, + rest_key, + site_id, + site, + created, + expires + } + }; + + console.log('โœ… Tokens refreshed successfully for user:', userEmail); + + return NextResponse.json(refreshedTokens); + + } catch (error) { + console.error('โŒ Error refreshing tokens:', error); + return NextResponse.json({ + error: 'Failed to refresh tokens', + details: error.message + }, { status: 500 }); + } +} diff --git a/src/app/api/tableau/views/route.js b/src/app/api/tableau/views/route.js new file mode 100644 index 00000000..8c975395 --- /dev/null +++ b/src/app/api/tableau/views/route.js @@ -0,0 +1,155 @@ +import { NextResponse } from 'next/server'; +import { getToken } from "next-auth/jwt"; + +export const dynamic = 'force-dynamic'; + +export async function GET(request) { + try { + // Get JWT token which contains the Tableau authentication data + const token = await getToken({ req: request }); + + if (!token?.tableau) { + return NextResponse.json({ error: 'Not authenticated or missing Tableau data' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const workbookId = searchParams.get('workbookId'); + + // Extract Tableau data from JWT token + const { tableau } = token; + const siteId = tableau.site_id; + const jwtToken = tableau.rest_token; // This is the JWT REST token + + console.log('โžก๏ธ Backend: Received request for views:', { + siteId, + workbookId, + hasJWT: !!jwtToken, + userEmail: token.email + }); + + if (!siteId || !workbookId || !jwtToken) { + return NextResponse.json({ + error: 'Missing authentication data from JWT token or workbookId', + details: { hasSiteId: !!siteId, hasWorkbookId: !!workbookId, hasJWT: !!jwtToken } + }, { status: 400 }); + } + + // First, authenticate with Tableau using the JWT token + console.log('๐Ÿ” Authenticating with Tableau using JWT...'); + + const authUrl = `${process.env.NEXT_PUBLIC_ANALYTICS_DOMAIN}/api/3.26/auth/signin`; + const authBody = ` + + + + + + `; + + const authResponse = await fetch(authUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/xml', + 'Accept': 'application/xml' + }, + body: authBody + }); + + if (!authResponse.ok) { + const authErrorText = await authResponse.text(); + console.error('โŒ Tableau Auth Error:', { + status: authResponse.status, + statusText: authResponse.statusText, + url: authResponse.url, + errorDetails: authErrorText + }); + return NextResponse.json({ + error: `Tableau Authentication Error: ${authResponse.status} ${authResponse.statusText}`, + details: authErrorText + }, { status: authResponse.status }); + } + + const authXml = await authResponse.text(); + console.log('โœ… Tableau auth response:', authXml.substring(0, 500) + '...'); + + // Extract session token from auth response + const sessionTokenMatch = authXml.match(/]*token="([^"]*)"[^>]*>/); + if (!sessionTokenMatch || !sessionTokenMatch[1]) { + console.error('โŒ Could not extract session token from auth response'); + return NextResponse.json({ + error: 'Failed to extract session token from Tableau auth response' + }, { status: 500 }); + } + + const sessionToken = sessionTokenMatch[1]; + console.log('๐ŸŽฏ Got Tableau session token:', sessionToken.substring(0, 20) + '...'); + + const tableauApiUrl = `${process.env.NEXT_PUBLIC_ANALYTICS_DOMAIN}/api/3.19/sites/${siteId}/workbooks/${workbookId}/views`; + + console.log('๐Ÿ“ก Backend: Calling Tableau REST API for views:', tableauApiUrl); + + const response = await fetch(tableauApiUrl, { + headers: { + 'X-Tableau-Auth': sessionToken, + 'Content-Type': 'application/xml' + } + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('โŒ Backend: Tableau Views API Error:', { + status: response.status, + statusText: response.statusText, + url: response.url, + errorDetails: errorText + }); + return NextResponse.json({ error: `Tableau Views API Error: ${response.status} ${response.statusText}`, details: errorText }, { status: response.status }); + } + + const xmlText = await response.text(); + console.log('โœ… Backend: Got views XML response:', xmlText.substring(0, 500) + '...'); + + // Parse XML and extract views - use regex for server-side parsing + const parseViewsXML = (xmlString) => { + const views = []; + + // Extract view elements using regex + const viewMatches = xmlString.match(/]*\/?>|]*>.*?<\/view>/gs) || []; + + viewMatches.forEach(viewXml => { + // Extract attributes using regex + const getAttr = (name) => { + const match = viewXml.match(new RegExp(`${name}="([^"]*)"`, 'i')); + return match ? match[1] : ''; + }; + + const viewData = { + id: getAttr('id'), + name: getAttr('name'), + contentUrl: getAttr('contentUrl'), + workbookId: workbookId, + createdAt: getAttr('createdAt'), + updatedAt: getAttr('updatedAt'), + viewUrlName: getAttr('viewUrlName') + }; + + views.push(viewData); + }); + + return views; + }; + + const views = parseViewsXML(xmlText); + + console.log('โœ… Backend: Returning views:', { + count: views.length, + workbookId + }); + + return NextResponse.json({ views }); + + } catch (error) { + console.error('โŒ Backend: Error fetching views:', error); + return NextResponse.json({ error: 'Failed to fetch views', details: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/tableau/workbooks/route.js b/src/app/api/tableau/workbooks/route.js new file mode 100644 index 00000000..5be9c007 --- /dev/null +++ b/src/app/api/tableau/workbooks/route.js @@ -0,0 +1,213 @@ +import { NextResponse } from 'next/server'; +import { getToken } from "next-auth/jwt"; + +export const dynamic = 'force-dynamic'; + +export async function GET(request) { + try { + // Get JWT token which contains the Tableau authentication data + const token = await getToken({ req: request }); + + if (!token?.tableau) { + return NextResponse.json({ error: 'Not authenticated or missing Tableau data' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const pageSize = searchParams.get('pageSize') || '100'; + const pageNumber = searchParams.get('page') || '1'; + + // Extract Tableau data from JWT token + const { tableau } = token; + const siteId = tableau.site_id; + const userId = tableau.user_id; + const jwtToken = tableau.rest_token; // This is the JWT REST token + + console.log('โžก๏ธ Backend: Received request for workbooks:', { + siteId, + userId, + hasJWT: !!jwtToken, + pageSize, + pageNumber, + userEmail: token.email + }); + + if (!siteId || !userId || !jwtToken) { + return NextResponse.json({ + error: 'Missing authentication data from JWT token', + details: { hasSiteId: !!siteId, hasUserId: !!userId, hasJWT: !!jwtToken } + }, { status: 400 }); + } + + // First, authenticate with Tableau using the JWT token + console.log('๐Ÿ” Authenticating with Tableau using JWT...'); + + const authUrl = `${process.env.NEXT_PUBLIC_ANALYTICS_DOMAIN}/api/3.26/auth/signin`; + const authBody = ` + + + + + + `; + + const authResponse = await fetch(authUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/xml', + 'Accept': 'application/xml' + }, + body: authBody + }); + + if (!authResponse.ok) { + const authErrorText = await authResponse.text(); + console.error('โŒ Tableau Auth Error:', { + status: authResponse.status, + statusText: authResponse.statusText, + url: authResponse.url, + errorDetails: authErrorText + }); + return NextResponse.json({ + error: `Tableau Authentication Error: ${authResponse.status} ${authResponse.statusText}`, + details: authErrorText + }, { status: authResponse.status }); + } + + const authXml = await authResponse.text(); + console.log('โœ… Tableau auth response:', authXml.substring(0, 500) + '...'); + + // Extract session token from auth response + const sessionTokenMatch = authXml.match(/]*token="([^"]*)"[^>]*>/); + if (!sessionTokenMatch || !sessionTokenMatch[1]) { + console.error('โŒ Could not extract session token from auth response'); + return NextResponse.json({ + error: 'Failed to extract session token from Tableau auth response' + }, { status: 500 }); + } + + const sessionToken = sessionTokenMatch[1]; + console.log('๐ŸŽฏ Got Tableau session token:', sessionToken.substring(0, 20) + '...'); + + // Use general workbooks endpoint with optional project filter + const url = `${process.env.NEXT_PUBLIC_ANALYTICS_DOMAIN}/api/3.26/sites/${siteId}/workbooks`; + + const params = new URLSearchParams({ + pageSize: pageSize.toString(), + pageNumber: pageNumber.toString(), + // filter: 'projectName:eq:ProjectName' // Optional: Filter by project name + }); + + console.log('๐Ÿ“ก Backend: Making API call to:', `${url}?${params}`); + + const response = await fetch(`${url}?${params}`, { + headers: { + 'X-Tableau-Auth': sessionToken, + 'Content-Type': 'application/xml', + 'Accept': 'application/xml' + } + }); + + if (!response.ok) { + console.error('โŒ Backend: Tableau API Error:', { + status: response.status, + statusText: response.statusText, + url: response.url + }); + + const errorText = await response.text(); + console.error('โŒ Backend: Error response:', errorText); + + return NextResponse.json( + { + error: `Tableau API Error: ${response.status} ${response.statusText}`, + details: errorText + }, + { status: response.status } + ); + } + + const xmlText = await response.text(); + console.log('โœ… Backend: Got XML response:', xmlText.substring(0, 500) + '...'); + + // Parse XML and extract workbooks - use a simple XML parser for Node.js + const parseXML = (xmlString) => { + const workbooks = []; + + // Extract workbook elements using regex (simple approach for server-side) + const workbookMatches = xmlString.match(/]*>.*?<\/workbook>/gs) || []; + + workbookMatches.forEach(workbookXml => { + // Extract attributes using regex + const getAttr = (name) => { + const match = workbookXml.match(new RegExp(`${name}="([^"]*)"`, 'i')); + return match ? match[1] : ''; + }; + + // Extract project info + const projectMatch = workbookXml.match(/]*>/i); + const projectId = projectMatch ? projectMatch[0].match(/id="([^"]*)"/)?.[1] || '' : ''; + const projectName = projectMatch ? projectMatch[0].match(/name="([^"]*)"/)?.[1] || '' : ''; + + const workbook = { + id: getAttr('id'), + name: getAttr('name'), + description: getAttr('description'), + contentUrl: getAttr('contentUrl'), + webPageUrl: getAttr('webpageUrl'), + showTabs: getAttr('showTabs') === 'true', + size: parseInt(getAttr('size') || '0'), + createdAt: getAttr('createdAt'), + updatedAt: getAttr('updatedAt'), + encryptExtracts: getAttr('encryptExtracts') === 'true', + defaultViewId: getAttr('defaultViewId'), + projectId: projectId, + projectName: projectName + }; + + workbooks.push(workbook); + }); + + return workbooks; + }; + + const workbooks = parseXML(xmlText); + + // Get pagination info using regex + const paginationMatch = xmlText.match(/]*>/i); + let totalAvailable = 0; + let pageSizeAttr = 0; + let pageNumberAttr = 0; + + if (paginationMatch) { + const paginationXml = paginationMatch[0]; + totalAvailable = parseInt(paginationXml.match(/totalAvailable="([^"]*)"/)?.[1] || '0'); + pageSizeAttr = parseInt(paginationXml.match(/pageSize="([^"]*)"/)?.[1] || '0'); + pageNumberAttr = parseInt(paginationXml.match(/pageNumber="([^"]*)"/)?.[1] || '0'); + } + + console.log('โœ… Backend: Returning workbooks:', { + count: workbooks.length, + totalAvailable, + pageSize: pageSizeAttr, + pageNumber: pageNumberAttr, + hasMore: parseInt(pageNumber) * parseInt(pageSize) < totalAvailable + }); + + return NextResponse.json({ + workbooks, + pagination: { + totalAvailable, + pageSize: pageSizeAttr, + pageNumber: pageNumberAttr, + hasMore: parseInt(pageNumber) * parseInt(pageSize) < totalAvailable + } + }); + + } catch (error) { + console.error('โŒ Backend: Error in workbooks API:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/test/session/route.js b/src/app/api/test/session/route.js new file mode 100644 index 00000000..6e55b3b1 --- /dev/null +++ b/src/app/api/test/session/route.js @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '../../auth/[...nextauth]/options'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request) { + try { + // Get the session to see if user is authenticated + const session = await getServerSession(authOptions); + + console.log('๐Ÿ” Test API - Session check:', { + hasSession: !!session, + sessionKeys: session ? Object.keys(session) : [], + user: session?.user ? { + name: session.user.name, + email: session.user.email, + hasRestKey: !!session.user.rest_key, + hasEmbedToken: !!session.user.embed_token, + userId: session.user.user_id, + siteId: session.user.site_id + } : null + }); + + return NextResponse.json({ + authenticated: !!session, + session: session ? { + user: session.user, + expires: session.expires + } : null, + timestamp: new Date().toISOString() + }); + + } catch (error) { + console.error('โŒ Test API Error:', error); + return NextResponse.json( + { error: 'Internal server error', details: error.message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/user/route.js b/src/app/api/user/route.js index 804e7637..ba9b4504 100644 --- a/src/app/api/user/route.js +++ b/src/app/api/user/route.js @@ -26,8 +26,9 @@ export async function POST(req) { vectors, uaf, embed_token: tableau.embed_token, - // rest_token: tableau.rest_token, // only for debugging the JWT on the client + rest_key: tableau.rest_token, // REST API token for Tableau API calls user_id: tableau.user_id, + site_id: tableau.site_id, // Add site_id for API calls site: tableau.site, created: tableau.created, expires: tableau.expires diff --git a/src/app/demo/cumulus/Home.jsx b/src/app/demo/cumulus/Home.jsx index 4c2e922f..c52a1b28 100644 --- a/src/app/demo/cumulus/Home.jsx +++ b/src/app/demo/cumulus/Home.jsx @@ -20,7 +20,7 @@ export const Home = () => { />
- + Overview The financial portfolio and client performance overview dashboard offers a snapshot of Assets Under Management (AUM), client count, and total assets. It visually tracks performance over time, highlighting portfolio growth and client engagement, providing a quick assessment of investment effectiveness and client satisfaction. @@ -30,22 +30,9 @@ export const Home = () => { src='https://prod-useast-b.online.tableau.com/t/embeddingplaybook/views/PortfolioPerformance/PortfolioOverview' hideTabs={true} toolbar='hidden' - className=' - min-w-[800px] min-h-[800px] - sm:min-w-[800px] sm:min-h-[800px] - md:min-w-[800px] md:min-h-[800px] - lg:min-w-[800px] lg:min-h-[800px] - xl:min-w-[800px] xl:min-h-[800px] - 2xl:min-w-[800px] 2xl:min-h-[800px] - ' - layouts = {{ - 'xs': { 'device': 'default' }, - 'sm': { 'device': 'default' }, - 'md': { 'device': 'default' }, - 'lg': { 'device': 'default' }, - 'xl': { 'device': 'default' }, - 'xl2': { 'device': 'default' }, - }} + className='w-full h-[500px] sm:h-[600px] md:h-[700px] lg:h-[800px] xl:h-[950px] 2xl:h-[900px]' + width='100%' + height='100%' /> diff --git a/src/app/demo/cumulus/clientportfolio/ClientPortfolio.jsx b/src/app/demo/cumulus/clientportfolio/ClientPortfolio.jsx index 839a3fe9..7fa41c99 100644 --- a/src/app/demo/cumulus/clientportfolio/ClientPortfolio.jsx +++ b/src/app/demo/cumulus/clientportfolio/ClientPortfolio.jsx @@ -32,7 +32,7 @@ export const ClientPortfolio = (props) => { */} - + Client Performance @@ -42,33 +42,17 @@ export const ClientPortfolio = (props) => { - + Asset Performance @@ -77,33 +61,18 @@ export const ClientPortfolio = (props) => { - + Advisor Portfolio @@ -113,27 +82,11 @@ export const ClientPortfolio = (props) => { diff --git a/src/app/demo/makana/Home.jsx b/src/app/demo/makana/Home.jsx index 538fad04..4e14590d 100644 --- a/src/app/demo/makana/Home.jsx +++ b/src/app/demo/makana/Home.jsx @@ -19,7 +19,7 @@ export const Home = () => { basis='sm:basis-1/2 md:basis-1/2 lg:basis-1/3 xl:basis-1/4 2xl:basis-1/5' />
- + Care Programs Centralizes patient care programs, goals, and tasks for streamlined management. It features key metrics and visual summaries of care plan performance alongside detailed lists of recent patient activities. Designed to help care teams monitor progress and coordinate effective, goal-driven care. @@ -29,22 +29,9 @@ export const Home = () => { src='https://prod-useast-b.online.tableau.com/t/embeddingplaybook/views/MakanaHealthCarePlanPerformance1/CarePlanPerformanceSummary' hideTabs={true} toolbar='hidden' - className=' - min-w-[1300px] min-h-[800px] - sm:min-w-[1300px] sm:min-h-[800px] - md:min-w-[1300px] md:min-h-[800px] - lg:min-w-[1300px] lg:min-h-[800px] - xl:min-w-[1300px] xl:min-h-[800px] - 2xl:min-w-[1300px] 2xl:min-h-[800px] - ' - layouts = {{ - 'xs': { 'device': 'phone' }, - 'sm': { 'device': 'default' }, - 'md': { 'device': 'default' }, - 'lg': { 'device': 'default' }, - 'xl': { 'device': 'default' }, - 'xl2': { 'device': 'default' }, - }} + className='w-full h-[500px] sm:h-[600px] md:h-[700px] lg:h-[800px] xl:h-[950px] 2xl:h-[900px]' + width='100%' + height='100%' /> diff --git a/src/app/demo/makana/members/Orders.jsx b/src/app/demo/makana/members/Orders.jsx index bb1fa9f8..cc91cf0e 100644 --- a/src/app/demo/makana/members/Orders.jsx +++ b/src/app/demo/makana/members/Orders.jsx @@ -10,10 +10,21 @@ export const description = "An orders dashboard with a sidebar navigation. The s export const Orders = (props) => { const { status, data, error, isError, isSuccess } = useMetrics(); // define which metrics to store on this page - const metricIds = ["da6f99eb-8cda-418f-8d9a-564a0c35bd1f", "54f85f6b-9c68-4e2c-98b7-b2ee8d2e07a9"]; + const metricIds = ["da6f99eb-8cda-418f-8d9a-564a0c35bd1f", "3ad631b6-565b-449d-974c-e9c007120a97"]; let metrics; + if (isSuccess && data) { + // Debug: Log all available metric IDs + console.log("Available metric IDs in data:"); + data.forEach((metric, index) => { + console.log(`Metric ${index}:`, { + id: metric.id, + name: metric.name, + type: typeof metric.id + }); + }); + // extract metrics if data is available metrics = extractMetrics(data, metricIds); } @@ -32,8 +43,8 @@ export const Orders = (props) => {
{isSuccess ? ( <> - + ) : null}
@@ -41,7 +52,7 @@ export const Orders = (props) => {
- + Shipping Summary @@ -53,26 +64,13 @@ export const Orders = (props) => { src='https://prod-useast-b.online.tableau.com/t/embeddingplaybook/views/superstore/ShipSummary' hideTabs={true} toolbar='hidden' - className=' - min-w-[309px] min-h-[240px] - sm:min-w-[486px] sm:min-h-[300px] - md:min-w-[600px] md:min-h-[400px] - lg:min-w-[240px] lg:min-h-[248px] - xl:min-w-[309px] xl:min-h-[226px] - 2xl:min-w-[400px] 2xl:min-h-[236px] - ' - layouts = {{ - 'xs': { 'device': 'default' }, - 'sm': { 'device': 'phone' }, - 'md': { 'device': 'default' }, - 'lg': { 'device': 'default' }, - 'xl': { 'device': 'tablet' }, - 'xl2': { 'device': 'desktop' } - }} + className='w-full h-[300px] sm:h-[400px] md:h-[500px] lg:h-[400px] xl:h-[450px] 2xl:h-[500px]' + width='100%' + height='100%' /> - + Shipping Trends @@ -84,22 +82,9 @@ export const Orders = (props) => { src='https://prod-useast-b.online.tableau.com/t/embeddingplaybook/views/superstore/ShippingTrend' hideTabs={true} toolbar='hidden' - className=' - min-w-[309px] min-h-[240px] - sm:min-w-[486px] sm:min-h-[300px] - md:min-w-[600px] md:min-h-[400px] - lg:min-w-[240px] lg:min-h-[248px] - xl:min-w-[309px] xl:min-h-[226px] - 2xl:min-w-[400px] 2xl:min-h-[236px] - ' - layouts = {{ - 'xs': { 'device': 'default' }, - 'sm': { 'device': 'phone' }, - 'md': { 'device': 'default' }, - 'lg': { 'device': 'default' }, - 'xl': { 'device': 'tablet' }, - 'xl2': { 'device': 'desktop' } - }} + className='w-full h-[300px] sm:h-[400px] md:h-[500px] lg:h-[400px] xl:h-[450px] 2xl:h-[500px]' + width='100%' + height='100%' /> diff --git a/src/app/demo/makana/mother/Products.jsx b/src/app/demo/makana/mother/Products.jsx index 2be4cd1c..8b380af2 100644 --- a/src/app/demo/makana/mother/Products.jsx +++ b/src/app/demo/makana/mother/Products.jsx @@ -32,7 +32,7 @@ export const Products = (props) => { - + Segment Analysis @@ -42,33 +42,17 @@ export const Products = (props) => { - + Category Performance @@ -78,27 +62,11 @@ export const Products = (props) => { diff --git a/src/app/demo/servicedesk/Home.jsx b/src/app/demo/servicedesk/Home.jsx new file mode 100644 index 00000000..42a4a865 --- /dev/null +++ b/src/app/demo/servicedesk/Home.jsx @@ -0,0 +1,409 @@ +"use client"; + +import { useState, useRef, useEffect } from 'react'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui"; +import { Metrics, TableauEmbed } from '@/components'; +import Image from 'next/image'; +import { + Headphones, + AlertTriangle, + TrendingUp, + CheckCircle, + Clock, + Filter, + X, + MessageSquare +} from 'lucide-react'; + +export const description = "Service Excellence Platform - Customer service analytics with real-time metrics, case management, and training insights to drive customer satisfaction and renewals"; + +export const Home = () => { + const [showFilterPopup, setShowFilterPopup] = useState(false); + const [casePriority, setCasePriority] = useState('all'); + const [selectedMarks, setSelectedMarks] = useState([]); + const [showEmailModal, setShowEmailModal] = useState(false); + const [emailPreviews, setEmailPreviews] = useState([]); + const [currentEmailIndex, setCurrentEmailIndex] = useState(0); + const [showSlackModal, setShowSlackModal] = useState(false); + const [slackMessage, setSlackMessage] = useState(''); + const [editableSlackMessage, setEditableSlackMessage] = useState(''); + const [currentUser, setCurrentUser] = useState(null); + const [userLoaded, setUserLoaded] = useState(false); + + // Get current user + useEffect(() => { + const fetchUser = async () => { + try { + const response = await fetch('/api/user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.ok) { + const userData = await response.json(); + setCurrentUser(userData); + setUserLoaded(true); + } else { + setUserLoaded(true); + } + } catch (error) { + setUserLoaded(true); + } + }; + fetchUser(); + }, []); + + // Prevent Tableau focus jumping + useEffect(() => { + const preventTableauFocusJump = () => { + const tableauVizElements = document.querySelectorAll('tableau-viz'); + + tableauVizElements.forEach(viz => { + // Prevent focus events from causing page jumps + viz.addEventListener('focus', (e) => { + e.preventDefault(); + e.stopPropagation(); + }, true); + + // Prevent click events from causing focus jumps + viz.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + }, true); + + // Prevent scroll events from Tableau + viz.addEventListener('scroll', (e) => { + e.preventDefault(); + e.stopPropagation(); + }, true); + }); + }; + + // Run immediately and on any DOM changes + preventTableauFocusJump(); + + // Use MutationObserver to catch dynamically added Tableau elements + const observer = new MutationObserver(() => { + preventTableauFocusJump(); + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + + return () => { + observer.disconnect(); + }; + }, []); + + // Apply filter to Tableau dashboards when case priority changes + useEffect(() => { + const applyFilter = async () => { + const fieldName = 'Case Priority'; + const filterValue = casePriority === 'all' ? [] : [casePriority]; + + const applyFilterToViz = async (vizId) => { + const viz = document.getElementById(vizId); + + if (!viz) { + return; + } + + if (!viz.workbook) { + setTimeout(() => applyFilterToViz(vizId), 500); + return; + } + + try { + const activeSheet = viz.workbook.activeSheet; + + if (activeSheet.sheetType === 'dashboard') { + const worksheets = activeSheet.worksheets; + + for (const worksheet of worksheets) { + if (casePriority === 'all') { + await worksheet.clearFilterAsync(fieldName); + } else { + await worksheet.applyFilterAsync(fieldName, filterValue, 'replace'); + } + } + } else { + if (casePriority === 'all') { + await activeSheet.clearFilterAsync(fieldName); + } else { + await activeSheet.applyFilterAsync(fieldName, filterValue, 'replace'); + } + } + } catch (error) { + // Filter application failed silently + } + }; + + await applyFilterToViz('serviceDashboardViz'); + await applyFilterToViz('caseManagementViz'); + }; + + applyFilter(); + }, [casePriority]); + + return ( + <> + +
+
+ {/* Header Section */} +
+
+

+ Service Excellence Dashboard +

+

+ Real-time insights to build trust, drive renewals, and showcase premium service +

+
+
+
+
+
+ System Healthy +
+
+
+
+ + {/* Pulse Metrics */} + + + {/* Main Dashboard */} +
+ + {/* Case Status Filter Widget */} +
+ + + {/* Action Button - Shows when marks are selected */} + {selectedMarks.length > 0 && userLoaded && ( + + )} +
+ + {/* Case Management Dashboard */} +
+ + + + + Case Management + + + Track open, closed cases and response times + + + +
+ +
+
+
+
+
+
+ + {/* Filter Popup Modal */} + {showFilterPopup && ( +
setShowFilterPopup(false)}> +
e.stopPropagation()}> +
+

+ + Filter by Case Priority +

+ +
+ +
+ {['All', 'Urgent', 'High', 'Medium', 'Low'].map((priority) => ( + + ))} +
+
+
+ )} + + {/* Slack Message Modal */} + {showSlackModal && ( +
setShowSlackModal(false)}> +
e.stopPropagation()}> +
+

+ + Share Case Update +

+ +
+ +
+ {/* Message Body */} +
+ +