@inwink-mcp/backoffice-extension-helpers 1.0.1 → 1.0.3
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/dist/cli.js +27 -3
- package/package.json +1 -1
- package/templates/test-app/src/pages/HomePage.tsx.tpl +14 -16
- package/templates/vite-extension/AGENTS.md.tpl +3 -61
- package/templates/vite-extension/extension.manifest.json.tpl +3 -3
- package/templates/vite-extension/index.html.tpl +0 -2
- package/templates/vite-extension/scripts/zip-build.mjs +23 -0
- package/templates/vite-extension/src/App.tsx.tpl +8 -14
- package/templates/vite-extension/src/auth/AuthProvider.tsx +58 -18
- package/templates/vite-extension/src/auth/ProtectedRoute.tsx +5 -1
- package/templates/vite-extension/src/main.tsx +1 -4
- package/templates/vite-extension/src/pages/HomePage.tsx.tpl +18 -22
- package/templates/vite-extension/src/styles.css +9 -68
package/dist/cli.js
CHANGED
|
@@ -101,7 +101,7 @@ function escapeForJson(value) {
|
|
|
101
101
|
}
|
|
102
102
|
function substitute(content, ph, json) {
|
|
103
103
|
const v = (value) => json ? escapeForJson(value) : value;
|
|
104
|
-
return content.replaceAll("__NAME__", v(ph.name)).replaceAll("__DISPLAY_NAME__", v(ph.displayName)).replaceAll("__DESCRIPTION__", v(ph.description)).replaceAll("__AUTHOR__", v(ph.author)).replaceAll("__EVENT_API_URL__", v(ph.eventApiUrl)).replaceAll("__COMMUNITY_API_URL__", v(ph.communityApiUrl)).replaceAll("__CUSTOMER_API_URL__", v(ph.customerApiUrl));
|
|
104
|
+
return content.replaceAll("__NAME__", v(ph.name)).replaceAll("__DISPLAY_NAME__", v(ph.displayName)).replaceAll("__VERSION__", v(ph.version)).replaceAll("__DESCRIPTION__", v(ph.description)).replaceAll("__AUTHOR__", v(ph.author)).replaceAll("__EVENT_API_URL__", v(ph.eventApiUrl)).replaceAll("__COMMUNITY_API_URL__", v(ph.communityApiUrl)).replaceAll("__CUSTOMER_API_URL__", v(ph.customerApiUrl));
|
|
105
105
|
}
|
|
106
106
|
function renderTemplate(templateId, destDir, ph) {
|
|
107
107
|
const srcRoot = path2.join(templatesRoot, templateId);
|
|
@@ -160,7 +160,8 @@ function apply(template, opts) {
|
|
|
160
160
|
// un overlay ne les ré-injecte pas.
|
|
161
161
|
eventApiUrl: "",
|
|
162
162
|
communityApiUrl: "",
|
|
163
|
-
customerApiUrl: ""
|
|
163
|
+
customerApiUrl: "",
|
|
164
|
+
version: str(manifest.version)
|
|
164
165
|
};
|
|
165
166
|
let written;
|
|
166
167
|
try {
|
|
@@ -261,6 +262,8 @@ function bootstrap(opts) {
|
|
|
261
262
|
} else {
|
|
262
263
|
fs3.mkdirSync(destDir, { recursive: true });
|
|
263
264
|
}
|
|
265
|
+
const now = /* @__PURE__ */ new Date();
|
|
266
|
+
const defaultVersion = `${now.getFullYear()}.${String(now.getMonth() + 1)}.${String(now.getDate())}.1`;
|
|
264
267
|
const placeholders = {
|
|
265
268
|
name: opts.name,
|
|
266
269
|
displayName: opts.displayName,
|
|
@@ -268,7 +271,9 @@ function bootstrap(opts) {
|
|
|
268
271
|
author: opts.author,
|
|
269
272
|
eventApiUrl: opts.eventApiUrl,
|
|
270
273
|
communityApiUrl: opts.communityApiUrl,
|
|
271
|
-
customerApiUrl: opts.customerApiUrl
|
|
274
|
+
customerApiUrl: opts.customerApiUrl,
|
|
275
|
+
version: defaultVersion
|
|
276
|
+
// version par défaut au format YYYY.MM.DD
|
|
272
277
|
};
|
|
273
278
|
let written;
|
|
274
279
|
try {
|
|
@@ -424,6 +429,23 @@ function sourceUsesBrowserRouter(dir) {
|
|
|
424
429
|
import fs5 from "fs";
|
|
425
430
|
import path6 from "path";
|
|
426
431
|
import AdmZip2 from "adm-zip";
|
|
432
|
+
function createSourceZip(projectDir) {
|
|
433
|
+
const sourceZip = new AdmZip2();
|
|
434
|
+
const rootFiles = fs5.readdirSync(projectDir);
|
|
435
|
+
for (const file of rootFiles) {
|
|
436
|
+
if (file === "node_modules" || file === "dist" || file.endsWith(".zip")) {
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const filePath = path6.join(projectDir, file);
|
|
440
|
+
const stat = fs5.statSync(filePath);
|
|
441
|
+
if (stat.isDirectory()) {
|
|
442
|
+
sourceZip.addLocalFolder(filePath, file);
|
|
443
|
+
} else {
|
|
444
|
+
sourceZip.addFile(file, fs5.readFileSync(filePath));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return sourceZip.toBuffer();
|
|
448
|
+
}
|
|
427
449
|
function zipDist(projectDir, name, version) {
|
|
428
450
|
const distDir = path6.join(projectDir, "dist");
|
|
429
451
|
if (!fs5.existsSync(distDir)) {
|
|
@@ -433,6 +455,8 @@ function zipDist(projectDir, name, version) {
|
|
|
433
455
|
const zipFilePath = path6.join(projectDir, zipFileName);
|
|
434
456
|
const zip = new AdmZip2();
|
|
435
457
|
zip.addLocalFolder(distDir);
|
|
458
|
+
const sourceZipBuffer = createSourceZip(projectDir);
|
|
459
|
+
zip.addFile(sourceZipBuffer, "source.zip");
|
|
436
460
|
zip.writeZip(zipFilePath);
|
|
437
461
|
return zipFilePath;
|
|
438
462
|
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
import { useState } from 'react';
|
|
2
|
-
import { FiRefreshCw } from 'react-icons/fi';
|
|
2
|
+
import { FiLogIn, FiLogOut, FiRefreshCw } from 'react-icons/fi';
|
|
3
3
|
import { useAuth } from '../hooks/useAuth';
|
|
4
|
-
import { useInwinkContext } from '../hooks/useInwinkContext';
|
|
5
4
|
import { useInwinkEventApi } from '../hooks/useInwinkEventApi';
|
|
6
5
|
|
|
7
6
|
// Version de HomePage générée par le template overlay "test-app".
|
|
8
|
-
// Sert à vérifier que le squelette bootstrappé compile et que les hooks
|
|
9
|
-
// (useInwinkEventApi
|
|
10
|
-
// désormais dans le Header (lui-même surchargé par cet overlay).
|
|
7
|
+
// Sert à vérifier que le squelette bootstrappé compile et que les hooks API
|
|
8
|
+
// (ici useInwinkEventApi) sont utilisables.
|
|
11
9
|
export default function HomePage() {
|
|
12
|
-
const { isAuthenticated, user } = useAuth();
|
|
13
|
-
const { scope, eventId, communityId, tenantId } = useInwinkContext();
|
|
10
|
+
const { isAuthenticated, isLoading, login, logout, user } = useAuth();
|
|
14
11
|
const eventApi = useInwinkEventApi();
|
|
15
12
|
const [result, setResult] = useState<string>('');
|
|
16
13
|
const [error, setError] = useState<string>('');
|
|
@@ -31,27 +28,28 @@ export default function HomePage() {
|
|
|
31
28
|
}
|
|
32
29
|
|
|
33
30
|
return (
|
|
34
|
-
<
|
|
31
|
+
<main>
|
|
35
32
|
<h1>__DISPLAY_NAME__ — test API Event</h1>
|
|
36
33
|
{!isAuthenticated ? (
|
|
37
|
-
<
|
|
34
|
+
<button disabled={isLoading} onClick={() => void login()}>
|
|
35
|
+
<FiLogIn aria-hidden />
|
|
36
|
+
{isLoading ? 'Initialisation...' : 'Se connecter'}
|
|
37
|
+
</button>
|
|
38
38
|
) : (
|
|
39
39
|
<>
|
|
40
40
|
<p>Connecté en tant que : {user?.profile.email ?? 'Inconnu'}</p>
|
|
41
|
-
<p>
|
|
42
|
-
Contexte courant : {scope ?? 'aucun'}
|
|
43
|
-
{eventId ? ` — événement ${eventId}` : ''}
|
|
44
|
-
{communityId ? ` — communauté ${communityId}` : ''}
|
|
45
|
-
{tenantId ? ` — audience ${tenantId}` : ''}
|
|
46
|
-
</p>
|
|
47
41
|
<button onClick={() => void callEventApi()}>
|
|
48
42
|
<FiRefreshCw aria-hidden />
|
|
49
43
|
Appeler l'API Event
|
|
50
44
|
</button>
|
|
45
|
+
<button onClick={() => void logout()}>
|
|
46
|
+
<FiLogOut aria-hidden />
|
|
47
|
+
Se déconnecter
|
|
48
|
+
</button>
|
|
51
49
|
{result && <pre>{result}</pre>}
|
|
52
50
|
{error && <p role="alert">Erreur : {error}</p>}
|
|
53
51
|
</>
|
|
54
52
|
)}
|
|
55
|
-
</
|
|
53
|
+
</main>
|
|
56
54
|
);
|
|
57
55
|
}
|
|
@@ -25,20 +25,9 @@ __NAME__/
|
|
|
25
25
|
├── vite.config.ts # Config Vite (base: './' + copie du manifest)
|
|
26
26
|
├── scripts/zip-build.mjs # Packaging zip {name}-{version}.zip
|
|
27
27
|
└── src/
|
|
28
|
-
├── main.tsx # Bootstrap React (HashRouter + AuthProvider
|
|
29
|
-
├── App.tsx # Routes
|
|
30
|
-
├── styles.css # Styles
|
|
31
|
-
├── components/
|
|
32
|
-
│ ├── Layout.tsx + .css # Gabarit : Header + zone de contenu (Outlet)
|
|
33
|
-
│ ├── Header.tsx + .css # En-tête (marque + login/logout) — surchargeable
|
|
34
|
-
│ ├── ContextCombobox.tsx + .css # Combobox ergonomique réutilisable (recherche + clavier)
|
|
35
|
-
│ ├── EventPicker.tsx # Sélecteur d'événement (autonome, non monté par défaut)
|
|
36
|
-
│ ├── CommunityPicker.tsx # Sélecteur de communauté (idem)
|
|
37
|
-
│ └── AudiencePicker.tsx # Sélecteur de tenant/audience (idem)
|
|
38
|
-
├── context/
|
|
39
|
-
│ └── InwinkContextProvider.tsx # Contexte event/community/tenant + loaders de listes
|
|
40
|
-
├── lib/
|
|
41
|
-
│ └── inwinkContextParams.ts # Lecture/persistance des params URL (eventid/communityid/tenantid)
|
|
28
|
+
├── main.tsx # Bootstrap React (HashRouter + AuthProvider)
|
|
29
|
+
├── App.tsx # Routes
|
|
30
|
+
├── styles.css # Styles
|
|
42
31
|
├── pages/
|
|
43
32
|
│ └── HomePage.tsx # Page d'accueil (logique métier / UI)
|
|
44
33
|
├── api/
|
|
@@ -49,8 +38,6 @@ __NAME__/
|
|
|
49
38
|
│ └── ProtectedRoute.tsx
|
|
50
39
|
└── hooks/
|
|
51
40
|
├── useAuth.ts # Hook d'accès au contexte auth
|
|
52
|
-
├── useInwinkContext.ts # Hook d'accès au contexte inwink (event/community/tenant)
|
|
53
|
-
├── useContextOptions.ts # Chargement (1×) des listes pour les pickers
|
|
54
41
|
├── useInwinkApi.ts # Hook générique d'appel API (Bearer + renouvellement 401)
|
|
55
42
|
├── useInwinkEventApi.ts # Hook API Event
|
|
56
43
|
├── useInwinkCommunityApi.ts # Hook API Community
|
|
@@ -98,50 +85,6 @@ Bonnes pratiques :
|
|
|
98
85
|
- Ne déclarer dans `permissions.platform` que les scopes réellement utilisés.
|
|
99
86
|
- Déclarer les hôtes externes dans `permissions.externalHosts` (HTTPS uniquement).
|
|
100
87
|
|
|
101
|
-
## Structure de pages & Header
|
|
102
|
-
|
|
103
|
-
L'app est rendue dans un `Layout` (`components/Layout.tsx`) = `Header` + zone de contenu (`<Outlet />`). Le `Header` (`components/Header.tsx`) est **minimal par défaut** (marque + bouton login/logout) et **conçu pour être surchargé** : c'est l'endroit naturel où monter un picker de contexte si l'extension en a besoin.
|
|
104
|
-
|
|
105
|
-
Pour **ajouter une page** : créer le composant dans `src/pages/`, puis ajouter une `<Route>` enfant du `<Route element={<Layout .../>}>` dans `App.tsx` (elle s'affichera dans l'`Outlet`).
|
|
106
|
-
|
|
107
|
-
## Contexte inwink (event / community / tenant)
|
|
108
|
-
|
|
109
|
-
Quand l'extension est ouverte depuis le back-office sur un objet précis, le BO passe `eventid`, `communityid` ou `tenantid` dans l'URL. Le template gère ça nativement :
|
|
110
|
-
|
|
111
|
-
- `lib/inwinkContextParams.ts` lit ces paramètres (query classique **et** query du hash), insensible à la casse, et les **persiste en sessionStorage** — indispensable car le callback OIDC nettoie l'URL et le `redirect_uri` ne réémet pas la query.
|
|
112
|
-
- `context/InwinkContextProvider.tsx` expose le contexte courant et les setters via le hook **`useInwinkContext`** :
|
|
113
|
-
|
|
114
|
-
```typescript
|
|
115
|
-
const { scope, eventId, communityId, tenantId, setEvent, setCommunity, setTenant } = useInwinkContext();
|
|
116
|
-
// scope = 'event' | 'community' | 'tenant' | null (tenant = audience)
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
Le contexte issu de l'URL est **pré-rempli mais modifiable** (l'utilisateur peut changer d'objet via un picker).
|
|
120
|
-
|
|
121
|
-
## Pickers de contexte (event / community / audience)
|
|
122
|
-
|
|
123
|
-
Trois composants autonomes existent dans `components/` : **`EventPicker`**, **`CommunityPicker`**, **`AudiencePicker`**. Ils consomment `useInwinkContext`, listent les objets de l'utilisateur via l'API Customer (`me/events/query`, `me/communities/query`, `me/tenants/query`) et appliquent la sélection. L'UI est mutualisée dans **`ContextCombobox`** (bouton + popover avec recherche et navigation clavier) — réutilise-le pour tout sélecteur similaire.
|
|
124
|
-
|
|
125
|
-
Ces listes sont **paginées côté serveur** (1ʳᵉ page seulement). La recherche du combobox est donc déléguée au serveur via `useContextOptions` (loaders `loadEvents/loadCommunities/loadTenants`, paramètre `IInwinkContextQuery = { search?, id? }`) : la saisie est debouncée et filtre par nom **ou** id (`{ filters: { $or: [{ id }, { <label>: { $contains } }] } }`). Les événements sont triés par date décroissante (`orders`). Comme l'objet pré-sélectionné via l'URL peut ne pas figurer dans la page courante, `useContextOptions` **fetch son détail par id** et le fusionne dans les options pour afficher son libellé.
|
|
126
|
-
|
|
127
|
-
**Ils ne sont PAS montés par défaut.** À toi de décider, selon l'usage de l'extension, d'en monter un — typiquement dans le `Header` (cf. ci-dessus) ou dans une page :
|
|
128
|
-
|
|
129
|
-
```tsx
|
|
130
|
-
import EventPicker from '../components/EventPicker';
|
|
131
|
-
// ...
|
|
132
|
-
{isAuthenticated && <EventPicker />}
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
## Branding inwink
|
|
136
|
-
|
|
137
|
-
- Polices : **Lato** (texte) + **Poppins** (titres), chargées via Google Fonts dans `styles.css`. Les hôtes `fonts.googleapis.com` / `fonts.gstatic.com` sont déjà déclarés dans `permissions.externalHosts`.
|
|
138
|
-
- Couleur d'accentuation : **`#e94c1e`** (variable CSS `--color-accent`).
|
|
139
|
-
- Variables/tokens globaux dans `styles.css` ; classe `.btn-primary` pour l'action principale.
|
|
140
|
-
|
|
141
|
-
## Convention CSS (obligatoire)
|
|
142
|
-
|
|
143
|
-
**Tout nouveau composant doit avoir un fichier `.css` co-localisé**, importé en tête de son `.tsx` (`import './MonComposant.css';`). Les styles globaux et les tokens (`--font-*`, `--color-*`) restent dans `styles.css` ; les styles spécifiques d'un composant vivent dans son `.css`. Vite bundle automatiquement ces CSS dans `dist/assets/` (packagé dans le zip) — ne pas les oublier.
|
|
144
|
-
|
|
145
88
|
## Règles et contraintes
|
|
146
89
|
|
|
147
90
|
| Règle | Détail |
|
|
@@ -153,7 +96,6 @@ import EventPicker from '../components/EventPicker';
|
|
|
153
96
|
| Pas d'imports Node.js | `fs`, `path`, `http`, `child_process`, etc. interdits dans le code source |
|
|
154
97
|
| Pas de `fetch` direct | Toujours passer par `useInwinkEventApi` / `useInwinkCommunityApi` / `useInwinkCustomerApi` (ou `useInwinkApi` pour un hôte externe déclaré) |
|
|
155
98
|
| Icônes via `react-icons` | Ne pas embarquer de SVG inline ni d'images d'icônes |
|
|
156
|
-
| CSS co-localisé par composant | Chaque composant a un `.css` importé en tête de son `.tsx` ; tokens/global dans `styles.css` |
|
|
157
99
|
| `HashRouter` obligatoire | Pour compatibilité avec l'hébergement versionné |
|
|
158
100
|
| `base: './'` obligatoire | Dans `vite.config.ts`, pour les chemins d'assets relatifs |
|
|
159
101
|
| `response_type: 'code'` | PKCE obligatoire, pas de flux implicit |
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "__NAME__",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "__VERSION__",
|
|
4
4
|
"displayName": "__DISPLAY_NAME__",
|
|
5
5
|
"description": "__DESCRIPTION__",
|
|
6
6
|
"author": "__AUTHOR__",
|
|
7
7
|
"permissions": {
|
|
8
8
|
"platform": [],
|
|
9
|
-
"externalHosts": [
|
|
9
|
+
"externalHosts": []
|
|
10
10
|
},
|
|
11
11
|
"auth": {
|
|
12
12
|
"scopes": ["openid", "profile", "email"]
|
|
13
13
|
},
|
|
14
14
|
"entrypoint": "./dist/index.html",
|
|
15
15
|
"platform": {
|
|
16
|
-
"minVersion": "
|
|
16
|
+
"minVersion": "__VERSION__"
|
|
17
17
|
}
|
|
18
18
|
}
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
7
|
-
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
8
6
|
<title>__DISPLAY_NAME__</title>
|
|
9
7
|
</head>
|
|
10
8
|
<body>
|
|
@@ -30,7 +30,30 @@ const zipFileName = `${manifest.name}-${manifest.version}.zip`;
|
|
|
30
30
|
const zipFilePath = path.join(projectRoot, zipFileName);
|
|
31
31
|
const zip = new AdmZip();
|
|
32
32
|
|
|
33
|
+
function createSourceZipBuffer(rootDir) {
|
|
34
|
+
const sourceZip = new AdmZip();
|
|
35
|
+
const rootEntries = fs.readdirSync(rootDir);
|
|
36
|
+
|
|
37
|
+
for (const entry of rootEntries) {
|
|
38
|
+
if (entry === 'node_modules' || entry === 'dist' || entry.endsWith('.zip')) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const entryPath = path.join(rootDir, entry);
|
|
43
|
+
const stat = fs.statSync(entryPath);
|
|
44
|
+
|
|
45
|
+
if (stat.isDirectory()) {
|
|
46
|
+
sourceZip.addLocalFolder(entryPath, entry);
|
|
47
|
+
} else {
|
|
48
|
+
sourceZip.addFile(entry, fs.readFileSync(entryPath));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return sourceZip.toBuffer();
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
zip.addLocalFolder(distDir);
|
|
56
|
+
zip.addFile('source.zip', createSourceZipBuffer(projectRoot));
|
|
34
57
|
zip.writeZip(zipFilePath);
|
|
35
58
|
|
|
36
59
|
process.stdout.write(`${zipFileName}\n`);
|
|
@@ -1,28 +1,22 @@
|
|
|
1
1
|
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
2
|
-
import Layout from './components/Layout';
|
|
3
2
|
import ProtectedRoute from './auth/ProtectedRoute';
|
|
4
3
|
import HomePage from './pages/HomePage';
|
|
5
4
|
// Pour appeler une API inwink, utilisez un hook dédié (jamais fetch directement) :
|
|
6
5
|
// import { useInwinkEventApi } from './hooks/useInwinkEventApi';
|
|
7
6
|
// const eventApi = useInwinkEventApi();
|
|
8
7
|
// const data = await eventApi.get('/v1/...');
|
|
9
|
-
//
|
|
10
|
-
// Pour ajouter une page : créez-la dans src/pages/ puis ajoutez une <Route> enfant
|
|
11
|
-
// du <Route element={<Layout .../>}> ci-dessous (elle s'affichera dans l'<Outlet> du Layout).
|
|
12
8
|
|
|
13
9
|
export default function App() {
|
|
14
10
|
return (
|
|
15
11
|
<Routes>
|
|
16
|
-
<Route
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
/>
|
|
25
|
-
</Route>
|
|
12
|
+
<Route
|
|
13
|
+
path="/"
|
|
14
|
+
element={
|
|
15
|
+
<ProtectedRoute>
|
|
16
|
+
<HomePage />
|
|
17
|
+
</ProtectedRoute>
|
|
18
|
+
}
|
|
19
|
+
/>
|
|
26
20
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
27
21
|
</Routes>
|
|
28
22
|
);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/auth/AuthProvider.tsx
|
|
2
|
-
import { createContext, useEffect, useState, type PropsWithChildren } from 'react';
|
|
2
|
+
import { createContext, useCallback, useEffect, useMemo, useRef, useState, type PropsWithChildren } from 'react';
|
|
3
3
|
import type { User } from 'oidc-client-ts';
|
|
4
4
|
import { userManager } from './config';
|
|
5
5
|
|
|
@@ -16,6 +16,9 @@ type AuthContextValue = {
|
|
|
16
16
|
|
|
17
17
|
export const AuthContext = createContext<AuthContextValue | null>(null);
|
|
18
18
|
|
|
19
|
+
const LOGIN_REDIRECT_COOLDOWN_MS = 15000;
|
|
20
|
+
const LAST_LOGIN_REDIRECT_AT_KEY = 'inwink.auth.lastLoginRedirectAt';
|
|
21
|
+
|
|
19
22
|
function isOidcCallback(): boolean {
|
|
20
23
|
const params = new URLSearchParams(window.location.search);
|
|
21
24
|
return params.has('code') && params.has('state');
|
|
@@ -24,6 +27,9 @@ function isOidcCallback(): boolean {
|
|
|
24
27
|
export function AuthProvider({ children }: PropsWithChildren) {
|
|
25
28
|
const [user, setUser] = useState<User | null>(null);
|
|
26
29
|
const [isLoading, setIsLoading] = useState(true);
|
|
30
|
+
const userRef = useRef(user);
|
|
31
|
+
const loginInFlightRef = useRef(false);
|
|
32
|
+
useEffect(() => { userRef.current = user; }, [user]);
|
|
27
33
|
|
|
28
34
|
useEffect(() => {
|
|
29
35
|
let isMounted = true;
|
|
@@ -56,24 +62,58 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|
|
56
62
|
};
|
|
57
63
|
}, []);
|
|
58
64
|
|
|
65
|
+
const login = useCallback(async () => {
|
|
66
|
+
if (loginInFlightRef.current) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const lastRedirectAt = Number(sessionStorage.getItem(LAST_LOGIN_REDIRECT_AT_KEY) ?? '0');
|
|
72
|
+
if (Number.isFinite(lastRedirectAt) && now - lastRedirectAt < LOGIN_REDIRECT_COOLDOWN_MS) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
loginInFlightRef.current = true;
|
|
77
|
+
setIsLoading(true);
|
|
78
|
+
sessionStorage.setItem(LAST_LOGIN_REDIRECT_AT_KEY, String(now));
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await userManager.signinRedirect();
|
|
82
|
+
} finally {
|
|
83
|
+
loginInFlightRef.current = false;
|
|
84
|
+
setIsLoading(false);
|
|
85
|
+
}
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const logout = useCallback(async () => {
|
|
89
|
+
await userManager.removeUser();
|
|
90
|
+
setUser(null);
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const getAccessToken = useCallback(() => userRef.current?.access_token ?? null, []);
|
|
94
|
+
|
|
95
|
+
const renewToken = useCallback(async () => {
|
|
96
|
+
try {
|
|
97
|
+
const renewed = await userManager.signinSilent();
|
|
98
|
+
setUser(renewed);
|
|
99
|
+
return renewed?.access_token ?? null;
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const value = useMemo(() => ({
|
|
106
|
+
isAuthenticated: Boolean(user && !user.expired),
|
|
107
|
+
isLoading,
|
|
108
|
+
login,
|
|
109
|
+
logout,
|
|
110
|
+
user,
|
|
111
|
+
getAccessToken,
|
|
112
|
+
renewToken,
|
|
113
|
+
}), [user, isLoading]);
|
|
114
|
+
|
|
59
115
|
return (
|
|
60
|
-
<AuthContext.Provider value={
|
|
61
|
-
isAuthenticated: Boolean(user && !user.expired),
|
|
62
|
-
isLoading,
|
|
63
|
-
login: async () => { setIsLoading(true); await userManager.signinRedirect(); },
|
|
64
|
-
logout: async () => { await userManager.removeUser(); setUser(null); },
|
|
65
|
-
user,
|
|
66
|
-
getAccessToken: () => user?.access_token ?? null,
|
|
67
|
-
renewToken: async () => {
|
|
68
|
-
try {
|
|
69
|
-
const renewed = await userManager.signinSilent();
|
|
70
|
-
setUser(renewed);
|
|
71
|
-
return renewed?.access_token ?? null;
|
|
72
|
-
} catch {
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
},
|
|
76
|
-
}}>
|
|
116
|
+
<AuthContext.Provider value={value}>
|
|
77
117
|
{children}
|
|
78
118
|
</AuthContext.Provider>
|
|
79
119
|
);
|
|
@@ -5,7 +5,11 @@ export default function ProtectedRoute({ children }: PropsWithChildren) {
|
|
|
5
5
|
const { isLoading } = useAuth();
|
|
6
6
|
|
|
7
7
|
if (isLoading) {
|
|
8
|
-
return
|
|
8
|
+
return (
|
|
9
|
+
<div style={{ display: 'grid', placeItems: 'center', minHeight: '30vh' }} aria-busy="true">
|
|
10
|
+
<progress />
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
return <>{children}</>;
|
|
@@ -3,16 +3,13 @@ import ReactDOM from 'react-dom/client';
|
|
|
3
3
|
import { HashRouter } from 'react-router-dom';
|
|
4
4
|
import App from './App';
|
|
5
5
|
import { AuthProvider } from './auth/AuthProvider';
|
|
6
|
-
import { InwinkContextProvider } from './context/InwinkContextProvider';
|
|
7
6
|
import './styles.css';
|
|
8
7
|
|
|
9
8
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
10
9
|
<React.StrictMode>
|
|
11
10
|
<HashRouter>
|
|
12
11
|
<AuthProvider>
|
|
13
|
-
<
|
|
14
|
-
<App />
|
|
15
|
-
</InwinkContextProvider>
|
|
12
|
+
<App />
|
|
16
13
|
</AuthProvider>
|
|
17
14
|
</HashRouter>
|
|
18
15
|
</React.StrictMode>,
|
|
@@ -1,30 +1,26 @@
|
|
|
1
|
+
import { FiLogIn, FiLogOut } from 'react-icons/fi';
|
|
1
2
|
import { useAuth } from '../hooks/useAuth';
|
|
2
|
-
import { useInwinkContext } from '../hooks/useInwinkContext';
|
|
3
3
|
|
|
4
4
|
export default function HomePage() {
|
|
5
|
-
const { isAuthenticated, user } = useAuth();
|
|
6
|
-
const { scope, eventId, communityId, tenantId } = useInwinkContext();
|
|
7
|
-
|
|
8
|
-
if (!isAuthenticated) {
|
|
9
|
-
return (
|
|
10
|
-
<section>
|
|
11
|
-
<h1>__DISPLAY_NAME__</h1>
|
|
12
|
-
<p>Connectez-vous via le bouton « Se connecter » en haut à droite.</p>
|
|
13
|
-
</section>
|
|
14
|
-
);
|
|
15
|
-
}
|
|
5
|
+
const { isAuthenticated, isLoading, login, logout, user } = useAuth();
|
|
16
6
|
|
|
17
7
|
return (
|
|
18
|
-
<
|
|
8
|
+
<main>
|
|
19
9
|
<h1>__DISPLAY_NAME__</h1>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
10
|
+
{!isAuthenticated ? (
|
|
11
|
+
<button disabled={isLoading} onClick={() => void login()}>
|
|
12
|
+
<FiLogIn aria-hidden />
|
|
13
|
+
{isLoading ? 'Initialisation...' : 'Se connecter'}
|
|
14
|
+
</button>
|
|
15
|
+
) : (
|
|
16
|
+
<>
|
|
17
|
+
<p>Connecté en tant que : {user?.profile.email ?? 'Inconnu'}</p>
|
|
18
|
+
<button onClick={() => void logout()}>
|
|
19
|
+
<FiLogOut aria-hidden />
|
|
20
|
+
Se déconnecter
|
|
21
|
+
</button>
|
|
22
|
+
</>
|
|
23
|
+
)}
|
|
24
|
+
</main>
|
|
29
25
|
);
|
|
30
26
|
}
|
|
@@ -1,91 +1,32 @@
|
|
|
1
|
-
/* Polices inwink : Lato (texte) + Poppins (titres) via Google Fonts.
|
|
2
|
-
Nécessite que fonts.googleapis.com / fonts.gstatic.com soient déclarés
|
|
3
|
-
dans extension.manifest.json > permissions.externalHosts. */
|
|
4
|
-
@import url('https://fonts.googleapis.com/css2?family=Lato:wght@400;700&family=Poppins:wght@500;600;700&display=swap');
|
|
5
|
-
|
|
6
|
-
/* Design tokens — inspirés de l'identité inwink. */
|
|
7
1
|
:root {
|
|
8
|
-
|
|
9
|
-
--font-base: 'Lato', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
10
|
-
--font-title: 'Poppins', var(--font-base);
|
|
11
|
-
|
|
12
|
-
/* Couleur d'accentuation */
|
|
13
|
-
--color-accent: #e94c1e;
|
|
14
|
-
--color-accent-hover: #cf3f15;
|
|
15
|
-
--color-accent-contrast: #ffffff;
|
|
16
|
-
|
|
17
|
-
/* Neutres */
|
|
18
|
-
--color-bg: #ffffff;
|
|
19
|
-
--color-surface: #f7f7f8;
|
|
20
|
-
--color-border: #e2e2e6;
|
|
21
|
-
--color-text: #1d1d1f;
|
|
22
|
-
--color-text-muted: #6b6b73;
|
|
23
|
-
|
|
24
|
-
/* Rayons & ombres */
|
|
25
|
-
--radius: 8px;
|
|
26
|
-
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
|
|
27
|
-
|
|
28
|
-
font-family: var(--font-base);
|
|
2
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
29
3
|
line-height: 1.5;
|
|
30
|
-
color:
|
|
31
|
-
background: var(--color-bg);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
* {
|
|
35
|
-
box-sizing: border-box;
|
|
4
|
+
color-scheme: light dark;
|
|
36
5
|
}
|
|
37
6
|
|
|
38
7
|
body {
|
|
39
8
|
margin: 0;
|
|
40
|
-
|
|
41
|
-
background: var(--color-bg);
|
|
42
|
-
color: var(--color-text);
|
|
9
|
+
padding: 2rem;
|
|
43
10
|
}
|
|
44
11
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
font-family: var(--font-title);
|
|
49
|
-
font-weight: 600;
|
|
50
|
-
line-height: 1.2;
|
|
12
|
+
main {
|
|
13
|
+
max-width: 720px;
|
|
14
|
+
margin: 0 auto;
|
|
51
15
|
}
|
|
52
16
|
|
|
53
|
-
a {
|
|
54
|
-
color: var(--color-accent);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/* Boutons — base générique réutilisable.
|
|
58
|
-
.btn-primary = action principale (accent), sinon variante neutre. */
|
|
59
17
|
button {
|
|
60
18
|
cursor: pointer;
|
|
61
19
|
display: inline-flex;
|
|
62
20
|
align-items: center;
|
|
63
21
|
gap: 0.5rem;
|
|
64
22
|
padding: 0.5rem 1rem;
|
|
65
|
-
border-radius:
|
|
66
|
-
border: 1px solid
|
|
67
|
-
background:
|
|
68
|
-
color: var(--color-text);
|
|
23
|
+
border-radius: 6px;
|
|
24
|
+
border: 1px solid currentColor;
|
|
25
|
+
background: transparent;
|
|
69
26
|
font: inherit;
|
|
70
|
-
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
button:hover:not(:disabled) {
|
|
74
|
-
border-color: var(--color-accent);
|
|
75
27
|
}
|
|
76
28
|
|
|
77
29
|
button:disabled {
|
|
78
30
|
cursor: default;
|
|
79
31
|
opacity: 0.6;
|
|
80
32
|
}
|
|
81
|
-
|
|
82
|
-
button.btn-primary {
|
|
83
|
-
background: var(--color-accent);
|
|
84
|
-
border-color: var(--color-accent);
|
|
85
|
-
color: var(--color-accent-contrast);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
button.btn-primary:hover:not(:disabled) {
|
|
89
|
-
background: var(--color-accent-hover);
|
|
90
|
-
border-color: var(--color-accent-hover);
|
|
91
|
-
}
|