@hgdc/ui 0.72.2 → 0.74.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/account-menu.js +3 -0
- package/dist/csp.d.ts +38 -0
- package/dist/csp.js +83 -0
- package/dist/module-bar.d.ts +6 -0
- package/dist/module-bar.js +19 -2
- package/dist/product-switch.d.ts +36 -0
- package/dist/product-switch.js +109 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -66,7 +66,7 @@ Le menu « Produits » et la palette (`badge` d'une commande) montrent la pastil
|
|
|
66
66
|
|
|
67
67
|
### D'un produit à l'autre sous `app.hgdc.ch` (ADR 0020)
|
|
68
68
|
|
|
69
|
-
Entre deux produits servis sous la même origine, la coque fait comme une seule app : transition de vue entre documents, lien relatif marqué `data-hgdc-prerender` et prérendu au survol (`PRODUCT_SPECULATION_RULES` de `@hgdc/ui/product-switch`). Le H couvre le contenu 100 ms après le clic (`PRODUCT_LEAVE_DELAY_MS`) : jamais pour une page prérendue. Chrome et Edge : tout ; Safari 18.2+ : la transition, sans prérendu ; Firefox : navigation ordinaire.
|
|
69
|
+
Entre deux produits servis sous la même origine, la coque fait comme une seule app : transition de vue entre documents, lien relatif marqué `data-hgdc-prerender` et prérendu au survol (`PRODUCT_SPECULATION_RULES` de `@hgdc/ui/product-switch`). Le H couvre le contenu 100 ms après le clic (`PRODUCT_LEAVE_DELAY_MS`) : jamais pour une page prérendue. Chrome et Edge : tout ; Safari 18.2+ : la transition, sans prérendu ; Firefox : navigation ordinaire. La barre ouvre aussi en arrière-plan la session des autres modules affichés (ADR 0026, `startSessionOpening`) : un cadre caché à la fois, au plus une fois par jour et par module, jamais après une déconnexion ni sans `localStorage`. Le premier clic vers un module trouve sa session.
|
|
70
70
|
|
|
71
71
|
Pendant le passage (0.39) :
|
|
72
72
|
|
package/dist/account-menu.js
CHANGED
|
@@ -8,6 +8,7 @@ import SecurityLockIcon from "@hugeicons/core-free-icons/SecurityLockIcon";
|
|
|
8
8
|
import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon";
|
|
9
9
|
import UserIcon from "@hugeicons/core-free-icons/UserIcon";
|
|
10
10
|
import { HugeiconsIcon } from "@hugeicons/react";
|
|
11
|
+
import { pauseSessionOpening } from "./product-switch.js";
|
|
11
12
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
|
12
13
|
import { Avatar, AvatarFallback, AvatarImage } from "./avatar.js";
|
|
13
14
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "./dropdown-menu.js";
|
|
@@ -47,6 +48,8 @@ export function AccountMenu({ user, onProfile, onSecurity, onSignOut, products:
|
|
|
47
48
|
return (_jsxs(DropdownMenu, { children: [_jsxs(DropdownMenuTrigger, { render: variant === "row" ? (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "flex w-full min-w-0 items-center gap-2.5 rounded-xl p-1.5 text-left outline-none transition-colors duration-(--duration-fast) ease-out hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/50 aria-expanded:bg-muted lg:hover:bg-shell-hover lg:aria-expanded:bg-shell-hover lg:rail:justify-center" })) : (_jsx("button", { type: "button", "aria-label": `Compte de ${user.name}`, className: "touch-target grid size-9 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50" })), children: [_jsx(AccountAvatar, { user: user }), variant === "row" ? (_jsxs("span", { className: "min-w-0 flex-1 lg:rail:hidden", children: [_jsx("span", { className: "block truncate text-sm font-medium", children: user.name }), _jsx("span", { className: "block truncate text-xs text-muted-foreground lg:text-shell-muted", children: user.email })] })) : null] }), _jsxs(DropdownMenuContent, { align: variant === "row" ? "start" : "end", side: variant === "row" ? "top" : "bottom", className: "w-72", children: [_jsx(DropdownMenuGroup, { children: _jsxs(DropdownMenuLabel, { className: "flex items-center gap-2.5 py-2", children: [_jsx(AccountAvatar, { user: user }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-sm font-medium text-foreground", children: user.name }), _jsx("span", { className: "block truncate", children: user.email })] })] }) }), _jsx(DropdownMenuSeparator, {}), onProfile ? (_jsxs(DropdownMenuItem, { onClick: onProfile, children: [_jsx(HugeiconsIcon, { icon: UserIcon, "aria-hidden": true }), "Profil"] })) : null, onSecurity ? (_jsxs(DropdownMenuItem, { onClick: onSecurity, children: [_jsx(HugeiconsIcon, { icon: SecurityLockIcon, "aria-hidden": true }), "S\u00E9curit\u00E9"] })) : null, _jsx(ThemeSwitch, {}), children ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), children] })) : null, _jsx(AccountMenuLinks, { links: menuLinks }), products.length ? (_jsxs(_Fragment, { children: [_jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Produits" }), products.map((product) => product.current ? (_jsxs(DropdownMenuItem, { disabled: true, className: "data-disabled:opacity-100", children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), _jsx(HugeiconsIcon, { icon: Tick02Icon, "aria-label": "Produit ouvert" })] }, product.name)) : (_jsxs(MenuPrimitive.LinkItem, { href: product.href, onClick: leaveOnClick, className: MENU_LINK_ITEM, children: [_jsx(ProductMenuIcon, { product: product }), _jsx("span", { className: "flex-1", children: product.name }), product.hint ? (_jsx("span", { className: "text-xs font-normal text-muted-foreground", children: product.hint })) : null] }, product.name)))] })] })) : null, _jsx(DropdownMenuSeparator, {}), _jsxs(DropdownMenuItem, { onClick: () => {
|
|
48
49
|
// La marque de l'organisation retenue pour la coque ne survit pas à la session.
|
|
49
50
|
forgetShellOrganization();
|
|
51
|
+
// Aucun module ne se rouvre seul après une déconnexion (ADR 0026).
|
|
52
|
+
pauseSessionOpening();
|
|
50
53
|
onSignOut();
|
|
51
54
|
}, children: [_jsx(HugeiconsIcon, { icon: Logout01Icon, "aria-hidden": true }), "Se d\u00E9connecter"] })] })] }));
|
|
52
55
|
}
|
package/dist/csp.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Socle : jamais encadrable (clickjacking), ni `<base>`, ni plugin, formulaires vers soi. */
|
|
2
|
+
export declare const BASE_POLICY = "frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self'";
|
|
3
|
+
/**
|
|
4
|
+
* Empreintes des scripts en ligne que ce paquet rend sans nonce. Aujourd'hui : les règles de
|
|
5
|
+
* préchargement de la barre (`PRODUCT_SPECULATION_RULES`), que Chrome bloque malgré
|
|
6
|
+
* `'inline-speculation-rules'`. `csp.test.ts` les recalcule : changer un script sans son empreinte échoue.
|
|
7
|
+
*/
|
|
8
|
+
export declare const KIT_INLINE_SCRIPT_HASHES: readonly string[];
|
|
9
|
+
/** Scripts en ligne couverts par `KIT_INLINE_SCRIPT_HASHES`, pour le test qui recalcule les empreintes. */
|
|
10
|
+
export declare const KIT_INLINE_SCRIPTS: readonly string[];
|
|
11
|
+
/**
|
|
12
|
+
* Origines que les composants de ce paquet joignent depuis le navigateur : bandeau d'état
|
|
13
|
+
* (`ServiceStatus`), formulaire « Demander un accès » (`AccessRequest`), réglage de la barre.
|
|
14
|
+
*/
|
|
15
|
+
export declare const FAMILY_CONNECT_SOURCES: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* Origines joignables par `fetch` ou WebSocket, tirées d'adresses complètes (backend, collecteur
|
|
18
|
+
* d'erreurs) : l'origine seule, plus sa version WebSocket. Une adresse vide ou illisible est ignorée :
|
|
19
|
+
* elle ne doit jamais ouvrir la politique.
|
|
20
|
+
*/
|
|
21
|
+
export declare function connectSources(urls: ReadonlyArray<string | undefined>): string[];
|
|
22
|
+
/** Nonce d'une requête : 16 octets aléatoires en base64. */
|
|
23
|
+
export declare function newNonce(): string;
|
|
24
|
+
export type DocumentPolicyOptions = {
|
|
25
|
+
/** Nonce de la requête (`newNonce`), posé aussi sur les scripts en ligne du rendu serveur. */
|
|
26
|
+
nonce: string;
|
|
27
|
+
/** Origines que le navigateur joint, en plus de la sienne et de `FAMILY_CONNECT_SOURCES`. */
|
|
28
|
+
connect: ReadonlyArray<string>;
|
|
29
|
+
/** Empreintes de scripts en ligne propres à l'application, en plus de celles du paquet. */
|
|
30
|
+
scriptHashes?: ReadonlyArray<string>;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Politique des documents : le socle, `script-src` (fichiers de l'application, scripts en ligne portant
|
|
34
|
+
* le nonce, ceux du paquet par leur empreinte) et `connect-src`. Ni `'unsafe-inline'` ni `'unsafe-eval'`.
|
|
35
|
+
* Images, styles et polices ne sont pas restreints. Une valeur mal formée lève : elle ne doit jamais
|
|
36
|
+
* pouvoir ajouter une directive.
|
|
37
|
+
*/
|
|
38
|
+
export declare function documentPolicy({ nonce, connect, scriptHashes }: DocumentPolicyOptions): string;
|
package/dist/csp.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Politique de sécurité du contenu (CSP) des documents d'une console, commune à tous les modules.
|
|
3
|
+
*
|
|
4
|
+
* Les modules partagent une même origine : un script injecté dans l'un agit dans tous les autres. La
|
|
5
|
+
* politique doit donc être la même partout, d'où ce module. Aucune dépendance : le middleware qui
|
|
6
|
+
* tire le nonce et pose l'en-tête reste dans chaque application (il dépend de son serveur).
|
|
7
|
+
*/
|
|
8
|
+
import { PRODUCT_SPECULATION_RULES } from "./product-switch.js";
|
|
9
|
+
/** Socle : jamais encadrable (clickjacking), ni `<base>`, ni plugin, formulaires vers soi. */
|
|
10
|
+
export const BASE_POLICY = "frame-ancestors 'none'; base-uri 'self'; object-src 'none'; form-action 'self'";
|
|
11
|
+
/**
|
|
12
|
+
* Empreintes des scripts en ligne que ce paquet rend sans nonce. Aujourd'hui : les règles de
|
|
13
|
+
* préchargement de la barre (`PRODUCT_SPECULATION_RULES`), que Chrome bloque malgré
|
|
14
|
+
* `'inline-speculation-rules'`. `csp.test.ts` les recalcule : changer un script sans son empreinte échoue.
|
|
15
|
+
*/
|
|
16
|
+
export const KIT_INLINE_SCRIPT_HASHES = ["sha256-a67/vPhviRex9BGXSAteyrRDrGJEKbTE/4tBKxtGgjQ="];
|
|
17
|
+
/** Scripts en ligne couverts par `KIT_INLINE_SCRIPT_HASHES`, pour le test qui recalcule les empreintes. */
|
|
18
|
+
export const KIT_INLINE_SCRIPTS = [PRODUCT_SPECULATION_RULES];
|
|
19
|
+
/**
|
|
20
|
+
* Origines que les composants de ce paquet joignent depuis le navigateur : bandeau d'état
|
|
21
|
+
* (`ServiceStatus`), formulaire « Demander un accès » (`AccessRequest`), réglage de la barre.
|
|
22
|
+
*/
|
|
23
|
+
export const FAMILY_CONNECT_SOURCES = [
|
|
24
|
+
"https://status.hgdc.ch",
|
|
25
|
+
"https://api.mail.hgdc.ch",
|
|
26
|
+
"https://account.hgdc.ch",
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Origines joignables par `fetch` ou WebSocket, tirées d'adresses complètes (backend, collecteur
|
|
30
|
+
* d'erreurs) : l'origine seule, plus sa version WebSocket. Une adresse vide ou illisible est ignorée :
|
|
31
|
+
* elle ne doit jamais ouvrir la politique.
|
|
32
|
+
*/
|
|
33
|
+
export function connectSources(urls) {
|
|
34
|
+
const sources = new Set();
|
|
35
|
+
for (const raw of urls) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
continue;
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
46
|
+
continue;
|
|
47
|
+
sources.add(url.origin);
|
|
48
|
+
sources.add(`${url.protocol === "https:" ? "wss:" : "ws:"}//${url.host}`);
|
|
49
|
+
}
|
|
50
|
+
return [...sources];
|
|
51
|
+
}
|
|
52
|
+
/** Nonce d'une requête : 16 octets aléatoires en base64. */
|
|
53
|
+
export function newNonce() {
|
|
54
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
55
|
+
return btoa(String.fromCharCode(...bytes));
|
|
56
|
+
}
|
|
57
|
+
const NONCE = /^[A-Za-z0-9+/_-]{16,}={0,2}$/;
|
|
58
|
+
const HASH = /^sha(256|384|512)-[A-Za-z0-9+/]+={0,2}$/;
|
|
59
|
+
/** Une source de `connect-src` : une origine `https:`, `wss:`, `http:` ou `ws:`, sans chemin ni séparateur. */
|
|
60
|
+
const SOURCE = /^(https|wss|http|ws):\/\/[A-Za-z0-9.*:[\]-]+$/;
|
|
61
|
+
/**
|
|
62
|
+
* Politique des documents : le socle, `script-src` (fichiers de l'application, scripts en ligne portant
|
|
63
|
+
* le nonce, ceux du paquet par leur empreinte) et `connect-src`. Ni `'unsafe-inline'` ni `'unsafe-eval'`.
|
|
64
|
+
* Images, styles et polices ne sont pas restreints. Une valeur mal formée lève : elle ne doit jamais
|
|
65
|
+
* pouvoir ajouter une directive.
|
|
66
|
+
*/
|
|
67
|
+
export function documentPolicy({ nonce, connect, scriptHashes = [] }) {
|
|
68
|
+
if (!NONCE.test(nonce))
|
|
69
|
+
throw new Error("nonce CSP invalide");
|
|
70
|
+
const hashes = [...KIT_INLINE_SCRIPT_HASHES, ...scriptHashes];
|
|
71
|
+
for (const hash of hashes)
|
|
72
|
+
if (!HASH.test(hash))
|
|
73
|
+
throw new Error(`empreinte CSP invalide : ${hash}`);
|
|
74
|
+
const sources = [...new Set([...connect, ...FAMILY_CONNECT_SOURCES])];
|
|
75
|
+
for (const source of sources)
|
|
76
|
+
if (!SOURCE.test(source))
|
|
77
|
+
throw new Error(`source CSP invalide : ${source}`);
|
|
78
|
+
return [
|
|
79
|
+
BASE_POLICY,
|
|
80
|
+
`script-src ${["'self'", `'nonce-${nonce}'`, ...hashes.map((hash) => `'${hash}'`), "'inline-speculation-rules'"].join(" ")}`,
|
|
81
|
+
`connect-src ${["'self'", ...sources].join(" ")}`,
|
|
82
|
+
].join("; ");
|
|
83
|
+
}
|
package/dist/module-bar.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import type { FamilyProduct } from "./family-svg.ts";
|
|
3
3
|
import { type ModuleWord } from "./modules.ts";
|
|
4
|
+
import { type ShellProductEntry } from "./products.ts";
|
|
4
5
|
/** Ce que montre le milieu de la barre : les onglets du module ouvert, la liste des modules, ou son réglage. */
|
|
5
6
|
export type ShellNavLevel = "tabs" | "modules" | "edit";
|
|
7
|
+
/**
|
|
8
|
+
* Bases des modules affichés (`shown`), autres que le module ouvert et servis sous la même origine :
|
|
9
|
+
* ceux dont la session s'ouvre en arrière-plan (ADR 0026). Manager n'en fait pas partie.
|
|
10
|
+
*/
|
|
11
|
+
export declare function sessionBases(entries: ReadonlyArray<ShellProductEntry>, shown: ReadonlyArray<ModuleWord>, current: FamilyProduct): Array<string>;
|
|
6
12
|
/**
|
|
7
13
|
* Le milieu de la barre de Basalte : Manager, un trait, puis l'un des trois niveaux. Dans un module,
|
|
8
14
|
* ses onglets (`nav`) sous « Retour aux modules » ; un clic y affiche la liste sans rien recharger ;
|
package/dist/module-bar.js
CHANGED
|
@@ -14,7 +14,7 @@ import { saveModuleBar, useModuleBar } from "./module-bar-store.js";
|
|
|
14
14
|
import { pushModuleBar } from "./product-bar.js";
|
|
15
15
|
import { hiddenModules, moduleByWord, moveModule, shownModules } from "./modules.js";
|
|
16
16
|
import { ProductBadge } from "./product-badge.js";
|
|
17
|
-
import { PRODUCT_PRERENDER_ATTRIBUTE } from "./product-switch.js";
|
|
17
|
+
import { PRODUCT_PRERENDER_ATTRIBUTE, productEntryBase, useSessionOpening } from "./product-switch.js";
|
|
18
18
|
import { shellProductEntries } from "./products.js";
|
|
19
19
|
import { familyProductName } from "./product-name.js";
|
|
20
20
|
import { lastOrganization } from "./shell-memory.js";
|
|
@@ -46,6 +46,19 @@ function ShellEntryLink({ entry, icon, onOpenCurrent, }) {
|
|
|
46
46
|
};
|
|
47
47
|
return (_jsx("li", { children: _jsxs(Tooltip, { disabled: !rail, children: [_jsxs(TooltipTrigger, { render: _jsx("a", { href: entry.href, onClick: onClick, ...(entry.sameOrigin ? { [PRODUCT_PRERENDER_ATTRIBUTE]: "" } : {}), "aria-current": entry.current ? "page" : undefined, className: SIDEBAR_ITEM }), children: [icon ? (_jsx(HugeiconsIcon, { icon: icon, strokeWidth: 1.8, "aria-hidden": true, className: "size-5 shrink-0 lg:size-[1.125rem]" })) : (_jsx(ProductBadge, { product: entry.badge, className: "size-5 shrink-0 lg:size-[1.125rem]" })), _jsx("span", { className: "min-w-0 flex-1 truncate lg:rail:sr-only", children: entry.name }), entry.hint ? _jsx("span", { className: "text-xs font-normal lg:rail:hidden", children: entry.hint }) : null] }), _jsx(TooltipContent, { side: "right", children: entry.name })] }) }));
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Bases des modules affichés (`shown`), autres que le module ouvert et servis sous la même origine :
|
|
51
|
+
* ceux dont la session s'ouvre en arrière-plan (ADR 0026). Manager n'en fait pas partie.
|
|
52
|
+
*/
|
|
53
|
+
export function sessionBases(entries, shown, current) {
|
|
54
|
+
return shown.flatMap((word) => {
|
|
55
|
+
const entry = entries.find((item) => item.badge === word);
|
|
56
|
+
if (!entry || entry.badge === current || !entry.sameOrigin)
|
|
57
|
+
return [];
|
|
58
|
+
const base = productEntryBase(entry.href);
|
|
59
|
+
return base ? [base] : [];
|
|
60
|
+
});
|
|
61
|
+
}
|
|
49
62
|
function ModuleList({ entries, order, onOpenCurrent, onCustomize, }) {
|
|
50
63
|
const shown = order.flatMap((word) => {
|
|
51
64
|
const entry = entries.find((item) => item.badge === word);
|
|
@@ -91,7 +104,11 @@ export function ShellNavigation({ product, nav, accountHref, level, onLevelChang
|
|
|
91
104
|
const organization = useLastOrganization();
|
|
92
105
|
const order = useModuleBar();
|
|
93
106
|
const { products, account } = shellProductEntries({ current: product, accountHref, pageOrigin, organization });
|
|
94
|
-
|
|
107
|
+
const shown = shownModules(order, product).filter((word) => !projectModules || word === product || projectModules.includes(word));
|
|
108
|
+
// Sessions des autres modules affichés, ouvertes en arrière-plan : le premier clic n'attend pas
|
|
109
|
+
// la connexion chez ID (ADR 0026).
|
|
110
|
+
useSessionOpening(sessionBases(products, shown, product));
|
|
111
|
+
return (_jsxs("div", { "data-slot": "shell-navigation", className: "flex min-h-0 flex-1 flex-col", children: [_jsxs("div", { "data-slot": "shell-manager", className: "shrink-0 px-3 pt-1", children: [_jsx("ul", { children: _jsx(ShellEntryLink, { entry: account, icon: UserGroupIcon }) }), _jsx("div", { "aria-hidden": true, className: "mx-2 mt-2 h-px bg-foreground/8" })] }), level === "modules" ? (_jsx(ModuleList, { entries: products, order: shown, onOpenCurrent: () => onLevelChange("tabs"), onCustomize: () => onLevelChange("edit") })) : level === "edit" ? (_jsx(ModuleBarEditor, { order: order, onSave: (next) => {
|
|
95
112
|
saveModuleBar(next);
|
|
96
113
|
void pushModuleBar(next);
|
|
97
114
|
onLevelChange("modules");
|
package/dist/product-switch.d.ts
CHANGED
|
@@ -13,6 +13,22 @@ export declare const BACKGROUND_REFRESH_TIMEOUT_MS = 20000;
|
|
|
13
13
|
export declare const BACKGROUND_REFRESH_INTERVAL_MS: number;
|
|
14
14
|
/** Heure de la dernière relecture d'un produit, en `localStorage` : `hgdc-org-sync:<base>` (`/` à la racine). */
|
|
15
15
|
export declare const ORG_SYNC_LAST_KEY_PREFIX = "hgdc-org-sync:";
|
|
16
|
+
/** Cadre caché qui ouvre la session d'un autre produit, seulement si elle manque (ADR 0026). */
|
|
17
|
+
export declare const SESSION_OPEN_PATH = "/auth/hgdc?frame=open";
|
|
18
|
+
/** Une ouverture au plus par produit pendant ce délai : une session vit 7 jours (`@hgdc/auth`). */
|
|
19
|
+
export declare const SESSION_OPEN_INTERVAL_MS: number;
|
|
20
|
+
/** Attente après l'affichage de la page avant la première ouverture : la page passe d'abord. */
|
|
21
|
+
export declare const SESSION_OPEN_DELAY_MS = 3000;
|
|
22
|
+
/** Heure de la dernière ouverture d'un produit, en `localStorage` : `hgdc-session-open:<base>`. */
|
|
23
|
+
export declare const SESSION_OPEN_KEY_PREFIX = "hgdc-session-open:";
|
|
24
|
+
/**
|
|
25
|
+
* Posée à la déconnexion d'un module (`AccountMenu`), levée à la prochaine connexion par clic
|
|
26
|
+
* (`HgdcSignInButton` de `@hgdc/auth`) : tant qu'elle est là, rien ne s'ouvre en arrière-plan. Une
|
|
27
|
+
* déconnexion (changer de compte, poste partagé) n'est jamais défaite sans la personne.
|
|
28
|
+
*/
|
|
29
|
+
export declare const SESSION_OPEN_PAUSED_KEY = "hgdc-session-open:paused";
|
|
30
|
+
/** À la déconnexion d'un module : plus d'ouverture en arrière-plan jusqu'à la prochaine connexion. */
|
|
31
|
+
export declare function pauseSessionOpening(win?: Window | null): void;
|
|
16
32
|
/** Marque d'un lien de la barre vers un autre produit servi sous la même origine : prérendu au survol. */
|
|
17
33
|
export declare const PRODUCT_PRERENDER_ATTRIBUTE = "data-hgdc-prerender";
|
|
18
34
|
/**
|
|
@@ -48,6 +64,20 @@ export declare function takeOrgSyncCookie(doc?: Document): boolean;
|
|
|
48
64
|
* clic ne relit rien.
|
|
49
65
|
*/
|
|
50
66
|
export declare function startBackgroundRefresh(doc?: Document): HTMLIFrameElement | null;
|
|
67
|
+
/**
|
|
68
|
+
* La base d'un produit (`/mail`) depuis le lien relatif de sa barre (`/mail/auth/hgdc?…`), ou
|
|
69
|
+
* `null` pour un lien absolu (autre origine) ou une base illisible.
|
|
70
|
+
*/
|
|
71
|
+
export declare function productEntryBase(href: string): string | null;
|
|
72
|
+
/**
|
|
73
|
+
* Ouvre en arrière-plan la session des autres modules de la barre (ADR 0026) : un cadre caché à la
|
|
74
|
+
* fois vers `<base>/auth/hgdc?frame=open`, au plus une fois par jour et par module. Session déjà
|
|
75
|
+
* ouverte : réponse vide sans appel à ID. Sinon, la connexion HGDC se fait dans le cadre ; si ID
|
|
76
|
+
* demande quelque chose (session expirée, consentement), il refuse d'être affiché dans un cadre et
|
|
77
|
+
* rien ne se passe : le clic fera la connexion habituelle. Pas sur une page prérendue, ni quand
|
|
78
|
+
* l'économie de données est demandée. Rend une fonction qui arrête la file.
|
|
79
|
+
*/
|
|
80
|
+
export declare function startSessionOpening(bases: ReadonlyArray<string>, doc?: Document): () => void;
|
|
51
81
|
/**
|
|
52
82
|
* Relecture en arrière-plan si l'entrée l'a demandé. Montée à la racine du document
|
|
53
83
|
* (`FamilyLoaderRoot`), pour toutes les pages, y compris celles hors de la coque : la page « il te
|
|
@@ -62,3 +92,9 @@ export declare function useBackgroundOrganizationRefresh(): void;
|
|
|
62
92
|
* `script-src` doit y autoriser `'inline-speculation-rules'` (README).
|
|
63
93
|
*/
|
|
64
94
|
export declare function useProductSwitch(enabled?: boolean): void;
|
|
95
|
+
/**
|
|
96
|
+
* Dans la barre de Basalte (`ShellNavigation`) : ouvre la session des modules affichés servis sous
|
|
97
|
+
* la même origine, `SESSION_OPEN_DELAY_MS` après l'affichage (ADR 0026). `bases` : clé stable
|
|
98
|
+
* (les bases jointes), pour ne pas relancer la file à chaque rendu.
|
|
99
|
+
*/
|
|
100
|
+
export declare function useSessionOpening(bases: ReadonlyArray<string>): void;
|
package/dist/product-switch.js
CHANGED
|
@@ -24,6 +24,29 @@ export const BACKGROUND_REFRESH_TIMEOUT_MS = 20_000;
|
|
|
24
24
|
export const BACKGROUND_REFRESH_INTERVAL_MS = 10 * 60_000;
|
|
25
25
|
/** Heure de la dernière relecture d'un produit, en `localStorage` : `hgdc-org-sync:<base>` (`/` à la racine). */
|
|
26
26
|
export const ORG_SYNC_LAST_KEY_PREFIX = "hgdc-org-sync:";
|
|
27
|
+
/** Cadre caché qui ouvre la session d'un autre produit, seulement si elle manque (ADR 0026). */
|
|
28
|
+
export const SESSION_OPEN_PATH = "/auth/hgdc?frame=open";
|
|
29
|
+
/** Une ouverture au plus par produit pendant ce délai : une session vit 7 jours (`@hgdc/auth`). */
|
|
30
|
+
export const SESSION_OPEN_INTERVAL_MS = 24 * 60 * 60_000;
|
|
31
|
+
/** Attente après l'affichage de la page avant la première ouverture : la page passe d'abord. */
|
|
32
|
+
export const SESSION_OPEN_DELAY_MS = 3000;
|
|
33
|
+
/** Heure de la dernière ouverture d'un produit, en `localStorage` : `hgdc-session-open:<base>`. */
|
|
34
|
+
export const SESSION_OPEN_KEY_PREFIX = "hgdc-session-open:";
|
|
35
|
+
/**
|
|
36
|
+
* Posée à la déconnexion d'un module (`AccountMenu`), levée à la prochaine connexion par clic
|
|
37
|
+
* (`HgdcSignInButton` de `@hgdc/auth`) : tant qu'elle est là, rien ne s'ouvre en arrière-plan. Une
|
|
38
|
+
* déconnexion (changer de compte, poste partagé) n'est jamais défaite sans la personne.
|
|
39
|
+
*/
|
|
40
|
+
export const SESSION_OPEN_PAUSED_KEY = "hgdc-session-open:paused";
|
|
41
|
+
/** À la déconnexion d'un module : plus d'ouverture en arrière-plan jusqu'à la prochaine connexion. */
|
|
42
|
+
export function pauseSessionOpening(win = typeof window === "undefined" ? null : window) {
|
|
43
|
+
try {
|
|
44
|
+
win?.localStorage.setItem(SESSION_OPEN_PAUSED_KEY, String(Date.now()));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Stockage refusé : l'ouverture n'a pas lieu non plus sans lui (`startSessionOpening`).
|
|
48
|
+
}
|
|
49
|
+
}
|
|
27
50
|
/** Marque d'un lien de la barre vers un autre produit servi sous la même origine : prérendu au survol. */
|
|
28
51
|
export const PRODUCT_PRERENDER_ATTRIBUTE = "data-hgdc-prerender";
|
|
29
52
|
/**
|
|
@@ -95,19 +118,20 @@ export function takeOrgSyncCookie(doc = document) {
|
|
|
95
118
|
* Vrai si la relecture de ce produit est due, et note alors l'heure : au plus une toutes les
|
|
96
119
|
* `BACKGROUND_REFRESH_INTERVAL_MS`. Sans `localStorage` (navigation privée stricte), toujours due.
|
|
97
120
|
*/
|
|
98
|
-
function refreshDue(base, win, now = Date.now()) {
|
|
99
|
-
const key = `${
|
|
121
|
+
function refreshDue(base, win, now = Date.now(), prefix = ORG_SYNC_LAST_KEY_PREFIX, interval = BACKGROUND_REFRESH_INTERVAL_MS, withoutStorage = true) {
|
|
122
|
+
const key = `${prefix}${base || "/"}`;
|
|
100
123
|
try {
|
|
101
124
|
const storage = win?.localStorage;
|
|
102
125
|
if (!storage)
|
|
103
|
-
return
|
|
126
|
+
return withoutStorage;
|
|
104
127
|
const last = Number(storage.getItem(key));
|
|
105
|
-
if (Number.isFinite(last) && last > 0 && now - last >= 0 && now - last <
|
|
128
|
+
if (Number.isFinite(last) && last > 0 && now - last >= 0 && now - last < interval)
|
|
106
129
|
return false;
|
|
107
130
|
storage.setItem(key, String(now));
|
|
108
131
|
}
|
|
109
132
|
catch {
|
|
110
|
-
// Stockage refusé : la relecture part, comme avant.
|
|
133
|
+
// Stockage refusé : la relecture part, comme avant ; l'ouverture, elle, ne part pas.
|
|
134
|
+
return withoutStorage;
|
|
111
135
|
}
|
|
112
136
|
return true;
|
|
113
137
|
}
|
|
@@ -128,24 +152,82 @@ export function startBackgroundRefresh(doc = document) {
|
|
|
128
152
|
const base = takeOrgSync(doc);
|
|
129
153
|
if (base === null || !refreshDue(base, doc.defaultView))
|
|
130
154
|
return null;
|
|
155
|
+
return openHiddenFrame(doc, `${base}${BACKGROUND_REFRESH_PATH}`);
|
|
156
|
+
}
|
|
157
|
+
/** Vrai si une déconnexion a suspendu l'ouverture, ou si le stockage manque (rien ne la limiterait). */
|
|
158
|
+
function openingPaused(win) {
|
|
159
|
+
try {
|
|
160
|
+
return !win?.localStorage || win.localStorage.getItem(SESSION_OPEN_PAUSED_KEY) !== null;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Un cadre caché vers une adresse de l'entrée, retiré à son chargement ou après
|
|
168
|
+
* `BACKGROUND_REFRESH_TIMEOUT_MS`. Ni script, ni formulaire, ni navigation de la page (`sandbox`) ;
|
|
169
|
+
* même origine pour les cookies du produit. `onDone` est appelé une fois, quand il est retiré.
|
|
170
|
+
*/
|
|
171
|
+
function openHiddenFrame(doc, src, onDone) {
|
|
131
172
|
const frame = doc.createElement("iframe");
|
|
132
173
|
frame.hidden = true;
|
|
133
174
|
frame.tabIndex = -1;
|
|
134
175
|
frame.title = "";
|
|
135
176
|
frame.setAttribute("aria-hidden", "true");
|
|
136
|
-
// Même origine pour les cookies du produit ; ni script, ni formulaire, ni navigation de la page.
|
|
137
177
|
frame.setAttribute("sandbox", "allow-same-origin");
|
|
138
178
|
frame.referrerPolicy = "no-referrer";
|
|
139
179
|
const done = () => {
|
|
140
180
|
window.clearTimeout(timer);
|
|
141
181
|
frame.remove();
|
|
182
|
+
onDone?.();
|
|
142
183
|
};
|
|
143
184
|
const timer = window.setTimeout(done, BACKGROUND_REFRESH_TIMEOUT_MS);
|
|
144
185
|
frame.addEventListener("load", done, { once: true });
|
|
145
|
-
frame.src =
|
|
186
|
+
frame.src = src;
|
|
146
187
|
doc.body.append(frame);
|
|
147
188
|
return frame;
|
|
148
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* La base d'un produit (`/mail`) depuis le lien relatif de sa barre (`/mail/auth/hgdc?…`), ou
|
|
192
|
+
* `null` pour un lien absolu (autre origine) ou une base illisible.
|
|
193
|
+
*/
|
|
194
|
+
export function productEntryBase(href) {
|
|
195
|
+
if (!href.startsWith("/"))
|
|
196
|
+
return null;
|
|
197
|
+
const index = href.indexOf("/auth/hgdc");
|
|
198
|
+
if (index <= 0)
|
|
199
|
+
return null;
|
|
200
|
+
const base = href.slice(0, index);
|
|
201
|
+
return BASE_PATH.test(base) ? base : null;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Ouvre en arrière-plan la session des autres modules de la barre (ADR 0026) : un cadre caché à la
|
|
205
|
+
* fois vers `<base>/auth/hgdc?frame=open`, au plus une fois par jour et par module. Session déjà
|
|
206
|
+
* ouverte : réponse vide sans appel à ID. Sinon, la connexion HGDC se fait dans le cadre ; si ID
|
|
207
|
+
* demande quelque chose (session expirée, consentement), il refuse d'être affiché dans un cadre et
|
|
208
|
+
* rien ne se passe : le clic fera la connexion habituelle. Pas sur une page prérendue, ni quand
|
|
209
|
+
* l'économie de données est demandée. Rend une fonction qui arrête la file.
|
|
210
|
+
*/
|
|
211
|
+
export function startSessionOpening(bases, doc = document) {
|
|
212
|
+
const win = doc.defaultView;
|
|
213
|
+
const saveData = win?.navigator?.connection
|
|
214
|
+
?.saveData;
|
|
215
|
+
if (saveData || doc.prerendering || openingPaused(win))
|
|
216
|
+
return () => { };
|
|
217
|
+
// Sans stockage, pas de limite par jour : rien ne part (sinon une connexion par module à chaque page).
|
|
218
|
+
const queue = bases.filter((base) => refreshDue(base, win, Date.now(), SESSION_OPEN_KEY_PREFIX, SESSION_OPEN_INTERVAL_MS, false));
|
|
219
|
+
let stopped = false;
|
|
220
|
+
const next = () => {
|
|
221
|
+
const base = queue.shift();
|
|
222
|
+
if (stopped || base === undefined)
|
|
223
|
+
return;
|
|
224
|
+
openHiddenFrame(doc, `${base}${SESSION_OPEN_PATH}`, next);
|
|
225
|
+
};
|
|
226
|
+
next();
|
|
227
|
+
return () => {
|
|
228
|
+
stopped = true;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
149
231
|
/**
|
|
150
232
|
* Relecture en arrière-plan si l'entrée l'a demandé. Montée à la racine du document
|
|
151
233
|
* (`FamilyLoaderRoot`), pour toutes les pages, y compris celles hors de la coque : la page « il te
|
|
@@ -181,3 +263,23 @@ export function useProductSwitch(enabled = true) {
|
|
|
181
263
|
return () => script.remove();
|
|
182
264
|
}, [enabled]);
|
|
183
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Dans la barre de Basalte (`ShellNavigation`) : ouvre la session des modules affichés servis sous
|
|
268
|
+
* la même origine, `SESSION_OPEN_DELAY_MS` après l'affichage (ADR 0026). `bases` : clé stable
|
|
269
|
+
* (les bases jointes), pour ne pas relancer la file à chaque rendu.
|
|
270
|
+
*/
|
|
271
|
+
export function useSessionOpening(bases) {
|
|
272
|
+
const key = bases.join(" ");
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!key)
|
|
275
|
+
return;
|
|
276
|
+
let stop = () => { };
|
|
277
|
+
const timer = window.setTimeout(() => {
|
|
278
|
+
stop = startSessionOpening(key.split(" "));
|
|
279
|
+
}, SESSION_OPEN_DELAY_MS);
|
|
280
|
+
return () => {
|
|
281
|
+
window.clearTimeout(timer);
|
|
282
|
+
stop();
|
|
283
|
+
};
|
|
284
|
+
}, [key]);
|
|
285
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0",
|
|
4
4
|
"description": "Interface de la H Collection : jetons de la DA, thèmes, polices et composants.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",
|