# Intégrer un tableau de bord

L'intégration est le moment où un tableau de bord cesse d'être quelque chose que votre équipe ouvre et devient une partie d'un produit que vos clients utilisent. Vibedasher a deux façons de le faire, et la différence n'est pas cosmétique — elle décide qui peut lire les données.

|                                  | Voie publique            | Voie à jeton signé                                                  |
| -------------------------------- | ------------------------ | ------------------------------------------------------------------- |
| `view_mode` du tableau de bord   | `public`                 | `protected`                                                         |
| Qui peut voir                    | n'importe qui avec l'URL | seulement un visionneur pour lequel votre backend a généré un jeton |
| Filtrage de ligne par visionneur | non                      | **oui**, appliqué côté serveur                                      |
| Ce que vous intégrez             | une URL                  | une URL plus un jeton de courte durée                               |

Un tableau de bord `private` n'est intégrable par aucune des deux voies. Les deux voies exigent que le tableau de bord soit d'abord **publié** — voir [Publier et partager](/fr/guides/publish-and-share.md).

L'intégration est l'une des deux réponses

L'intégration met *notre* tableau de bord rendu dans un rectangle de votre page. L'autre réponse est d'[éjecter](/fr/guides/eject-your-dashboard.md) — prendre la source React et SQL et la rendre vous-même, nativement, avec nous hors du chemin de rendu et seulement le moteur de données en dessous. Celle des deux que vous voulez se décide surtout par deux questions : est-ce que des visionneurs différents doivent voir des lignes différentes (l'intégration est la seule voie qui le fait), et est-ce que le tableau de bord doit avoir l'apparence et le comportement du reste de votre application (seule l'éjection vous donne cela). Les compromis sont mis côte à côte dans **[Intégrer ou éjecter](/fr/guides/embed-or-eject.md)**.

## La voie publique[​](#la-voie-publique "Lien direct vers La voie publique")

Si le tableau de bord est publié avec `view_mode: public`, une iframe est toute l'intégration :

```
<iframe

  src="https://cloud.vibedasher.com/v/<slug>"

  width="100%"

  height="800"

  style="border: 0;"

  title="Vibedasher dashboard"

  sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"

  allowfullscreen

  loading="lazy"

></iframe>
```

`allow-same-origin` paraît alarmant à côté de `allow-scripts` et est sûr ici précisément parce que le cadre est **cross-origin par rapport à votre page** : il accorde au moteur de rendu sa propre origine, pas la vôtre. `allow-top-navigation` est délibérément absent, donc le cadre ne peut pas faire naviguer votre page ailleurs. `allow-downloads` est ce qui fait fonctionner l'export PNG de graphique.

L'extrait fonctionne depuis n'importe quelle page que vous contrôlez — rien n'a à être configuré de notre côté d'abord. L'audience sous laquelle vous publiez est le contrôle qui compte ici, et [La frontière du cadre](#the-boundary-of-the-embed) est l'endroit où ce choix est exposé.

La boîte de dialogue Partager de l'application vous donne cet extrait pré-rempli. Récupérez-le là plutôt que d'assembler l'URL à la main.

## La voie à jeton signé[​](#la-voie-à-jeton-signé "Lien direct vers La voie à jeton signé")

C'est celle qui mérite d'être comprise, car c'est le seul endroit où Vibedasher applique la **sécurité au niveau des lignes**.

### Votre backend génère le jeton — vous ne signez rien[​](#votre-backend-génère-le-jeton--vous-ne-signez-rien "Lien direct vers Votre backend génère le jeton — vous ne signez rien")

Vibedasher signe le jeton lui-même, en ES256, avec sa propre clé rotative. Votre backend en demande un avec sa clé API :

```
from vibedasher.models import EmbedTokenRequest, EmbedTokenRequestFilters



resp = client.embed_tokens.create_embed(

    EmbedTokenRequest(

        viz_id=1234,

        filters=EmbedTokenRequestFilters.from_dict({"region": "EMEA"}),

        ttl_seconds=600,

    )

)

print(resp.token, resp.expires_in)
```

```
const resp = await client.embedTokens.createEmbed({

  vizId: 1234,

  filters: { region: 'EMEA' },

  ttlSeconds: 600,

});
```

Il n'existe pas de « secret d'intégration »

Vous ne détenez aucun matériel de signature et n'en avez pas besoin — générez vos jetons comme indiqué ici.

Ou en HTTP brut, depuis n'importe quel langage :

```
curl -X POST https://api.vibedasher.com/api/v1/embed/token \

  -H "X-Api-Key: $VIBEDASHER_API_KEY" \

  -H "Content-Type: application/json" \

  -d '{"vizId": 1234, "ttlSeconds": 600, "filters": {"region": "EMEA"}}'

# → {"token": "<ES256 JWT>", "token_type": "embed", "expires_in": 600}
```

