@inwink-mcp/backoffice-extension-helpers 1.0.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 +96 -0
- package/dist/cli.js +500 -0
- package/package.json +36 -0
- package/templates/test-app/src/pages/HomePage.tsx.tpl +55 -0
- package/templates/vite-extension/AGENTS.md.tpl +132 -0
- package/templates/vite-extension/CLAUDE.md +5 -0
- package/templates/vite-extension/extension.manifest.json.tpl +18 -0
- package/templates/vite-extension/index.html.tpl +12 -0
- package/templates/vite-extension/package.json.tpl +29 -0
- package/templates/vite-extension/scripts/zip-build.mjs +36 -0
- package/templates/vite-extension/src/App.tsx.tpl +23 -0
- package/templates/vite-extension/src/api/endpoints.ts.tpl +5 -0
- package/templates/vite-extension/src/auth/AuthProvider.tsx +80 -0
- package/templates/vite-extension/src/auth/ProtectedRoute.tsx +12 -0
- package/templates/vite-extension/src/auth/config.ts +16 -0
- package/templates/vite-extension/src/hooks/useAuth.ts +12 -0
- package/templates/vite-extension/src/hooks/useInwinkApi.ts +101 -0
- package/templates/vite-extension/src/hooks/useInwinkCommunityApi.ts +7 -0
- package/templates/vite-extension/src/hooks/useInwinkCustomerApi.ts +7 -0
- package/templates/vite-extension/src/hooks/useInwinkEventApi.ts +7 -0
- package/templates/vite-extension/src/main.tsx +16 -0
- package/templates/vite-extension/src/pages/HomePage.tsx.tpl +26 -0
- package/templates/vite-extension/src/styles.css +32 -0
- package/templates/vite-extension/src/vite-env.d.ts +1 -0
- package/templates/vite-extension/tsconfig.json +20 -0
- package/templates/vite-extension/tsconfig.node.json +15 -0
- package/templates/vite-extension/vite.config.ts +30 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# __DISPLAY_NAME__ — Guide pour les agents IA
|
|
2
|
+
|
|
3
|
+
> Ce fichier documente l'architecture du projet et les contraintes à respecter pour tout agent IA (Claude Code, Copilot, etc.) qui contribue à cette extension inwink.
|
|
4
|
+
|
|
5
|
+
## Objectif du projet
|
|
6
|
+
|
|
7
|
+
`__DISPLAY_NAME__` (`__NAME__`) est une **extension backoffice inwink** : __DESCRIPTION__
|
|
8
|
+
|
|
9
|
+
C'est une SPA React/TypeScript **100 % client-side** (zéro code serveur) qui s'authentifie via OIDC (Authorization Code + PKCE) et communique avec les API inwink en Bearer token.
|
|
10
|
+
|
|
11
|
+
## Stack technique
|
|
12
|
+
|
|
13
|
+
- **React 19** + **TypeScript** (strict)
|
|
14
|
+
- **Vite** (build/bundling) — `base: './'` obligatoire
|
|
15
|
+
- **react-router-dom** avec **HashRouter** (routage dans le fragment `#`)
|
|
16
|
+
- **oidc-client-ts** pour l'authentification OIDC
|
|
17
|
+
- **react-icons** pour les icônes
|
|
18
|
+
|
|
19
|
+
## Structure des dossiers
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
__NAME__/
|
|
23
|
+
├── extension.manifest.json # Identité + permissions de l'extension (obligatoire)
|
|
24
|
+
├── index.html # Point d'entrée HTML
|
|
25
|
+
├── vite.config.ts # Config Vite (base: './' + copie du manifest)
|
|
26
|
+
├── scripts/zip-build.mjs # Packaging zip {name}-{version}.zip
|
|
27
|
+
└── src/
|
|
28
|
+
├── main.tsx # Bootstrap React (HashRouter + AuthProvider)
|
|
29
|
+
├── App.tsx # Routes
|
|
30
|
+
├── styles.css # Styles
|
|
31
|
+
├── pages/
|
|
32
|
+
│ └── HomePage.tsx # Page d'accueil (logique métier / UI)
|
|
33
|
+
├── api/
|
|
34
|
+
│ └── endpoints.ts # URLs de base des API inwink (injectées au bootstrap)
|
|
35
|
+
├── auth/
|
|
36
|
+
│ ├── config.ts # Configuration OIDC (UserManager)
|
|
37
|
+
│ ├── AuthProvider.tsx # Contexte d'auth (callback OIDC inline + renewToken)
|
|
38
|
+
│ └── ProtectedRoute.tsx
|
|
39
|
+
└── hooks/
|
|
40
|
+
├── useAuth.ts # Hook d'accès au contexte auth
|
|
41
|
+
├── useInwinkApi.ts # Hook générique d'appel API (Bearer + renouvellement 401)
|
|
42
|
+
├── useInwinkEventApi.ts # Hook API Event
|
|
43
|
+
├── useInwinkCommunityApi.ts # Hook API Community
|
|
44
|
+
└── useInwinkCustomerApi.ts # Hook API Customer
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Découverte des API inwink (obligatoire)
|
|
48
|
+
|
|
49
|
+
Avant d'écrire **tout** appel API, utilise les outils MCP inwink pour découvrir dynamiquement endpoints, méthodes, payloads et entités :
|
|
50
|
+
|
|
51
|
+
1. `inwink_mcp_find_api_endpoint` avec une requête sémantique précise et le bon `targetApi` (`eventapi`, `communityapi` ou `customerapi`).
|
|
52
|
+
2. `inwink_mcp_get_entity_description` pour comprendre les entités, champs, payloads, filtres et DTOs.
|
|
53
|
+
3. Code ensuite l'appel via le hook API approprié, uniquement à partir du résultat de ces outils.
|
|
54
|
+
|
|
55
|
+
**Ne devine jamais une route ou un format de payload.** Le MCP est un outil de développement : l'extension n'appelle jamais le serveur MCP en runtime.
|
|
56
|
+
|
|
57
|
+
## Appeler l'API inwink
|
|
58
|
+
|
|
59
|
+
**N'appelle jamais `fetch` directement.** Utilise les hooks dédiés (`useInwinkEventApi`, `useInwinkCommunityApi`, `useInwinkCustomerApi`). Ils encapsulent l'envoi du Bearer token, le renouvellement automatique sur 401 (`signinSilent` puis rejeu, sinon login interactif) et la gestion d'erreurs (`InwinkApiError`).
|
|
60
|
+
|
|
61
|
+
```typescript
|
|
62
|
+
import { useInwinkEventApi } from './hooks/useInwinkEventApi';
|
|
63
|
+
|
|
64
|
+
function MonComposant() {
|
|
65
|
+
const eventApi = useInwinkEventApi();
|
|
66
|
+
|
|
67
|
+
async function charger() {
|
|
68
|
+
// GET — le chemin est relatif à la base de l'API Event (déjà configurée)
|
|
69
|
+
const data = await eventApi.get<MaReponse>('/v1/...');
|
|
70
|
+
|
|
71
|
+
// POST avec corps JSON
|
|
72
|
+
await eventApi.post('/v1/...', { champ: 'valeur' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Méthodes disponibles sur chaque hook : `request<T>(path, init?)`, `get<T>`, `post<T>(path, body?)`, `put<T>(path, body?)`, `del<T>`.
|
|
78
|
+
|
|
79
|
+
Pour appeler un **hôte externe déclaré** (hors API inwink), utilise le hook générique `useInwinkApi(baseUrl)`.
|
|
80
|
+
|
|
81
|
+
Bonnes pratiques :
|
|
82
|
+
- Toujours passer par un hook `useInwink*Api` — jamais de `fetch` direct.
|
|
83
|
+
- Le 401 est géré automatiquement (renouvellement puis reconnexion) ; ne pas le ré-implémenter.
|
|
84
|
+
- Gérer le 429 (rate limiting) avec un backoff exponentiel côté appelant si nécessaire.
|
|
85
|
+
- Ne déclarer dans `permissions.platform` que les scopes réellement utilisés.
|
|
86
|
+
- Déclarer les hôtes externes dans `permissions.externalHosts` (HTTPS uniquement).
|
|
87
|
+
|
|
88
|
+
## Règles et contraintes
|
|
89
|
+
|
|
90
|
+
| Règle | Détail |
|
|
91
|
+
|-------|--------|
|
|
92
|
+
| Pas de code serveur | L'extension est 100 % client-side (SPA statique) |
|
|
93
|
+
| Pas d'`eval()` / `new Function()` | Interdit pour raisons de sécurité |
|
|
94
|
+
| Pas de tokens dans `localStorage` manuellement | Utiliser uniquement le `WebStorageStateStore` d'`oidc-client-ts` |
|
|
95
|
+
| Pas de tokens dans les URLs | Ne jamais passer de token en query string |
|
|
96
|
+
| Pas d'imports Node.js | `fs`, `path`, `http`, `child_process`, etc. interdits dans le code source |
|
|
97
|
+
| Pas de `fetch` direct | Toujours passer par `useInwinkEventApi` / `useInwinkCommunityApi` / `useInwinkCustomerApi` (ou `useInwinkApi` pour un hôte externe déclaré) |
|
|
98
|
+
| Icônes via `react-icons` | Ne pas embarquer de SVG inline ni d'images d'icônes |
|
|
99
|
+
| `HashRouter` obligatoire | Pour compatibilité avec l'hébergement versionné |
|
|
100
|
+
| `base: './'` obligatoire | Dans `vite.config.ts`, pour les chemins d'assets relatifs |
|
|
101
|
+
| `response_type: 'code'` | PKCE obligatoire, pas de flux implicit |
|
|
102
|
+
| HTTPS uniquement | Pour les `externalHosts` déclarés dans le manifest |
|
|
103
|
+
| `redirect_uri` = pathname courant | Toujours `${window.location.origin}${window.location.pathname}` |
|
|
104
|
+
| `post_logout_redirect_uri` | Ne pas l'envoyer par défaut ; uniquement si l'URL exacte est enregistrée côté IdP |
|
|
105
|
+
| Pas de `CallbackPage` séparée | Le callback est détecté inline dans `AuthProvider` via `?code=&state=` |
|
|
106
|
+
| `authority` en dur | `'https://auth.inwink.com/inwink'` — ne pas utiliser `import.meta.env` (non substitué par Vite) |
|
|
107
|
+
|
|
108
|
+
## Commandes
|
|
109
|
+
|
|
110
|
+
```powershell
|
|
111
|
+
npm install # Installer les dépendances
|
|
112
|
+
npm run dev # Serveur de développement Vite
|
|
113
|
+
npm run build # Vérification TypeScript + build dans dist/
|
|
114
|
+
npm run package # Build + zip {name}-{version}.zip
|
|
115
|
+
npm run preview # Prévisualiser le build
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Packaging et vérification via la CLI helper :
|
|
119
|
+
|
|
120
|
+
```powershell
|
|
121
|
+
npx @inwink-mcp/backoffice-extension-helpers@latest package # build + zip + vérification de conformité
|
|
122
|
+
npx @inwink-mcp/backoffice-extension-helpers@latest verify # vérification de conformité seule
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Conventions de code
|
|
126
|
+
|
|
127
|
+
- TypeScript strict ; pas de `any` implicite.
|
|
128
|
+
- Composants fonctionnels React + hooks.
|
|
129
|
+
- L'auth passe **toujours** par `useAuth()` ; ne pas appeler `userManager` directement hors de `auth/`.
|
|
130
|
+
- Les appels API passent **toujours** par un hook `useInwink*Api` ; jamais de `fetch` direct dans les composants.
|
|
131
|
+
- Les icônes proviennent de **`react-icons`** (ex. `react-icons/fi`, `react-icons/md`) ; pas de SVG inline ni d'images d'icônes.
|
|
132
|
+
- Le squelette (auth, api, config, structure) est généré par la CLI helper — ne pas le régénérer à la main. Ajouter la logique métier dans `App.tsx` et de nouveaux composants/hooks.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"displayName": "__DISPLAY_NAME__",
|
|
5
|
+
"description": "__DESCRIPTION__",
|
|
6
|
+
"author": "__AUTHOR__",
|
|
7
|
+
"permissions": {
|
|
8
|
+
"platform": [],
|
|
9
|
+
"externalHosts": []
|
|
10
|
+
},
|
|
11
|
+
"auth": {
|
|
12
|
+
"scopes": ["openid", "profile", "email"]
|
|
13
|
+
},
|
|
14
|
+
"entrypoint": "./dist/index.html",
|
|
15
|
+
"platform": {
|
|
16
|
+
"minVersion": "1.0.0"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="fr">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>__DISPLAY_NAME__</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc --noEmit && vite build",
|
|
9
|
+
"package": "npm run build && npm run zip",
|
|
10
|
+
"preview": "vite preview",
|
|
11
|
+
"zip": "node ./scripts/zip-build.mjs"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"oidc-client-ts": "^3.1.0",
|
|
15
|
+
"react": "^19.1.0",
|
|
16
|
+
"react-dom": "^19.1.0",
|
|
17
|
+
"react-icons": "^5.4.0",
|
|
18
|
+
"react-router-dom": "^7.5.1"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.14.1",
|
|
22
|
+
"adm-zip": "^0.5.16",
|
|
23
|
+
"@types/react": "^19.1.2",
|
|
24
|
+
"@types/react-dom": "^19.1.2",
|
|
25
|
+
"@vitejs/plugin-react": "^4.4.1",
|
|
26
|
+
"typescript": "^5.8.3",
|
|
27
|
+
"vite": "^6.3.5"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import AdmZip from 'adm-zip';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const projectRoot = path.resolve(currentDir, '..');
|
|
8
|
+
const distDir = path.join(projectRoot, 'dist');
|
|
9
|
+
const manifestPath = path.join(projectRoot, 'extension.manifest.json');
|
|
10
|
+
|
|
11
|
+
if (!fs.existsSync(distDir)) {
|
|
12
|
+
throw new Error('dist directory not found. Run "npm run build" before "npm run zip".');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!fs.existsSync(manifestPath)) {
|
|
16
|
+
throw new Error('extension.manifest.json not found.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
20
|
+
|
|
21
|
+
if (typeof manifest.name !== 'string' || !manifest.name.trim()) {
|
|
22
|
+
throw new Error('extension.manifest.json must define a non-empty name.');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (typeof manifest.version !== 'string' || !manifest.version.trim()) {
|
|
26
|
+
throw new Error('extension.manifest.json must define a non-empty version.');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const zipFileName = `${manifest.name}-${manifest.version}.zip`;
|
|
30
|
+
const zipFilePath = path.join(projectRoot, zipFileName);
|
|
31
|
+
const zip = new AdmZip();
|
|
32
|
+
|
|
33
|
+
zip.addLocalFolder(distDir);
|
|
34
|
+
zip.writeZip(zipFilePath);
|
|
35
|
+
|
|
36
|
+
process.stdout.write(`${zipFileName}\n`);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Navigate, Route, Routes } from 'react-router-dom';
|
|
2
|
+
import ProtectedRoute from './auth/ProtectedRoute';
|
|
3
|
+
import HomePage from './pages/HomePage';
|
|
4
|
+
// Pour appeler une API inwink, utilisez un hook dédié (jamais fetch directement) :
|
|
5
|
+
// import { useInwinkEventApi } from './hooks/useInwinkEventApi';
|
|
6
|
+
// const eventApi = useInwinkEventApi();
|
|
7
|
+
// const data = await eventApi.get('/v1/...');
|
|
8
|
+
|
|
9
|
+
export default function App() {
|
|
10
|
+
return (
|
|
11
|
+
<Routes>
|
|
12
|
+
<Route
|
|
13
|
+
path="/"
|
|
14
|
+
element={
|
|
15
|
+
<ProtectedRoute>
|
|
16
|
+
<HomePage />
|
|
17
|
+
</ProtectedRoute>
|
|
18
|
+
}
|
|
19
|
+
/>
|
|
20
|
+
<Route path="*" element={<Navigate to="/" replace />} />
|
|
21
|
+
</Routes>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// URLs de base des API inwink, injectées au bootstrap de l'extension.
|
|
2
|
+
// Ne pas modifier à la main : régénérées par la CLI selon les flags --*-api-url.
|
|
3
|
+
export const EVENT_API_URL = '__EVENT_API_URL__';
|
|
4
|
+
export const COMMUNITY_API_URL = '__COMMUNITY_API_URL__';
|
|
5
|
+
export const CUSTOMER_API_URL = '__CUSTOMER_API_URL__';
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// src/auth/AuthProvider.tsx
|
|
2
|
+
import { createContext, useEffect, useState, type PropsWithChildren } from 'react';
|
|
3
|
+
import type { User } from 'oidc-client-ts';
|
|
4
|
+
import { userManager } from './config';
|
|
5
|
+
|
|
6
|
+
type AuthContextValue = {
|
|
7
|
+
isAuthenticated: boolean;
|
|
8
|
+
isLoading: boolean;
|
|
9
|
+
login: () => Promise<void>;
|
|
10
|
+
logout: () => Promise<void>;
|
|
11
|
+
user: User | null;
|
|
12
|
+
getAccessToken: () => string | null;
|
|
13
|
+
/** Renouvelle le token silencieusement. Retourne le nouvel access_token, ou null si échec. */
|
|
14
|
+
renewToken: () => Promise<string | null>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const AuthContext = createContext<AuthContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
function isOidcCallback(): boolean {
|
|
20
|
+
const params = new URLSearchParams(window.location.search);
|
|
21
|
+
return params.has('code') && params.has('state');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function AuthProvider({ children }: PropsWithChildren) {
|
|
25
|
+
const [user, setUser] = useState<User | null>(null);
|
|
26
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
let isMounted = true;
|
|
30
|
+
|
|
31
|
+
async function init() {
|
|
32
|
+
try {
|
|
33
|
+
if (isOidcCallback()) {
|
|
34
|
+
const callbackUser = await userManager.signinCallback(window.location.href);
|
|
35
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
36
|
+
if (isMounted) { setUser(callbackUser ?? null); setIsLoading(false); }
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const loadedUser = await userManager.getUser();
|
|
40
|
+
if (isMounted) { setUser(loadedUser); setIsLoading(false); }
|
|
41
|
+
} catch {
|
|
42
|
+
if (isMounted) { setUser(null); setIsLoading(false); }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
void init();
|
|
47
|
+
|
|
48
|
+
const handleUserLoaded = (u: User) => { setUser(u); setIsLoading(false); };
|
|
49
|
+
const handleUserUnloaded = () => { setUser(null); setIsLoading(false); };
|
|
50
|
+
userManager.events.addUserLoaded(handleUserLoaded);
|
|
51
|
+
userManager.events.addUserUnloaded(handleUserUnloaded);
|
|
52
|
+
return () => {
|
|
53
|
+
isMounted = false;
|
|
54
|
+
userManager.events.removeUserLoaded(handleUserLoaded);
|
|
55
|
+
userManager.events.removeUserUnloaded(handleUserUnloaded);
|
|
56
|
+
};
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
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
|
+
}}>
|
|
77
|
+
{children}
|
|
78
|
+
</AuthContext.Provider>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PropsWithChildren } from 'react';
|
|
2
|
+
import { useAuth } from '../hooks/useAuth';
|
|
3
|
+
|
|
4
|
+
export default function ProtectedRoute({ children }: PropsWithChildren) {
|
|
5
|
+
const { isLoading } = useAuth();
|
|
6
|
+
|
|
7
|
+
if (isLoading) {
|
|
8
|
+
return <p>Chargement...</p>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return <>{children}</>;
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// src/auth/config.ts
|
|
2
|
+
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
|
|
3
|
+
|
|
4
|
+
const redirectUri = `${window.location.origin}${window.location.pathname}`;
|
|
5
|
+
|
|
6
|
+
export const userManager = new UserManager({
|
|
7
|
+
authority: 'https://auth.inwink.com/inwink',
|
|
8
|
+
client_id: 'inwink-extensions',
|
|
9
|
+
redirect_uri: redirectUri,
|
|
10
|
+
response_type: 'code',
|
|
11
|
+
scope: 'openid profile email',
|
|
12
|
+
loadUserInfo: true,
|
|
13
|
+
monitorSession: false,
|
|
14
|
+
automaticSilentRenew: false,
|
|
15
|
+
userStore: new WebStorageStateStore({ store: localStorage }),
|
|
16
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useContext } from 'react';
|
|
2
|
+
import { AuthContext } from '../auth/AuthProvider';
|
|
3
|
+
|
|
4
|
+
export function useAuth() {
|
|
5
|
+
const context = useContext(AuthContext);
|
|
6
|
+
|
|
7
|
+
if (!context) {
|
|
8
|
+
throw new Error('useAuth must be used within AuthProvider.');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return context;
|
|
12
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useAuth } from './useAuth';
|
|
2
|
+
|
|
3
|
+
/** Erreur levée lorsqu'une réponse API n'est pas OK (hors 401 géré par renouvellement). */
|
|
4
|
+
export class InwinkApiError extends Error {
|
|
5
|
+
constructor(
|
|
6
|
+
public readonly status: number,
|
|
7
|
+
public readonly body: unknown,
|
|
8
|
+
) {
|
|
9
|
+
super(`Inwink API error ${status}`);
|
|
10
|
+
this.name = 'InwinkApiError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InwinkApi {
|
|
15
|
+
request: <T = unknown>(path: string, init?: RequestInit) => Promise<T>;
|
|
16
|
+
get: <T = unknown>(path: string, init?: RequestInit) => Promise<T>;
|
|
17
|
+
post: <T = unknown>(path: string, body?: unknown, init?: RequestInit) => Promise<T>;
|
|
18
|
+
put: <T = unknown>(path: string, body?: unknown, init?: RequestInit) => Promise<T>;
|
|
19
|
+
del: <T = unknown>(path: string, init?: RequestInit) => Promise<T>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function safeBody(response: Response): Promise<unknown> {
|
|
23
|
+
try {
|
|
24
|
+
return await response.json();
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hook générique d'appel aux API inwink. Encapsule l'envoi du Bearer token,
|
|
32
|
+
* le renouvellement automatique sur 401 (signinSilent puis rejeu), et le
|
|
33
|
+
* fallback sur login interactif si le renouvellement échoue.
|
|
34
|
+
*
|
|
35
|
+
* Préférez les hooks dédiés (`useInwinkEventApi`, etc.) ; ce hook générique
|
|
36
|
+
* sert aux hôtes externes déclarés ou à des bases d'URL spécifiques.
|
|
37
|
+
*/
|
|
38
|
+
export function useInwinkApi(baseUrl: string): InwinkApi {
|
|
39
|
+
const { getAccessToken, renewToken, login } = useAuth();
|
|
40
|
+
|
|
41
|
+
async function doFetch(path: string, token: string, init?: RequestInit): Promise<Response> {
|
|
42
|
+
return fetch(`${baseUrl}${path}`, {
|
|
43
|
+
...init,
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${token}`,
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
...(init?.headers as Record<string, string> | undefined),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
|
|
53
|
+
let token = getAccessToken();
|
|
54
|
+
if (!token) {
|
|
55
|
+
await login();
|
|
56
|
+
throw new InwinkApiError(401, 'Non authentifié.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let response = await doFetch(path, token, init);
|
|
60
|
+
|
|
61
|
+
if (response.status === 401) {
|
|
62
|
+
const renewed = await renewToken();
|
|
63
|
+
if (renewed) {
|
|
64
|
+
token = renewed;
|
|
65
|
+
response = await doFetch(path, token, init);
|
|
66
|
+
}
|
|
67
|
+
if (response.status === 401) {
|
|
68
|
+
await login();
|
|
69
|
+
throw new InwinkApiError(401, 'Session expirée, reconnexion requise.');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new InwinkApiError(response.status, await safeBody(response));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (response.status === 204) {
|
|
78
|
+
return undefined as T;
|
|
79
|
+
}
|
|
80
|
+
return (await response.json()) as T;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const withBody =
|
|
84
|
+
(method: string) =>
|
|
85
|
+
<T = unknown>(path: string, body?: unknown, init?: RequestInit): Promise<T> =>
|
|
86
|
+
request<T>(path, {
|
|
87
|
+
...init,
|
|
88
|
+
method,
|
|
89
|
+
body: body === undefined ? init?.body : JSON.stringify(body),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
request,
|
|
94
|
+
get: <T = unknown>(path: string, init?: RequestInit) =>
|
|
95
|
+
request<T>(path, { ...init, method: 'GET' }),
|
|
96
|
+
post: withBody('POST'),
|
|
97
|
+
put: withBody('PUT'),
|
|
98
|
+
del: <T = unknown>(path: string, init?: RequestInit) =>
|
|
99
|
+
request<T>(path, { ...init, method: 'DELETE' }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { COMMUNITY_API_URL } from '../api/endpoints';
|
|
2
|
+
import { useInwinkApi, type InwinkApi } from './useInwinkApi';
|
|
3
|
+
|
|
4
|
+
/** Accès à l'API Community inwink (Bearer token + renouvellement 401 gérés). */
|
|
5
|
+
export function useInwinkCommunityApi(): InwinkApi {
|
|
6
|
+
return useInwinkApi(COMMUNITY_API_URL);
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { CUSTOMER_API_URL } from '../api/endpoints';
|
|
2
|
+
import { useInwinkApi, type InwinkApi } from './useInwinkApi';
|
|
3
|
+
|
|
4
|
+
/** Accès à l'API Customer inwink (Bearer token + renouvellement 401 gérés). */
|
|
5
|
+
export function useInwinkCustomerApi(): InwinkApi {
|
|
6
|
+
return useInwinkApi(CUSTOMER_API_URL);
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { EVENT_API_URL } from '../api/endpoints';
|
|
2
|
+
import { useInwinkApi, type InwinkApi } from './useInwinkApi';
|
|
3
|
+
|
|
4
|
+
/** Accès à l'API Event inwink (Bearer token + renouvellement 401 gérés). */
|
|
5
|
+
export function useInwinkEventApi(): InwinkApi {
|
|
6
|
+
return useInwinkApi(EVENT_API_URL);
|
|
7
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import ReactDOM from 'react-dom/client';
|
|
3
|
+
import { HashRouter } from 'react-router-dom';
|
|
4
|
+
import App from './App';
|
|
5
|
+
import { AuthProvider } from './auth/AuthProvider';
|
|
6
|
+
import './styles.css';
|
|
7
|
+
|
|
8
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
9
|
+
<React.StrictMode>
|
|
10
|
+
<HashRouter>
|
|
11
|
+
<AuthProvider>
|
|
12
|
+
<App />
|
|
13
|
+
</AuthProvider>
|
|
14
|
+
</HashRouter>
|
|
15
|
+
</React.StrictMode>,
|
|
16
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { FiLogIn, FiLogOut } from 'react-icons/fi';
|
|
2
|
+
import { useAuth } from '../hooks/useAuth';
|
|
3
|
+
|
|
4
|
+
export default function HomePage() {
|
|
5
|
+
const { isAuthenticated, isLoading, login, logout, user } = useAuth();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<main>
|
|
9
|
+
<h1>__DISPLAY_NAME__</h1>
|
|
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>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
3
|
+
line-height: 1.5;
|
|
4
|
+
color-scheme: light dark;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
margin: 0;
|
|
9
|
+
padding: 2rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
main {
|
|
13
|
+
max-width: 720px;
|
|
14
|
+
margin: 0 auto;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
button {
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
display: inline-flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: 0.5rem;
|
|
22
|
+
padding: 0.5rem 1rem;
|
|
23
|
+
border-radius: 6px;
|
|
24
|
+
border: 1px solid currentColor;
|
|
25
|
+
background: transparent;
|
|
26
|
+
font: inherit;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
button:disabled {
|
|
30
|
+
cursor: default;
|
|
31
|
+
opacity: 0.6;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"allowJs": false,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"module": "ESNext",
|
|
13
|
+
"moduleResolution": "Bundler",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"jsx": "react-jsx"
|
|
18
|
+
},
|
|
19
|
+
"include": ["src"]
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"composite": true,
|
|
4
|
+
"strict": true,
|
|
5
|
+
"forceConsistentCasingInFileNames": true,
|
|
6
|
+
"target": "ES2022",
|
|
7
|
+
"lib": ["ES2022"],
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"module": "ESNext",
|
|
10
|
+
"moduleResolution": "Bundler",
|
|
11
|
+
"allowSyntheticDefaultImports": true,
|
|
12
|
+
"types": ["node"]
|
|
13
|
+
},
|
|
14
|
+
"include": ["vite.config.ts"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { defineConfig } from 'vite';
|
|
5
|
+
import react from '@vitejs/plugin-react';
|
|
6
|
+
|
|
7
|
+
const projectRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
base: './',
|
|
11
|
+
plugins: [
|
|
12
|
+
react(),
|
|
13
|
+
{
|
|
14
|
+
name: 'emit-extension-manifest',
|
|
15
|
+
generateBundle() {
|
|
16
|
+
const manifestPath = path.resolve(projectRoot, 'extension.manifest.json');
|
|
17
|
+
const source = fs.readFileSync(manifestPath, 'utf8');
|
|
18
|
+
|
|
19
|
+
this.emitFile({
|
|
20
|
+
fileName: 'extension.manifest.json',
|
|
21
|
+
source,
|
|
22
|
+
type: 'asset',
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
build: {
|
|
28
|
+
outDir: 'dist',
|
|
29
|
+
},
|
|
30
|
+
});
|