Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Load js asynchronously and login using url #60

Merged
merged 7 commits into from
Jun 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 55 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,51 +72,33 @@ To test against a local hydra instance

### Make your frontend editable

- Take the latest [hydra.js](https://github.com/collective/volto-hydra/tree/hydra.js) and include it in your frontend
- Your frontend will know to initialise the hydra iframe bridge when it is being edited using hydra as it will recieve a ```?hydra_auth=xxxxx```
- Take the latest [hydra.js](https://github.com/collective/volto-hydra/tree/main/packages/hydra-js) frome hydra-js package and include it in your frontend
- Your frontend will know to initialise the hydra iframe bridge when it is being edited using hydra as it will recieve a ```?_edit=true```, [checkout below](#asynchronously-load-the-bridge) to load `hydra.js` asynchronously.
- Initialising hydra iframe bridge creates a two way link between the hydra editor and your frontend. You will be able to optionally register call backs
for events allowing you to add more advanced editor functionality depending on your needs.

### How to initialise the bridge.

- Import `initBridge` from [hydra.js](https://github.com/collective/volto-hydra/tree/hydra.js).
- Call the `initBridge` and pass the origin of your adminUI as the argument to the initBridge method.
- For example, if you are trying out demo editor, it will be: `https://hydra.pretagov.com`
```js
// In Layout.js or App.js
import { initBridge } from './hydra.js';
initBridge("https://hydra.pretagov.com");
```
- This will enable the 2 way link between hydra and your frontend.
- Log into https://hydra.pretagov.com/ and paste in your local running frontend to test.

TODO: more integrations will be added below as the [Hydra GSoC project progresses](https://github.com/orgs/collective/projects/3/views/4)

#### Authenticate frontend to access private content

In hydra.js, it initiates the Bridge, and starts listening to the token response from the Hydra. It also have the method `(_getTokenFromCookies)` to fetch the token from the cookies and pass it to the integrator to use it in the `ploneClient.initialize()`.

Integrate your frontend:

- Add 'hydra.js` in your frontend.
- Initialize the Bridge using `initBridge` method provided by './hydra.js', use 'https://hydra.pretagov.com' for option `adminOrigin` to tryout demo.
- Use the `getToken()` method provided by './hydra.js' to access the token. Use this in your ploneClient inctance.
- At [Volto-Hydra demo](https://hydra.pretagov.com/) type in your hosted frontend url to preview public content and login to see the private pages.
- When you input your frontend URL at the Volto Hydra (adminUI) it will set 2 params in your frontend URL.
- You can extract the `access_token` parameter directly from the URL for the `ploneClient` token option.
- Or you can use it in Authorization header if you are using other methods to fetch content from plone Backend.

Example Usage:
```js
// nextjs 14
// nextjs 14 using ploneClient
import ploneClient from "@plone/client";
import { useQuery } from "@tanstack/react-query";
import { initBridge } from "@/utils/hydra";

export default function Blog({ params }) {
const bridge = initBridge("http://localhost:3000"); // Origin of your local Volto-Hydra
const token = bridge._getTokenFromCookie();
// Extract token directly from the URL
const url = new URL(window.location.href);
const token = url.searchParams.get("access_token");
MAX-786 marked this conversation as resolved.
Show resolved Hide resolved

const client = ploneClient.initialize({
apiPath: "http://localhost:8080/Plone/", // Plone backend
token: token,
});

const { getContentQuery } = client;
const { data, isLoading } = useQuery(getContentQuery({ path: '/blogs' }));

Expand All @@ -133,6 +115,49 @@ Reference Issue: [#6](https://github.com/collective/volto-hydra/issues/6)

Now your editors login to hydra and navigate the site within the editor or via the frontend displayed in the middle of the screen. They can add, remove objects and do normal plone toolbar functions as well as edit a page metadata via the sidebar.

### How to initialise the bridge.

- Import `initBridge` from [hydra.js](https://github.com/collective/volto-hydra/tree/main/packages/hydra-js).
- Call the `initBridge` and pass the origin of your adminUI as the argument to the initBridge method.
- For example, if you are trying out demo editor, it will be: `https://hydra.pretagov.com`
```js
// In Layout.js or App.js
import { initBridge } from './hydra.js';
initBridge("https://hydra.pretagov.com");
```
- This will enable the 2 way link between hydra and your frontend.
- Log into https://hydra.pretagov.com/ and paste in your local running frontend to test.

### Asynchronously Load the Bridge

Since the script has a considerable size, it’s recommended to load the bridge only when necessary, such as in edit mode.
To load the bridge asynchronously, add a function that checks if the bridge is already present. If it isn't, the function will load it and then call a callback function. This ensures the bridge is loaded only when needed.

```js
function loadBridge(callback) {
const existingScript = document.getElementById("hydraBridge");
if (!existingScript) {
const script = document.createElement("script");
script.src = "./hydra.js";
script.id = "hydraBridge";
document.body.appendChild(script);
script.onload = () => {
callback();
};
} else {
callback();
}
}

// Initialize the bridge only inside the admin UI
if (window.location.search.includes('_edit=true')) {
loadBridge(() => {
const { initBridge } = window;
initBridge('https://hydra.pretagov.com');
});
}
```

#### Show changes after save

This is the most basic form of integration. For this no additional integraion is needed.
Expand Down Expand Up @@ -188,4 +213,4 @@ on https://hydra.pretagov.com for others to test.
But be sure to subscribe to the project so you can keep your frontend updated with changes to the hydra api as more
capabilities are added. If there are bugs lets us know.


TODO: more integrations will be added below as the [Hydra GSoC project progresses](https://github.com/orgs/collective/projects/3/views/4)
159 changes: 79 additions & 80 deletions packages/hydra-js/hydra.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ class Bridge {
constructor(adminOrigin) {
this.adminOrigin = adminOrigin;
this.token = null;
this.navigationHandler = null; // Handler for navigation events
this.realTimeDataHandler = null; // Handler for message events
this.blockClickHandler = null; // Handler for block click events
this.init();
}

Expand All @@ -12,25 +15,27 @@ class Bridge {
}

if (window.self !== window.top) {
window.navigation.addEventListener('navigate', (event) => {
this.navigationHandler = (event) => {
window.parent.postMessage(
{ type: 'URL_CHANGE', url: event.destination.url },
this.adminOrigin,
);
});
}
};

window.addEventListener('message', (event) => {
if (event.origin === this.adminOrigin) {
if (event.data.type === 'GET_TOKEN_RESPONSE') {
this.token = event.data.token;
this._setTokenCookie(event.data.token);
}
}
});
// Ensure we don't add multiple listeners
window.navigation.removeEventListener('navigate', this.navigationHandler);
window.navigation.addEventListener('navigate', this.navigationHandler);

// Get the access token from the URL
const url = new URL(window.location.href);
const access_token = url.searchParams.get('access_token');
this.token = access_token;
this._setTokenCookie(access_token);
}
}

onEditChange(callback) {
window.addEventListener('message', (event) => {
this.realTimeDataHandler = (event) => {
if (event.origin === this.adminOrigin) {
if (event.data.type === 'FORM') {
if (event.data.data) {
Expand All @@ -40,79 +45,50 @@ class Bridge {
}
}
}
});
}
async get_token() {
if (this.token !== null) {
return this.token;
}
const cookieToken = this._getTokenFromCookie();
if (cookieToken) {
this.token = cookieToken;
return cookieToken;
}

if (window.self !== window.top) {
try {
window.parent.postMessage({ type: 'GET_TOKEN' }, this.adminOrigin);
const token = await this._waitForToken(this.adminOrigin);
return token;
} catch (error) {
console.error('Failed to retrieve auth_token:', error);
return null;
}
} else {
return null;
}
}
};

_waitForToken(adminOrigin) {
return new Promise((resolve, reject) => {
const tokenListener = (event) => {
if (adminOrigin === this.adminOrigin) {
if (event.data.type === 'GET_TOKEN_RESPONSE') {
window.removeEventListener('message', tokenListener);
this._setTokenCookie(event.data.token);
resolve(event.data.token);
} else {
reject(
new Error(
`Invalid message type: Expected GET_TOKEN_RESPONSE, received ${event.data.type}`,
),
);
}
} else {
reject(
new Error(
`Origin mismatch: Expected ${this.adminOrigin}, received ${adminOrigin}`,
),
);
}
};
window.addEventListener('message', tokenListener);
});
// Ensure we don't add multiple listeners
window.removeEventListener('message', this.realTimeDataHandler);
window.addEventListener('message', this.realTimeDataHandler);
}

_setTokenCookie(token) {
const expiryDate = new Date();
expiryDate.setTime(expiryDate.getTime() + 12 * 60 * 60 * 1000); // 12 hours
document.cookie = `auth_token=${token}; expires=${expiryDate.toUTCString()}; path=/`;

const url = new URL(window.location.href);
const domain = url.hostname;
document.cookie = `auth_token=${token}; expires=${expiryDate.toUTCString()}; path=/; domain=${domain};`;
}

_getTokenFromCookie() {
if (typeof document === 'undefined') {
return null;
}
const name = 'auth_token=';
const decodedCookie = decodeURIComponent(document.cookie);
const cookieArray = decodedCookie.split(';');
for (let i = 0; i < cookieArray.length; i++) {
let cookie = cookieArray[i].trim();
if (cookie.indexOf(name) === 0) {
return cookie.substring(name.length, cookie.length);
enableBlockClickListener() {
this.blockClickHandler = (event) => {
const blockElement = event.target.closest('[data-block-uid]');
if (blockElement) {
const blockUid = blockElement.getAttribute('data-block-uid');
window.parent.postMessage(
{ type: 'OPEN_SETTINGS', uid: blockUid },
this.adminOrigin,
);
}
};

// Ensure we don't add multiple listeners
document.removeEventListener('click', this.blockClickHandler);
document.addEventListener('click', this.blockClickHandler);
}

// Method to clean up all event listeners
cleanup() {
if (this.navigationHandler) {
window.navigation.removeEventListener('navigate', this.navigationHandler);
}
if (this.realTimeDataHandler) {
window.removeEventListener('message', this.realTimeDataHandler);
}
if (this.blockClickHandler) {
document.removeEventListener('click', this.blockClickHandler);
}
return null;
}
}

Expand All @@ -136,19 +112,42 @@ export function initBridge(adminOrigin) {
* Get the token from the admin
* @returns string
*/
export async function getToken() {
if (bridgeInstance) {
return await bridgeInstance.get_token();
export function getTokenFromCookie() {
if (typeof document === 'undefined') {
return null;
}
return '';
const name = 'auth_token=';
const decodedCookie = decodeURIComponent(document.cookie);
const cookieArray = decodedCookie.split(';');
for (let i = 0; i < cookieArray.length; i++) {
let cookie = cookieArray[i].trim();
if (cookie.indexOf(name) === 0) {
return cookie.substring(name.length, cookie.length);
}
}
return null;
}

/**
* Enable the frontend to listen for changes in the admin and call the callback with updated data
* @param {*} initialData
* @param {*} callback
*/
export function onEditChange(callback) {
if (bridgeInstance) {
bridgeInstance.onEditChange(callback);
}
}

/**
* Enable the frontend to listen for clicks on blocks to open the settings
*/
export function enableBlockClickListener() {
if (bridgeInstance) {
bridgeInstance.enableBlockClickListener();
}
}

// Make initBridge available globally
if (typeof window !== 'undefined') {
window.initBridge = initBridge;
}
Loading