Le corps de la requête accepte :

| Champ        | Requis  | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| ------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `vizId`      | **oui** | Exactement un tableau de bord. Un jeton ne peut pas couvrir un dossier ou un compte.                                                                                                                                                                                                                                                                                                                                                                             |
| `filters`    | non     | Les filtres de ligne. Voir ci-dessous.                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `viewerId`   | non     | Un identifiant de siège opaque. **Audit et mesure d'usage seulement — ce n'est pas un paramètre de contrôle d'accès.** Ne comptez pas dessus pour scoper quoi que ce soit.                                                                                                                                                                                                                                                                                       |
| `ttlSeconds` | non     | Défaut **600**. Plancher 60, plafond dur 86 400 (24 h). Les valeurs hors plage sont plafonnées à la génération *et* revérifiées à la vérification. La vérification tolère une petite marge de dérive d'horloge au-delà de `exp`, plafonnée à 120 s **et** à la moitié de la durée de vie du jeton lui-même — la durée de vie réellement appliquée ne dépasse donc jamais 1,5× `expires_in`, et à partir d'un TTL de 240 s la marge est le plafond fixe de 120 s. |
| `apiKeyId`   | non     | Seulement nécessaire lors de la génération depuis une session de navigateur, pour lier le jeton à la liste blanche de domaines d'une clé et à sa révocation.                                                                                                                                                                                                                                                                                                     |

Le `account_id` est estampillé à partir de votre identifiant et n'est jamais lu depuis le corps de la requête.

La génération échoue avec un message qui nomme le contrôle en échec — vous générez un jeton, vous n'en présentez pas un, donc l'erreur vous dit quoi changer sur le tableau de bord ou la clé :

| Statut | Détail                                                                                    | Correctif                                                                                  |
| ------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `403`  | la visualisation appartient à un autre compte                                             | générez avec une clé API du compte qui la détient                                          |
| `403`  | la visualisation n'est pas intégrable avec un jeton : sa visibilité doit être `protected` | passez la visibilité du tableau de bord sur **Protégé** dans la boîte de dialogue Partager |
| `404`  | la visualisation n'est pas publiée                                                        | publiez-la d'abord                                                                         |
| `403`  | aucune clé API utilisable pour lier le jeton d'intégration                                | la clé est absente, inactive, expirée ou appartient à un autre compte                      |
| `404`  | Viz introuvable                                                                           | vérifiez `vizId`                                                                           |

**Un tableau de bord `public` est rejeté par cette voie** — il a déjà la voie anonyme, et mélanger les deux serait une façon de croire que vous avez des filtres de ligne alors que non.

### Filtres de ligne[​](#filtres-de-ligne "Lien direct vers Filtres de ligne")

`filters` est une correspondance entre un nom de colonne et un scalaire ou une liste. Un scalaire devient `=`, une liste devient `IN`, et plusieurs clés sont combinées par ET. Les clés sont le `sqlName` de la colonne tel que `GET /v1/datasets/{id}` le rapporte — le nom que la vue SQL expose, pas le nom d'affichage.

La plateforme n'ajoute pas de clause `WHERE` à votre requête ; elle **réécrit chaque référence de jeu de données en une relation filtrée**, en conservant l'alias d'origine :

```
FROM orders

  ->  FROM (SELECT * FROM "orders" WHERE "tenant" = 'acme') AS orders
```

C'est ce qui rend la portée **non élargissable**, et pas seulement présente. Ce que la page demande s'applique *à l'extérieur* de la relation filtrée, donc elle ne peut que la restreindre davantage — `WHERE tenant = 'globex' OR 1=1` ne voit toujours qu'un seul locataire, une sous-requête enveloppante enveloppe la relation filtrée, les deux branches d'un `UNION` sont réécrites, et une CTE qui masque l'alias, une référence qualifiée par schéma, ou toute fonction de lecture de fichier sont refusées d'emblée.

Trois propriétés rendent cela auditable :

* **Les filtres ne proviennent que du jeton vérifié.** Ils ne sont jamais lus depuis le corps de la requête, donc un visionneur qui fouille l'onglet réseau ne peut pas élargir sa propre portée.
* **Une clé de filtre qui ne nomme aucune colonne des jeux de données du tableau de bord est refusée à la création** — `403`, en nommant la clé et en listant les colonnes qui existent. Aucun jeton n'est signé. Il en va de même pour une valeur qui ne peut pas être convertie au type de sa colonne. Un filtre qui correspondrait silencieusement à tout serait une fuite de données déguisée en contrôle de sécurité : il est donc refusé avant de pouvoir devenir un lien que vous remettez à un client.
* **La création vous dit ce que le jeton permet**, dans le champ `scope` de sa réponse. Lisez-le ; ne le supposez pas.

