@inwink-mcp/backoffice-extension-helpers 1.0.6 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inwink-mcp/backoffice-extension-helpers",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "author": "inwink",
5
5
  "license": "ISC",
6
6
  "description": "CLI pour bootstrapper, packager et vérifier les extensions backoffice inwink.",
@@ -0,0 +1,54 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { FiRefreshCw } from 'react-icons/fi';
3
+ import { useInwinkEventApi } from '../hooks/useInwinkEventApi';
4
+
5
+ // Composant de test (overlay "test-app") : exerce useInwinkEventApi en lançant
6
+ // l'appel automatiquement au montage. N'est monté que lorsque l'utilisateur est
7
+ // authentifié (cf. HomePage), mais l'appel passe de toute façon par le gate
8
+ // `whenReady` du hook, qui attend la résolution d'auth avant de lire le token.
9
+ export default function EventApiTester() {
10
+ const eventApi = useInwinkEventApi();
11
+ const [result, setResult] = useState<string>('');
12
+ const [error, setError] = useState<string>('');
13
+ const [isLoading, setIsLoading] = useState(false);
14
+
15
+ async function callEventApi(signal?: { aborted: boolean }) {
16
+ setError('');
17
+ setResult('');
18
+ setIsLoading(true);
19
+ try {
20
+ const data = await eventApi.post('/dae274d9-e86e-f011-8dca-0022488a3cad/session/query', {
21
+ "selects": { "id": true, "title": true, "status": true, "moderators": { "person": { "id": true, "firstname": true, "lastname": true, "company": true, "mail": true } }, "startDate": true, "endDate": true, "room": { "name": true, "id": true }, "speakers": { "id": true, "firstname": true, "lastname": true, "company": true, "mail": true }, "exhibitors": { "id": true, "name": true }, "infos": { "registered": true, "attended": true, "attendedOnsite": true, "attendedOnlineLive": true, "attendedOnlineReplay": true } },
22
+ "orders": [{ "desc": true, "value": { "validFrom": {} } }],
23
+ "page": { "index": 0, "size": 200 }
24
+ });
25
+ if (!signal?.aborted) setResult(JSON.stringify(data, null, 2));
26
+ } catch (err) {
27
+ if (!signal?.aborted) setError(err instanceof Error ? err.message : String(err));
28
+ } finally {
29
+ if (!signal?.aborted) setIsLoading(false);
30
+ }
31
+ }
32
+
33
+ // Appel automatique au montage ; on ignore le résultat si le composant est
34
+ // démonté entre-temps (évite un setState sur composant démonté).
35
+ useEffect(() => {
36
+ const signal = { aborted: false };
37
+ void callEventApi(signal);
38
+ return () => {
39
+ signal.aborted = true;
40
+ };
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ }, []);
43
+
44
+ return (
45
+ <section>
46
+ <button disabled={isLoading} onClick={() => void callEventApi()}>
47
+ <FiRefreshCw aria-hidden />
48
+ {isLoading ? 'Appel en cours…' : "Rappeler l'API Event"}
49
+ </button>
50
+ {result && <pre>{result}</pre>}
51
+ {error && <p role="alert">Erreur : {error}</p>}
52
+ </section>
53
+ );
54
+ }
@@ -1,31 +1,12 @@
1
- import { useState } from 'react';
2
- import { FiLogIn, FiLogOut, FiRefreshCw } from 'react-icons/fi';
1
+ import { FiLogIn, FiLogOut } from 'react-icons/fi';
3
2
  import { useAuth } from '../hooks/useAuth';
4
- import { useInwinkEventApi } from '../hooks/useInwinkEventApi';
3
+ import EventApiTester from '../components/EventApiTester';
5
4
 
6
5
  // Version de HomePage générée par le template overlay "test-app".
7
6
  // Sert à vérifier que le squelette bootstrappé compile et que les hooks API
