diff --git a/aem-contenthub-assets-details-sample/README.md b/aem-contenthub-assets-details-sample/README.md index 30b9b67..468d909 100644 --- a/aem-contenthub-assets-details-sample/README.md +++ b/aem-contenthub-assets-details-sample/README.md @@ -1,28 +1,243 @@ -# SampleApp +# Content Hub – Extend Expiry with Workfront Integration -Welcome to my Adobe I/O Application! +This sample shows how to build an AEM Content Hub UI Extension that lets users request an asset-expiry extension directly from the Content Hub asset-details panel. On submission the extension creates a Workfront task and links the selected asset to that task – all through a single Adobe I/O Runtime action. -## Setup +--- -- Populate the `.env` file in the project root and fill it as shown [below](#env) +## Prerequisites -## Local Dev +**App Builder & Local Setup** -- `aio app run` to start your local Dev server -- App will run on `localhost:9080` by default +- Access to [Adobe Developer Console](https://developer.adobe.com/uix/docs/guides/creating-project-in-dev-console/) in the correct IMS organization +- App Builder entitlement for your org +- Node.js and npm installed locally +- [Adobe I/O CLI](https://developer.adobe.com/uix/docs/guides/local-environment/) (`aio`) installed globally +- A GitHub Personal Access Token (required when initializing from the Content Hub sample repository) +- Familiarity with JavaScript/TypeScript, React, and basic REST APIs -By default the UI will be served locally but actions will be deployed and served from Adobe I/O Runtime. To start a -local serverless stack and also run your actions locally use the `aio app run --local` option. +**Content Hub UI Extension** + +- [Content Hub](https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/assets/content-hub/product-overview) enabled for your AEM as a Cloud Service environment +- Permissions to access Content Hub + +**Workfront Integration** + +- A Workfront environment +- A [technical integration](https://experienceleague.adobe.com/en/docs/experience-manager-learn/getting-started-with-aem-headless/authentication/service-credentials) or API client capable of server-side authentication +- [Permissions](https://experienceleague.adobe.com/en/docs/workfront/using/administration-and-setup/add-users/access-levels/access-level-requirements-in-documentation) to [create tasks](https://experienceleague.adobe.com/en/docs/workfront/using/manage-work/tasks/create-tasks/create-tasks-in-project) and link external documents +- A Workfront admin to configure the document provider for AEM/Content Hub + +--- + +## Step 1: Scaffold the Content Hub Extension and Set Up for Workfront Integration + +Start by creating a new project in [Adobe Developer Console](https://developer.adobe.com/uix/docs/guides/creating-project-in-dev-console/). + +Set up your [local development](https://developer.adobe.com/uix/docs/guides/local-environment/) environment. Next, initialize your project locally using the Adobe I/O CLI and the sample app that targets Content Hub Asset Details: + +```bash +aio login +aio console org select +aio console project select +aio console workspace select # e.g., Stage +aio app init --repo adobe/aem-uix-examples/aem-contenthub-assets-details-sample +``` + +During initialization, enable the `aem/contenthub/assets/details/1` extension and specify the repositories (delivery hosts) where the extension should be available. Edit [`src/aem-contenthub-assets-details-1/web-src/src/components/ExtensionRegistration.js`](src/aem-contenthub-assets-details-1/web-src/src/components/ExtensionRegistration.js#L10-L19) and update `allowedRepos` to include your Delivery host. + +To enable secure server-to-server communication between your Content Hub extension, Adobe I/O Runtime, and Workfront, [configure IMS Technical Account](https://experienceleague.adobe.com/en/docs/experience-manager-learn/getting-started-with-aem-headless/authentication/service-credentials) with the required product access as both User and Admin in Admin Console and in Workfront. + +### IMS Technical Account Setup + +1. Go to **Admin Console**. +2. Navigate to **Products → Workfront → Workfront** link. +3. Add the Technical Account as both **User** and **Admin**. +4. Ensure your own user is also added as both User and Admin in Admin Console and in Workfront. +5. In Adobe Developer Console, create or verify a Server-to-Server (JWT) integration for Workfront. + +### Workfront Configuration + +1. Note your tenant base URL for `WORKFRONT_BASE_URL` (e.g., `https://.my.workfront.com/attask/api/v15.0`). +2. Ensure a default project exists and note its `DEFAULT_PROJECT_ID`. +3. If using AEM external documents, get the `DOCUMENT_PROVIDER_ID` from **Setup → Documents → External Document Providers**. +4. Ensure permissions to create tasks in the default project and to link external documents. +5. Get Workfront user ID (used by `DOCUMENT_PROVIDER_ID` API when needed): + + ```bash + curl --location 'https://.my.workfront.com/attask/api-internal/user/realUser' \ + --header 'Authorization: Bearer ' + # Response → use ID field as USER_ID in DOCUMENT_PROVIDER_ID API + ``` + +6. One-time creation of `DOCUMENT_PROVIDER_ID` via API (optional): + + ```bash + curl --location --request PUT \ + "https://.my.workfront.com/attask/api/unsupported/user/?action=initializeStatelessDocumentProviderForUser" \ + --header 'user-agent: Workfront Fusion/production' \ + --header 'content-type: application/json' \ + --header 'authorization: Bearer ' \ + --data '{ + "providerType": "AEM", + "documentProviderConfigID": "", + "documentProviderConfigName": "Content Hub" + }' + ``` + + - Replace `` with the Workfront user ID. + - Replace `` with the ID of the active AEM provider configuration in Workfront. + - The resulting configuration ID is what you set as `DOCUMENT_PROVIDER_ID` in your `.env`. + +### Environment Variables + +All secrets must be added to your App Builder workspace so they remain secure and environment-specific (for example, different values for Stage and Production). These secrets allow Runtime actions to reliably create Workfront tasks, link AEM assets, and call Workfront APIs. In Stage and Production, provide all secrets via `.env` files so that `aio` can inject them at build and deploy time. + +The environment variables are mapped to Runtime action inputs in [`ext.config.yaml`](src/aem-contenthub-assets-details-1/ext.config.yaml#L19-L38). Here is the full list: + +```bash +# IMS / Technical Account +IMS_ENDPOINT=ims-na1.adobelogin.com +METASCOPES= +TECHNICAL_ACCOUNT_CLIENT_ID= +TECHNICAL_ACCOUNT_CLIENT_SECRET= +TECHNICAL_ACCOUNT_EMAIL= +TECHNICAL_ACCOUNT_ID= +ORGANIZATION_ID= +PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n" +PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" +CERTIFICATE_EXPIRATION_DATE= + +# Workfront +WORKFRONT_BASE_URL=https://.my.workfront.com/attask/api/v15.0 +AEM_AUTHOR= +DOCUMENT_PROVIDER_ID= +DEFAULT_PROJECT_ID= + +# Runtime Namespace +AIO_runtime_namespace= +``` + +Verify namespace: + +```bash +aio runtime namespace get +``` + +When selecting a workspace using the Adobe I/O CLI, the namespace is automatically populated in `.env`. + +--- + +## Step 2: Register the "Extend Expiry" Tab + +To expose the Extend Expiry panel in Content Hub, register a custom tab in your extension registration code. + +Use the `register` API from `@adobe/uix-guest`. The `ExtensionRegistration` component initializes the extension by connecting to the host application and declaring the methods that Content Hub can invoke on your extension. +Implement [assetDetails.getTabPanels()](src/aem-contenthub-assets-details-1/web-src/src/components/ExtensionRegistration.js#L30-L56) to declare your custom tab. + +You can later enhance this logic to conditionally display the tab based on asset metadata, permissions, or user group membership. + +--- + +## Step 3: Build the Extend Expiry Panel UI + +In your panel component, build the interactive UI that allows users to submit requests to extend the expiration date of assets directly from Content Hub. + +First, attach the panel to the Content Hub extension and [initialize the guest connection](src/aem-contenthub-assets-details-1/web-src/src/components/PanelExtendExpiryTab.js#L47-L48). This establishes a connection between your React component and the Content Hub host, allowing the panel to communicate with and consume host-provided APIs. + +Retrieve details about the [currently selected asset](src/aem-contenthub-assets-details-1/web-src/src/components/PanelExtendExpiryTab.js#L50-L51) using Content Hub Host APIs. + +Render an extend expiration request form that captures inputs such as new expiry date, justification, notes, or any additional information required to process the request. + +On submission, send the data to an [Adobe I/O Runtime action](src/aem-contenthub-assets-details-1/web-src/src/components/PanelExtendExpiryTab.js#L70-L71). The backend action creates a Workfront task and links it to the selected asset. + +Finally, use the [Content Hub host toast API](src/aem-contenthub-assets-details-1/web-src/src/components/PanelExtendExpiryTab.js#L124-L128) to display feedback to the user after submission. + +--- + +## Step 4: Handle Request and Create Workfront Task + +In this integration, the backend exposes a [single entry point](src/aem-contenthub-assets-details-1/actions/generic/index.js#L176-L177) to handle all requests coming from the front-end panel. This entry point inspects the `action` parameter (for example, `createTaskAndLinkAsset`) and routes the request to the appropriate handler. + +This action-dispatch pattern allows multiple backend operations to be handled through a single Runtime URL, while keeping the implementation modular, extensible, and easy to maintain. + +The [`main` function](src/aem-contenthub-assets-details-1/actions/generic/index.js#L156-L194) validates required parameters and dispatches to the correct handler based on the `action` field. + +Once the request is routed, the handler processes the information submitted through the extend-expiration request form and creates a [new task in Workfront](src/aem-contenthub-assets-details-1/actions/generic/index.js#L32-L48). + +After the task is successfully created, the handler [links the task to the corresponding asset](src/aem-contenthub-assets-details-1/actions/generic/index.js#L53-L90) in AEM Content Hub. + +By combining request routing, Workfront task creation, and asset linking within a single handler flow, the Extend Expiration Request process becomes fully automated – from submitting a request in Content Hub to creating and associating a Workfront task. + +--- + +## Step 5: Preview and Deployment + +During development, [run and preview](https://developer.adobe.com/uix/docs/guides/preview-extension-locally/) the extension locally: + +```bash +aio app run +``` + +The app will run on `localhost:9080` by default. To run your actions locally as well, use: + +```bash +aio app run --local +``` + +Once the application is in good shape, it can be fully deployed to your targeted workspace. + +To switch workspaces, use: + +```bash +aio app use -w +``` + +After workspace switching, deploy with: + +```bash +aio app deploy +``` + +After deployment, test the extension in Content Hub by enabling developer mode in the browser: + +``` +https://experience.adobe.com/?devMode=true&ext= +``` + +This allows you to load and validate your extension directly within Content Hub before releasing it to end users. + +--- + +## Why Runtime Namespace Is Required + +- The UI constructs the backend URL using the namespace to reach your deployed web action, e.g., `https://.adobeio-static.net/api/v1/web/aem-contenthub-assets-details-1/generic`. +- This ensures requests route to the correct Adobe I/O Runtime workspace (dev / stage / prod). If the namespace is wrong or missing, reqyests return 404 or hit the wrong environment. +- You can see this in the [backend URL config in PanelExtendExpiryTab.js](src/aem-contenthub-assets-details-1/web-src/src/components/PanelExtendExpiryTab.js#L26). +- Ensure your Workfront API version in `WORKFRONT_BASE_URL` matches your tenant (the example uses `v15.0`). + +--- + +## Troubleshooting + +- **Missing env vars** – The action will return 500 with a message listing missing configuration elements. +- **401/403 from Workfront** – Verify `METASCOPES`, IMS integration credentials, and `WORKFRONT_BASE_URL`. +- **Unknown action** – Ensure the UI sends `action: createTaskAndLinkAsset` and your action is deployed. +- **Panel hidden** – Make sure your repo host is in `allowedRepos` in [`ExtensionRegistration.js`](src/aem-contenthub-assets-details-1/web-src/src/components/ExtensionRegistration.js#L10). + +--- ## Test & Coverage -- Run `aio app test` to run unit tests for ui and actions -- Run `aio app test --e2e` to run e2e tests +```bash +aio app test # unit tests for UI and actions +aio app test --e2e # end-to-end tests +``` -## Deploy & Cleanup +## Cleanup -- `aio app deploy` to build and deploy all actions on Runtime and static files to CDN -- `aio app undeploy` to undeploy the app +```bash +aio app undeploy # undeploy the app +``` ## Config @@ -79,3 +294,4 @@ and make sure you have the below config added } } ``` + diff --git a/aem-contenthub-assets-details-sample/package.json b/aem-contenthub-assets-details-sample/package.json index 673be92..3d93595 100644 --- a/aem-contenthub-assets-details-sample/package.json +++ b/aem-contenthub-assets-details-sample/package.json @@ -11,9 +11,13 @@ "@adobe/uix-guest": "^0.10.5", "@react-spectrum/list": "^3.0.0-rc.0", "@spectrum-icons/workflow": "^3.2.0", + "axios": "^1.6.0", + "https-proxy-agent": "^7.0.2", "chalk": "^4", "core-js": "^3.6.4", + "jsonwebtoken": "^9.0.2", "node-html-parser": "^5.4.2-0", + "qs": "^6.11.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-error-boundary": "^1.2.5", diff --git a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/actions/generic/index.js b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/actions/generic/index.js index 6523bf2..549533c 100644 --- a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/actions/generic/index.js +++ b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/actions/generic/index.js @@ -2,68 +2,193 @@ * */ +const { Core } = require('@adobe/aio-sdk') +const { checkMissingRequestInputs } = require('../utils') +const axios = require('axios') +const { getAccessToken } = require('./token-exchange'); + /** - * This is a sample action showcasing how to access an external API - * - * Note: - * You might want to disable authentication and authorization checks against Adobe Identity Management System for a generic action. In that case: - * - Remove the require-adobe-auth annotation for this action in the manifest.yml of your application - * - Remove the Authorization header from the array passed in checkMissingRequestInputs - * - The two steps above imply that every client knowing the URL to this deployed action will be able to invoke it without any authentication and authorization checks against Adobe Identity Management System - * - Make sure to validate these changes against your security requirements before deploying the action + * Get Workfront configuration from environment variables */ +function getWorkfrontConfig(params) { + const requiredVars = ['WORKFRONT_BASE_URL', 'AEM_AUTHOR', 'DOCUMENT_PROVIDER_ID']; + + const missingVars = requiredVars.filter(varName => !params[varName]); + + if (missingVars.length > 0) { + throw new Error(`Missing required Workfront environment variables: ${missingVars.join(', ')}`); + } + + return { + baseUrl: params.WORKFRONT_BASE_URL, + aemAuthor: params.AEM_AUTHOR, + documentProviderID: params.DOCUMENT_PROVIDER_ID + }; +} +/** + * Create a task in Workfront + */ +async function createWorkfrontTask(taskData, accessToken, params) { + const workfrontConfig = getWorkfrontConfig(params); + const taskUrl = `${workfrontConfig.baseUrl}/task`; + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + }; -const fetch = require('node-fetch') -const { Core } = require('@adobe/aio-sdk') -const { errorResponse, getBearerToken, stringParameters, checkMissingRequestInputs } = require('../utils') - -// main function that will be executed by Adobe I/O Runtime -async function main (params) { - // create a Logger - const logger = Core.Logger('main', { level: params.LOG_LEVEL || 'info' }) - - try { - // 'info' is the default level if not set - logger.info('Calling the main action') - - // log parameters, only if params.LOG_LEVEL === 'debug' - logger.debug(stringParameters(params)) - - // check for missing request input parameters and headers - const requiredParams = [/* add required params */] - const requiredHeaders = ['Authorization'] - const errorMessage = checkMissingRequestInputs(params, requiredParams, requiredHeaders) - if (errorMessage) { - // return and log client errors - return errorResponse(400, errorMessage, logger) + const response = await axios.post(taskUrl, taskData, { headers }); + + if (!response.data) { + throw new Error(`Task creation failed with status: ${response.status}`); } + + return response.data; +} - // extract the user Bearer token from the Authorization header - const token = getBearerToken(params) +/** + * Link asset to task in Workfront + */ +async function linkAssetToTask(assetId, taskID, accessToken, params) { + const workfrontConfig = getWorkfrontConfig(params); + const linkUrl = `${workfrontConfig.baseUrl}/extdoc?action=linkExternalDocumentObjects`; + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + }; - // replace this with the api you want to access - const apiEndpoint = `${params.API_ENDPOINT}` - // fetch content from external api endpoint - const res = await fetch(apiEndpoint) - if (!res.ok) { - throw new Error('request to ' + apiEndpoint + ' failed with status code ' + res.status) - } - const content = await res.json() - const response = { - statusCode: 200, - body: content + // Format the asset ID for Workfront + const encodedAssetId = encodeURIComponent(assetId); + const fullAssetId = `urn:workfront:documents:aem:${workfrontConfig.aemAuthor}:${encodedAssetId}`; + + // Create the objects string + const objectsString = JSON.stringify({ + [assetId]: { + ID: fullAssetId, + name: 'asset-test', + ext: 'jpg', + isFolder: 'false' + } + }); + + const linkData = { + objects: objectsString, + refObjCode: 'TASK', + refObjID: taskID, + documentProviderID: workfrontConfig.documentProviderID, + providerType: 'AEM' + }; + + const response = await axios.put(linkUrl, linkData, { + headers, + timeout: 30000 + }); + + return response.status === 200; +} + +/** + * Handle extend expiry request: create task and link asset + */ +async function handleCreateExtendExpiryRequest(params, logger) { + try { + // Get access token + const accessToken = await getAccessToken(params); + logger.info('Access token retrieved successfully'); + + // Create task + const taskData = { + name: params.taskName || 'Task from AEM Asset', + projectID: params.DEFAULT_PROJECT_ID, + description: params.taskDescription || 'Task created from AEM Content Hub asset', + plannedStartDate: new Date().toISOString().split('T')[0] + 'T00:00:00:000-0800', + plannedCompletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] + 'T00:00:00:000-0800' + }; + + const taskResult = await createWorkfrontTask(taskData, accessToken.access_token, params); + const taskID = taskResult?.data?.ID || taskResult?.taskID; + logger.info(`Created task with ID: ${taskID}`); + + // Link asset to task + let assetLinked = false; + + if (params.assetId) { + assetLinked = await linkAssetToTask( + params.assetId, + taskID, + accessToken.access_token, + params + ); + } + + return { + statusCode: 200, + body: { + success: true, + taskID: taskID, + taskCreated: true, + assetLinked: assetLinked, + message: `Task created successfully with ID: ${taskID}. Asset linking: ${assetLinked ? 'Success' : 'Failed'}` + } + }; + + } catch (error) { + logger.error('Error creating task:', error.message); + + return { + statusCode: 500, + body: { + error: `Task creation failed: ${error.message}`, + details: { + message: error.message, + status: error.response?.status + } + } + }; } +} - // log the response status code - logger.info(`${response.statusCode}: successful request`) - return response - } catch (error) { - // log any server errors - logger.error(error) - // return with 500 - return errorResponse(500, 'server error', logger) - } +/** + * Main function executed by Adobe I/O Runtime + */ +async function main(params) { + const logger = Core.Logger('main', { level: params.LOG_LEVEL || 'info' }); + + try { + // Handle preflight OPTIONS request + if (params.__ow_method === 'OPTIONS') { + return { statusCode: 200, body: {} }; + } + + // Check for required parameters + const requiredParams = ['action']; + const errorMessage = checkMissingRequestInputs(params, requiredParams, []); + if (errorMessage) { + return { + statusCode: 400, + body: { error: errorMessage } + }; + } + + // Handle the createTaskAndLinkAsset action + if (params.action === 'createTaskAndLinkAsset') { + return await handleCreateExtendExpiryRequest(params, logger); + } + + return { + statusCode: 400, + body: { error: `Unknown action: ${params.action}` } + }; + + } catch (error) { + logger.error('Server error:', error); + return { + statusCode: 500, + body: { error: 'Internal server error' } + }; + } } -exports.main = main +exports.main = main; \ No newline at end of file diff --git a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/actions/token-exchange.js b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/actions/token-exchange.js new file mode 100644 index 0000000..f61b143 --- /dev/null +++ b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/actions/token-exchange.js @@ -0,0 +1,204 @@ +/* +* +*/ + +const axios = require('axios'); +const qs = require('qs'); +const jwt = require('jsonwebtoken'); +const util = require('util'); + +class IMSJWTTokenExchange { + constructor(host, proxy) { + if (host === undefined) { + throw new Error("Client lib must have a target host defined, imsHost or jilHost"); + } + this.host = host; + + if (proxy) { + const { HttpsProxyAgent } = require("https-proxy-agent"); + const httpsAgent = new HttpsProxyAgent({host: proxy.host, port: proxy.port}); + this.request = axios.create({ + baseURL: `https://${this.host}`, + timeout: 10000, + httpsAgent + }); + } else { + this.request = axios.create({ + baseURL: `https://${this.host}`, + timeout: 10000 + }); + } + + // Request interceptor for logging + this.request.interceptors.request.use(function (config) { + console.debug(`>> ${config.method} ${config.url}`); + if (config.verbose) { + console.debug(JSON.stringify(config, null, 2)); + } + return config; + }, function (error) { + console.error(`Failed making request ${error.message}`); + return Promise.reject(error); + }); + + // Response interceptor for logging + this.request.interceptors.response.use(function (response) { + console.debug(`<< ${response.config.method} ${response.config.url} ${response.status}`); + if (response.config.verbose) { + console.debug(util.inspect(response.data)); + } + return response; + }, function (error) { + if (error.config) { + console.error(`Error performing operation ${error.message} request ${error.config.url}`); + } else { + console.error(`Error performing operation ${error.message} request (no config)`); + } + if (error.response) { + console.error(util.inspect(error.response.data)); + } + return Promise.reject(error); + }); + } + + checkRequired(options, key) { + if (options[key] === undefined) { + throw new Error(`${key} is a required option.`); + } + } + + /** + * Exchanges a integration for an access token using JWT Token exchange with IMS. + * @returns { + * access_token, + * token_type, + * expires_in + * } + */ + async exchangeJwt(options) { + this.checkRequired(options, "issuer"); + this.checkRequired(options, "subject"); + this.checkRequired(options, "expiration_time_seconds"); + this.checkRequired(options, "metascope"); + this.checkRequired(options, "client_id"); + this.checkRequired(options, "client_secret"); + this.checkRequired(options, "privateKey"); + + const jwt_payload = { + iss: options.issuer, + sub: options.subject, + exp: options.expiration_time_seconds, + aud: `https://${this.host}/c/${options.client_id}` + }; + + options.metascope.forEach((v) => { + jwt_payload[`https://${this.host}/s/${v}`] = true; + }); + + // Sign with RSA256 + const jwt_token = jwt.sign(jwt_payload, options.privateKey, { algorithm: 'RS256' }); + + if (options.publicKey) { + console.debug(jwt.verify(jwt_token, options.publicKey, { complete: true })); + } + + const body = qs.stringify({ + client_id: options.client_id, + client_secret: options.client_secret, + jwt_token: jwt_token + }); + + const config = { + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + verbose: options.verbose + }; + + const response = await this.request.post(`/ims/exchange/jwt`, body, config); + if (response.status === 200) { + return response.data; + } + throw new Error("Failed to exchange jwt."); + } +} + +/** + * Deep validation helper function + */ +const assertPresent = (config, path, missing) => { + const pathElements = path.split("."); + let c = config; + for (let p of pathElements) { + if (!c[p]) { + missing.push(path); + return; + } + c = c[p]; + } +}; + +/** + * Get integration configuration from environment variables with deep validation + */ +function getIntegrationConfig(params) { + const integrationConfig = { + integration: { + imsEndpoint: params.IMS_ENDPOINT, + org: params.ORGANIZATION_ID, + id: params.TECHNICAL_ACCOUNT_ID, + technicalAccount: { + clientId: params.TECHNICAL_ACCOUNT_CLIENT_ID, + clientSecret: params.TECHNICAL_ACCOUNT_CLIENT_SECRET + }, + metascopes: params.METASCOPES, + privateKey: params.PRIVATE_KEY, + publicKey: params.PUBLIC_KEY + } + }; + + // Deep validation + const missing = []; + assertPresent(integrationConfig, "integration.imsEndpoint", missing); + assertPresent(integrationConfig, "integration.org", missing); + assertPresent(integrationConfig, "integration.id", missing); + assertPresent(integrationConfig, "integration.technicalAccount.clientId", missing); + assertPresent(integrationConfig, "integration.technicalAccount.clientSecret", missing); + assertPresent(integrationConfig, "integration.metascopes", missing); + assertPresent(integrationConfig, "integration.privateKey", missing); + assertPresent(integrationConfig, "integration.publicKey", missing); + + if (missing.length > 0) { + throw new Error(`The following configuration elements are missing: ${missing.join(", ")}`); + } + + return integrationConfig; +} + +/** + * Get access token using production-ready JWT exchange + */ +async function getAccessToken(params) { + const integrationConfig = getIntegrationConfig(params); + + // Create JWT exchange instance + const jwtExchange = new IMSJWTTokenExchange(integrationConfig.integration.imsEndpoint); + + // Exchange JWT for access token + return await jwtExchange.exchangeJwt({ + issuer: `${integrationConfig.integration.org}`, + subject: `${integrationConfig.integration.id}`, + expiration_time_seconds: Math.floor((Date.now() / 1000) + 3600 * 8), + metascope: integrationConfig.integration.metascopes.split(","), + client_id: integrationConfig.integration.technicalAccount.clientId, + client_secret: integrationConfig.integration.technicalAccount.clientSecret, + privateKey: integrationConfig.integration.privateKey, + publicKey: integrationConfig.integration.publicKey, + verbose: params.LOG_LEVEL === 'debug' + }); +} + +module.exports = { + IMSJWTTokenExchange, + getIntegrationConfig, + getAccessToken, + assertPresent +}; \ No newline at end of file diff --git a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/ext.config.yaml b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/ext.config.yaml index fef3b66..dedb118 100644 --- a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/ext.config.yaml +++ b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/ext.config.yaml @@ -18,6 +18,21 @@ runtimeManifest: inputs: LOG_LEVEL: debug API_ENDPOINT: $API_ENDPOINT + IMS_ENDPOINT: $IMS_ENDPOINT + METASCOPES: $METASCOPES + TECHNICAL_ACCOUNT_CLIENT_ID: $TECHNICAL_ACCOUNT_CLIENT_ID + TECHNICAL_ACCOUNT_CLIENT_SECRET: $TECHNICAL_ACCOUNT_CLIENT_SECRET + TECHNICAL_ACCOUNT_EMAIL: $TECHNICAL_ACCOUNT_EMAIL + TECHNICAL_ACCOUNT_ID: $TECHNICAL_ACCOUNT_ID + ORGANIZATION_ID: $ORGANIZATION_ID + PRIVATE_KEY: $PRIVATE_KEY + PUBLIC_KEY: $PUBLIC_KEY + CERTIFICATE_EXPIRATION_DATE: $CERTIFICATE_EXPIRATION_DATE + WORKFRONT_BASE_URL: $WORKFRONT_BASE_URL + AEM_AUTHOR: $AEM_AUTHOR + DOCUMENT_PROVIDER_ID: $DOCUMENT_PROVIDER_ID + DEFAULT_PROJECT_ID: $DEFAULT_PROJECT_ID + AIO_runtime_namespace: $AIO_runtime_namespace annotations: require-adobe-auth: false final: true diff --git a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/App.js b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/App.js index ec71c20..12bc2d2 100644 --- a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/App.js +++ b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/App.js @@ -7,6 +7,7 @@ import ErrorBoundary from 'react-error-boundary'; import { HashRouter as Router, Routes, Route } from 'react-router-dom'; import ExtensionRegistration from './ExtensionRegistration'; import PanelAssetDetailsExtensionTab from './PanelAssetDetailsExtensionTab'; +import PanelExtendExpiryTab from './PanelExtendExpiryTab'; function App() { return ( @@ -16,6 +17,7 @@ function App() { } /> } /> } /> + } /> // YOUR CUSTOM ROUTES SHOULD BE HERE diff --git a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/ExtensionRegistration.js b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/ExtensionRegistration.js index bf007d2..b6e1a63 100644 --- a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/ExtensionRegistration.js +++ b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/ExtensionRegistration.js @@ -42,6 +42,13 @@ function ExtensionRegistration() { 'title': 'Asset Details Extension Tab', 'contentUrl': '/#asset-details-extension-tab', }, + { + 'id': 'extend-expiry-tab', + 'tooltip': 'Extend Expiry', + 'icon': 'AssetsExpired', + 'title': 'Extend Expiry', + 'contentUrl': '/#extend-expiry-tab', + }, ]; }, }, diff --git a/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/PanelExtendExpiryTab.js b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/PanelExtendExpiryTab.js new file mode 100644 index 0000000..339cfdb --- /dev/null +++ b/aem-contenthub-assets-details-sample/src/aem-contenthub-assets-details-1/web-src/src/components/PanelExtendExpiryTab.js @@ -0,0 +1,179 @@ +/* + * + */ + +import React, { useState, useEffect } from 'react'; +import { attach } from '@adobe/uix-guest'; +import { + Flex, + Provider, + defaultTheme, + Text, + Button, + View, + TextField, + TextArea, + Heading, + StatusLight +} from '@adobe/react-spectrum'; + +import { extensionId } from './Constants'; + +/** + * Configuration constants + */ +const CONFIG = { + backendUrl: `https://${process.env.AIO_runtime_namespace}.adobeio-static.net/api/v1/web/aem-contenthub-assets-details-1/generic`, + requestTimeout: 30000 +}; + +export default function PanelExtendExpiryTab() { + const [guestConnection, setGuestConnection] = useState(); + const [currentAsset, setCurrentAsset] = useState(); + const [taskName, setTaskName] = useState(''); + const [taskDescription, setTaskDescription] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState({ type: 'info', message: '' }); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + if (!isInitialized) { + initializeExtension(); + } + }, [isInitialized]); + + const initializeExtension = async () => { + try { + const guestConnection = await attach({ id: extensionId }); + setGuestConnection(guestConnection); + + const asset = await guestConnection.host.assetDetails.getCurrentAsset(); + setCurrentAsset(asset); + setIsInitialized(true); + } catch (error) { + console.error('Error initializing extension:', error); + setStatus({ type: 'negative', message: 'Failed to initialize extension' }); + setIsInitialized(true); // Set to true even on error to prevent retry loops + } + }; + + const createTaskAndLinkAsset = async () => { + if (!currentAsset) { + displayToast('negative', 'Asset not available'); + return; + } + + setIsLoading(true); + setStatus({ type: 'info', message: '' }); + + try { + const response = await fetch(CONFIG.backendUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action: 'createTaskAndLinkAsset', + assetId: currentAsset, + taskName: taskName, + taskDescription: taskDescription + }), + signal: AbortSignal.timeout(CONFIG.requestTimeout) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Backend request failed! Status: ${response.status}, Message: ${errorText}`); + } + + const result = await response.json(); + handleBackendResponse(result); + + } catch (error) { + console.error('Error creating task and linking asset:', error); + setStatus({ type: 'negative', message: `Error: ${error.message}` }); + displayToast('negative', `Error: ${error.message}`); + } finally { + setIsLoading(false); + } + }; + + const handleBackendResponse = (result) => { + const responseBody = result.body || result; + + if (responseBody && responseBody.success) { + // Clear any previous status messages + setStatus({ type: 'info', message: '' }); + // Clear the form fields + setTaskName(''); + setTaskDescription(''); + displayToast('positive', 'Task created successfully!'); + } else if (responseBody && responseBody.error) { + setStatus({ type: 'negative', message: `Error: ${responseBody.error}` }); + displayToast('negative', `Failed to create task: ${responseBody.error}`); + } else { + // Fallback for unexpected response structure + setStatus({ type: 'info', message: '' }); + // Clear the form fields + setTaskName(''); + setTaskDescription(''); + displayToast('positive', 'Task creation completed'); + } + }; + + const displayToast = (variant, message) => { + if (guestConnection) { + guestConnection.host.toast.display({ variant, message }); + } + }; + + return ( + + + + + Workfront Integration + + + + Create a Workfront task and link this asset to it. + + + + +