#### Lisez `scope.fullyScoped` — ce n'est pas « les filtres ont été acceptés »[​](#read-fullyscoped "Lien direct vers read-fullyscoped")

Un tableau de bord peut lire plusieurs jeux de données, et un filtre ne peut s'appliquer qu'à un jeu de données qui **possède cette colonne** — on ne filtre pas sur une colonne qu'une table n'a pas. Ainsi un tableau de bord sur `orders` et `price_list`, avec la portée `{"tenant": "acme"}`, filtre `orders` et lit `price_list` **en entier**. La page se charge, chaque tuile se dessine, chaque nombre est plausible, et rien dans la requête n'a l'air anormal.

C'est correct et voulu quand le second jeu de données est une table de référence partagée — une grille tarifaire, un catalogue d'offres, un jeu de libellés. Ce n'est **pas** ce que vous voulez quand le second jeu de données contient des lignes par client sous une colonne au nom différent (`tenant_id` plutôt que `tenant`, `customer`, `org`). La réponse de création est l'endroit où vous découvrez dans quel cas vous êtes :

```
{

  "token": "<ES256 JWT>", "token_type": "embed", "expires_in": 600,

  "scope": {

    "vizId": 1234,

    "filtered":   [{"datasetId": 7, "datasetName": "orders",

                    "predicate": "\"tenant\" = 'acme'", "columns": ["tenant"]}],

    "unfiltered": [{"datasetId": 8, "datasetName": "price_list",

                    "predicate": null, "columns": []}],

    "unresolvedDatasetIds": [],

    "fullyScoped": false,

    "meaning": "PARTIALLY SCOPED — read this before handing the link out. ..."

  }

}
```

| Champ                  | Quoi en faire                                                                                                                                                                                                                      |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fullyScoped`          | **Celui sur lequel asserter.** `true` seulement quand chaque jeu de données lu par le tableau de bord est filtré par ces filtres. Tout le reste vaut `false`.                                                                      |
| `filtered[].predicate` | Le SQL exact appliqué à ce jeu de données. C'est la phrase à mettre devant un responsable sécurité.                                                                                                                                |
| `unfiltered[]`         | Les jeux de données que ce jeton lit **en entier**. Décidez, pour chacun, s'il s'agit d'une table de référence partagée ou d'une faille.                                                                                           |
| `unresolvedDatasetIds` | Les jeux de données dont le schéma n'a pas pu être lu. Ce n'est pas la même chose que « non filtré » : « lit tout » et « nous ne savons pas ce que ceci lit » sont deux réponses différentes, et aucune n'est fondue dans l'autre. |

Une vérification de production raisonnable, dans votre route de création, consiste à refuser de renvoyer le jeton quand `scope.fullyScoped` vaut `false` alors que vous l'attendiez à `true` — vous savez lesquels de vos tableaux de bord sont censés être par client, et la plateforme, non.

### Présenter le jeton[​](#présenter-le-jeton "Lien direct vers Présenter le jeton")

Le cadre ne peut pas définir d'en-tête, donc le jeton voyage sur l'URL :

```
<iframe

  src="https://cloud.vibedasher.com/v/<slug>?embed_token=<token>"

  sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"

  allowfullscreen