8
- // (ici useInwinkEventApi) sont utilisables.
7
+ // (ici useInwinkEventApi, via EventApiTester) sont utilisables.
9
8
  export default function HomePage() {
10
9
  const { isAuthenticated, isLoading, login, logout, user } = useAuth();
11
- const eventApi = useInwinkEventApi();
12
- const [result, setResult] = useState<string>('');
13
- const [error, setError] = useState<string>('');
14
-
15
- async function callEventApi() {
16
- setError('');
17
- setResult('');
18
- try {
19
- const data = await eventApi.post('/dae274d9-e86e-f011-8dca-0022488a3cad/session/query', {
20
- "selects": { "id": true, "title": true, "status": true, "moderators": { "person": { "id": true, "firstname": true, "lastname": true, "company": true, "mail": true } }, "startDate": true, "endDate": true, "room": { "name": true, "id": true }, "speakers": { "id": true, "firstname": true, "lastname": true, "company": true, "mail": true }, "exhibitors": { "id": true, "name": true }, "infos": { "registered": true, "attended": true, "attendedOnsite": true, "attendedOnlineLive": true, "attendedOnlineReplay": true } },
21
- "orders": [{ "desc": true, "value": { "validFrom": {} } }],
22
- "page": { "index": 0, "size": 200 }
23
- });
24
- setResult(JSON.stringify(data, null, 2));
25
- } catch (err) {
26
- setError(err instanceof Error ? err.message : String(err));
27
- }
28
- }
29
10
 
30
11
  return (
31
12
  <main>
@@ -38,16 +19,11 @@ export default function HomePage() {
38
19
  ) : (
39
20
  <>
40
21
  <p>Connecté en tant que : {user?.profile.email ?? 'Inconnu'}</p>
41
- <button onClick={() => void callEventApi()}>
42
- <FiRefreshCw aria-hidden />
43
- Appeler l'API Event
44
- </button>
45
22
  <button onClick={() => void logout()}>
46
23
  <FiLogOut aria-hidden />
47
24
  Se déconnecter
48
25
  </button>
49
- {result && <pre>{result}</pre>}
50
- {error && <p role="alert">Erreur : {error}</p>}
26
+ <EventApiTester />
51
27
  </>
52
28
  )}
53
29
  </main>
@@ -12,6 +12,13 @@ type AuthContextValue = {
12
12
  getAccessToken: () => string | null;
13
13
  /** Renouvelle le token silencieusement. Retourne le nouvel access_token, ou null si échec. */
14
14
  renewToken: () => Promise<string | null>;
15
+ /**
16
+ * Résout une fois la résolution d'auth initiale terminée (callback OIDC ou
17
+ * `getUser()`). À attendre avant de lire le token dans un appel API : évite la
18
+ * course où un effet enfant part avant que la session soit chargée et
19
+ * déclenche un login interactif intempestif.
20
+ */
21
+ whenReady: () => Promise<void>;
15
22
  };
16
23
 
17
24
  export const AuthContext = createContext<AuthContextValue | null>(null);
@@ -29,8 +36,18 @@ export function AuthProvider({ children }: PropsWithChildren) {
29
36
  const [isLoading, setIsLoading] = useState(true);
30
37
  const userRef = useRef(user);
31
38
  const loginInFlightRef = useRef(false);
39
+ const renewInFlightRef = useRef<Promise<string | null> | null>(null);
32
40
  useEffect(() => { userRef.current = user; }, [user]);
33
41
 
42
+ // Deferred résolu à la fin de la résolution d'auth initiale (cf. `whenReady`).
43
+ // Créé en lazy-init pour exister dès le premier rendu (avant tout effet enfant).
44
+ const readyRef = useRef<{ promise: Promise<void>; resolve: () => void } | null>(null);
45
+ if (!readyRef.current) {
46
+ let resolve!: () => void;
47
+ const promise = new Promise<void>((r) => { resolve = r; });
48
+ readyRef.current = { promise, resolve };
49
+ }
50
+
34
51
  useEffect(() => {
35
52
  let isMounted = true;
36
53
 
@@ -49,7 +66,10 @@ export function AuthProvider({ children }: PropsWithChildren) {
49
66
  }
50
67
  }
51
68
 
52
- void init();
69
+ // Le gate `ready` est résolu quelle que soit l'issue de init() (succès,
70
+ // absence de session, ou erreur) : les appels en attente reprennent alors,
71
+ // token présent ou non — le cas « réellement non authentifié » mène à login().
72
+ void init().finally(() => readyRef.current?.resolve());
53
73
 
54
74
  const handleUserLoaded = (u: User) => { setUser(u); setIsLoading(false); };
55
75
  const handleUserUnloaded = () => { setUser(null); setIsLoading(false); };
@@ -92,14 +112,27 @@ export function AuthProvider({ children }: PropsWithChildren) {
92
112
 
93
113
  const getAccessToken = useCallback(() => userRef.current?.access_token ?? null, []);
94
114
 
115
+ const whenReady = useCallback(() => readyRef.current!.promise, []);
116
+
95
117
  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;
118
+ // Déduplication : des appels concurrents recevant un 401 partagent un seul
119
+ // signinSilent() en vol plutôt que d'en lancer un chacun.
120
+ if (renewInFlightRef.current) {
121
+ return renewInFlightRef.current;
102
122
  }
123
+ const renewal = (async () => {
124
+ try {
125
+ const renewed = await userManager.signinSilent();
126
+ setUser(renewed);
127
+ return renewed?.access_token ?? null;
128
+ } catch {
129
+ return null;
130
+ } finally {
131
+ renewInFlightRef.current = null;
132
+ }
133
+ })();
134
+ renewInFlightRef.current = renewal;
135
+ return renewal;
103
136
  }, []);
104
137
 
105
138
  const value = useMemo(() => ({
@@ -110,6 +143,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
110
143
  user,
111
144
  getAccessToken,
112
145
  renewToken,
146
+ whenReady,
113
147
  }), [user, isLoading]);
114
148
 
115
149
  return (
@@ -59,7 +59,12 @@ export function useInwinkApi(baseUrl: string): InwinkApi {
59
59
  }
60
60
 
61
61
  async function request<T = unknown>(path: string, init?: RequestInit): Promise<T> {
62
- const { getAccessToken, renewToken, login } = authRef.current;
62
+ const { getAccessToken, renewToken, login, whenReady } = authRef.current;
63
+ // Attend la fin de la résolution d'auth initiale avant de lire le token :
64
+ // sans ce gate, un appel parti depuis l'effet de montage d'un composant
65
+ // (hors ProtectedRoute) verrait token=null et déclencherait un login()
66
+ // intempestif alors que la session est en cours de chargement.
67
+ await whenReady();
63
68
  let token = getAccessToken();
64
69
  if (!token) {
65
70
  await login();