></iframe>
```

Les réponses d'intégration sont envoyées avec `Referrer-Policy: no-referrer` pour que le jeton ne fuite pas dans un en-tête `Referer` sur des sous-requêtes. Cela reste un identifiant porteur dans une URL, ce qui est la vraie raison pour laquelle le TTL par défaut est de dix minutes : générez-en un par chargement de page, gardez-le court, et rafraîchissez-le plutôt que d'augmenter le TTL.

### Configurer votre liste blanche de domaines[​](#configurer-votre-liste-blanche-de-domaines "Lien direct vers Configurer votre liste blanche de domaines")

Une clé API peut porter une liste de domaines depuis lesquels ses jetons d'intégration seront honorés. **Une liste vide signifie tout autoriser.** C'est une décision délibérée pour que la fonctionnalité marche dès que vous l'essayez, et cela signifie qu'une clé non configurée ne protège rien au niveau de l'origine. Définissez `allowed_domains` sur la clé avant de passer en production.

## La boucle de rafraîchissement de jeton[​](#la-boucle-de-rafraîchissement-de-jeton "Lien direct vers La boucle de rafraîchissement de jeton")

Un jeton de dix minutes sur un tableau de bord que quelqu'un laisse ouvert tout l'après-midi a besoin d'être rafraîchi, et le moteur de rendu vous le signale :

1. Le cadre envoie **`EMBED_TOKEN_EXPIRED`** (ou `EMBED_TOKEN_INVALID`) à votre page.
2. Votre page demande un nouveau jeton à votre backend.
3. Votre page envoie **`UPDATE_TOKEN`** dans le cadre : `{ type: 'UPDATE_TOKEN', token: '<new>' }`.

```
window.addEventListener('message', async (e) => {

  if (e.origin !== 'https://cloud.vibedasher.com') return;

  if (e.data?.type === 'EMBED_TOKEN_EXPIRED' || e.data?.type === 'EMBED_TOKEN_INVALID') {

    const token = await fetch('/api/vibedasher-embed-token').then((r) => r.text());

    frame.contentWindow.postMessage({ type: 'UPDATE_TOKEN', token }, 'https://cloud.vibedasher.com');

  }

});
```

Vérifiez toujours `e.origin`. `UPDATE_TOKEN` est le **seul** message qu'un hôte tiers peut envoyer vers l'intérieur — tout le reste est ignoré.

## Ce que votre page reçoit réellement[​](#ce-que-votre-page-reçoit-réellement "Lien direct vers Ce que votre page reçoit réellement")

Les messages qu'un hôte tiers reçoit sont une liste blanche fixe de treize. Voici la liste complète :

| Message                                                                  | Quand                                                                    |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
| `READY`                                                                  | Le moteur de rendu a démarré.                                            |
| `LOADED`                                                                 | Le tableau de bord a fini de charger.                                    |
| `RESIZE`                                                                 | La hauteur du contenu a changé — utilisez-le pour dimensionner l'iframe. |
| `BUNDLE_STARTED` / `BUNDLE_COMPLETED` / `BUNDLE_FAILED` / `BUNDLE_RETRY` | Cycle de vie du build du code du tableau de bord.                        |
| `RUNTIME_ERROR`                                                          | Le code du tableau de bord a levé une erreur.                            |
| `EMPTY_DATA`                                                             | Une requête n'a renvoyé aucune ligne.                                    |
| `RENDER_HEALTH`                                                          | Santé de rendu par tuile.                                                |
| `EMBED_TOKEN_EXPIRED` / `EMBED_TOKEN_INVALID` / `EMBED_UNAVAILABLE`      | Cycle de vie du jeton — voir ci-dessus.                                  |

Tout ce à quoi vous pourriez voir une référence par ailleurs est interne. En particulier, un ensemble de messages n'est explicitement **jamais** transmis à un hôte tiers — `VIZ_DATA`, `FILES`, `UPDATE_TOKEN` sortant, `SQL_ERROR`, `CONSOLE_ERROR`, `REQUEST_NEW_AUTH_TOKEN`, `ELEMENT_SELECTED`, `FILTER_STATE_CHANGED` — car ils portent soit la source de votre tableau de bord, soit son état de requête brut.

## La frontière du cadre[​](#the-boundary-of-the-embed "Lien direct vers La frontière du cadre")

Deux propriétés décident de ce sur quoi une intégration peut se construire, et les deux méritent d'être lues avant de concevoir autour du cadre plutôt qu'après.

**Le cadre possède son propre état de filtrage.** Les messages que reçoit un hôte tiers sont ceux listés plus haut ; l'état de filtrage du tableau de bord n'en fait pas partie, dans un sens comme dans l'autre. Le cadre est une vue autonome : pilotez-le par l'URL et le jeton que vous émettez, et laissez-le posséder ce qui se passe à l'intérieur. Si votre coquille doit partager une sélection avec le tableau de bord, mettez cette sélection dans le prédicat de filtrage du jeton, que la plateforme ajoute à chaque requête qu'elle autorise — côté serveur, et avec la même réponse pour chaque tuile.

**Choisissez l'audience en gardant le cadrage en tête.** Un tableau de bord publié en `public` est une URL publique, avec tout ce qui en découle ; la voie à jeton signé est celle à utiliser quand il importe de savoir qui regarde et depuis où, et la liste de domaines autorisés d'une clé d'API est ce qui lie ses réponses à votre origine.

**La voie de requête à jeton signé tourne en `eu-central-1`.** C'est là que vit le moteur de requête des intégrations protégées. Si les données de votre compte sont dans une autre région, parlez-nous avant de construire sur cette voie.

## Prochaines étapes[​](#prochaines-étapes "Lien direct vers Prochaines étapes")

* **[Qui peut voir quoi](/fr/guides/who-can-see-what.md)** — où s'insère le filtrage au niveau des lignes dans le modèle de permissions plus large, et pourquoi il n'existe qu'ici.
* **[Publier et partager](/fr/guides/publish-and-share.md)** — amener un tableau de bord à `published` et choisir son `view_mode`.
* **[Référence API](/fr/reference/vibedasher-sdk-api.md)** — l'opération `embed-tokens` en entier.
