@abgov/nx-adsp 13.18.0-beta.10 → 13.18.0-beta.12

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": "@abgov/nx-adsp",
3
- "version": "13.18.0-beta.10",
3
+ "version": "13.18.0-beta.12",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -176,7 +176,7 @@ from **`<%= goaImportPath %>`**, not hand-rolled markup:
176
176
 
177
177
  | Component | Purpose |
178
178
  |---|---|
179
- | `AppSideMenu` | `goa-work-side-menu`. Takes `heading`/`primaryItems`/`secondaryItems`/`accountItems` props (each item: `{ label, to?, icon?, badge?, current? }`) and emits `itemClick` — it doesn't know about routing or auth, so `App.vue` computes `current`/handles the click itself. `App.vue` currently only populates `accountItems` (a single Sign in/out item); add real nav items to `primaryItems`/`secondaryItems` as routes are added. Also owns the skip-to-main-content landmark for this layout — `AppLayout` deliberately doesn't duplicate it. Also takes an optional `#topbar` slot — a slim row above the routed content, for something like a notification bell; only renders when given content, unused by default |
179
+ | `AppSideMenu` | `goa-work-side-menu`. Takes `heading`/`primaryItems`/`secondaryItems`/`accountItems` props (each item: `{ label, to?, icon?, badge?, current? }`) and emits `itemClick` — it doesn't know about routing or auth, so `App.vue` computes `current`/handles the click itself. `App.vue` currently only populates `accountItems` (a single Sign in/out item); add real nav items to `primaryItems`/`secondaryItems` as routes are added. **`icon` is effectively required** — `goa-work-side-menu-item` renders a blank item without it; use a GoA icon name (see [design.alberta.ca/components/icons](https://design.alberta.ca/components/icons)). Also owns the skip-to-main-content landmark for this layout — `AppLayout` deliberately doesn't duplicate it. Also takes an optional `#topbar` slot — a slim row above the routed content, for something like a notification bell; only renders when given content, unused by default |
180
180
  | `AppLayout` | The content gutter every view renders inside (see the key files table) |
181
181
  | `SessionExpiredBanner` | A `v-model:show` banner with `signIn`/`dismiss` emits. Bound to the `useSessionStore()` Pinia store (`src/stores/session.ts`), which `main.ts`'s `onAuthRefreshError` hook flips when the refresh token itself has expired |
182
182
 
@@ -234,20 +234,105 @@ mark what's meant to be edited.
234
234
  { path: '/queue', component: () => import('../views/QueueView.vue'), meta: { layout: 'wide' } }
235
235
  { path: '/apply', component: () => import('../views/ApplyView.vue'), meta: { layout: 'form' } }
236
236
  ```
237
- 3. Add a nav link to `src/App.vue` if needed
237
+ 3. **For the `internal` layout: add a nav link to `src/App.vue`.** The generated shell only
238
+ populates `accountItems`; primary navigation lives in `primaryItems` (or `secondaryItems`).
239
+ `AppSideMenu` is purely presentational — it doesn't know about routing, so `App.vue` owns
240
+ `current` detection and the `itemClick` handler:
241
+
242
+ ```typescript
243
+ // In <script setup>:
244
+ import { computed } from 'vue';
245
+ import { RouterView, useRoute, useRouter } from 'vue-router';
246
+ // ...existing imports...
247
+
248
+ const route = useRoute(); // already in the generated App.vue
249
+ const router = useRouter();
250
+
251
+ // Icon names come from the GoA icon set: design.alberta.ca/components/icons
252
+ const primaryItems = computed(() => [
253
+ { label: 'Home', to: '/', icon: 'home', current: route.path === '/' },
254
+ { label: 'My Feature', to: '/my-feature', icon: 'list', current: route.path.startsWith('/my-feature') },
255
+ ]);
256
+
257
+ function onItemClick(item: { to?: string }) {
258
+ if (item.to) router.push(item.to);
259
+ }
260
+ ```
261
+
262
+ ```html
263
+ <!-- In <template>, add :primary-items and @item-click to <AppSideMenu>: -->
264
+ <AppSideMenu
265
+ heading="..."
266
+ :primary-items="primaryItems"
267
+ :account-items="accountItems"
268
+ @item-click="onItemClick"
269
+ >
270
+ ```
271
+
272
+ The `header` layout has no side menu — add route-level breadcrumbs or a secondary nav
273
+ directly inside the view component instead.
238
274
 
239
275
  ## Backend API calls (proxy setup)
240
276
 
241
- Use relative `/api/` paths — they route through Vite's dev proxy and nginx in production:
277
+ Use relative `/api/` paths — they route through Vite's dev proxy and nginx in production.
278
+
279
+ **Use `useApi()` for all API calls.** `apiFetch` automatically refreshes the token and adds
280
+ `Authorization: Bearer` when the user is authenticated — you never need to do it at each
281
+ call site.
242
282
 
243
283
  ```typescript
244
- // correct
245
- const res = await fetch('/api/v1/my-resource');
284
+ import { useApi } from '../composables/useApi' // adjust depth for nested components (../../…)
285
+
286
+ const { apiFetch } = useApi()
287
+
288
+ // ✓ Any route — adds the token when authenticated, skips it when not
289
+ const res = await apiFetch('/api/v1/my-resource')
246
290
 
247
- // wrong bypasses proxy, won't work in production
248
- const res = await fetch('http://localhost:3333/my-service/v1/my-resource');
291
+ // With a request body
292
+ const res = await apiFetch('/api/v1/my-resource', {
293
+ method: 'POST',
294
+ headers: { 'Content-Type': 'application/json' },
295
+ body: JSON.stringify(payload),
296
+ })
297
+
298
+ // ✗ Wrong path — bypasses proxy, won't work in production
299
+ const res = await fetch('http://localhost:3333/<%= pairedProject || 'my-service' %>/v1/my-resource')
300
+ ```
301
+
302
+ **Most routes require authentication.** The service mounts `/v1` with the anonymous passport
303
+ strategy, so a bare request without a token reaches the router — but business routes also
304
+ gate on `tenant` auth and will 401. `apiFetch` handles this automatically when signed in.
305
+
306
+ **React to auth state for calls that require the user to be signed in.** Keycloak settles
307
+ asynchronously; watch `kc.authenticated` rather than reading it once on mount:
308
+
309
+ ```typescript
310
+ import { watch } from 'vue'
311
+ import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
312
+ import { useApi } from '../composables/useApi'
313
+
314
+ const kc = useKeycloak()
315
+ const { apiFetch } = useApi()
316
+
317
+ watch(
318
+ () => kc.authenticated,
319
+ async (authenticated) => {
320
+ if (!authenticated) return
321
+ try {
322
+ const res = await apiFetch('/api/v1/my-resource')
323
+ // ...
324
+ } catch {
325
+ // apiFetch rejects on network error or if the refresh token expired;
326
+ // the SessionExpiredBanner handles the latter via onAuthRefreshError.
327
+ }
328
+ },
329
+ { immediate: true },
330
+ )
249
331
  ```
250
332
 
333
+ `apiFetch` adds the token only when `kc.authenticated` is true — the watch ensures
334
+ you call it only after auth has settled, not speculatively on mount.
335
+
251
336
  ## Testing
252
337
 
253
338
  Tests live alongside source files (`*.spec.ts` / `*.spec.vue`) and run with Vitest:
@@ -0,0 +1,21 @@
1
+ import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
2
+
3
+ export function useApi() {
4
+ // Keep the reactive instance — do NOT destructure: `authenticated` and `keycloak`
5
+ // are plain values inside a reactive object and only populate asynchronously once
6
+ // Keycloak settles. Reading `kc.authenticated` at call time (inside apiFetch)
7
+ // always gets the current value; a destructured snapshot would freeze at setup time.
8
+ const kc = useKeycloak()
9
+
10
+ async function apiFetch(url: string, init: RequestInit = {}): Promise<Response> {
11
+ if (kc.authenticated) {
12
+ await kc.keycloak?.updateToken(30)
13
+ const headers = new Headers(init.headers)
14
+ headers.set('Authorization', `Bearer ${kc.keycloak?.token}`)
15
+ return fetch(url, { ...init, headers })
16
+ }
17
+ return fetch(url, init)
18
+ }
19
+
20
+ return { apiFetch }
21
+ }
@@ -1,43 +1,42 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, watch } from 'vue';
3
- import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
2
+ import { ref, onMounted, watch } from 'vue'
3
+ import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
4
+ import { useApi } from '../composables/useApi'
4
5
 
5
6
  // Keep the reactive instance — do NOT destructure (see App.vue): a destructured
6
- // `authenticated`/`keycloak` freezes at its setup-time value, so the private
7
- // fetch below would never see the user sign in and the bearer token stays undefined.
8
- const kc = useKeycloak();
9
- const publicResource = ref('Not retrieved — is the backend service running?');
10
- const privateResource = ref('Not retrieved — sign in first.');
7
+ // `authenticated` freezes at its setup-time value, so the watch below would never
8
+ // see the user sign in and apiFetch would always skip the token.
9
+ const kc = useKeycloak()
10
+ const { apiFetch } = useApi()
11
+ const publicResource = ref('Not retrieved — is the backend service running?')
12
+ const privateResource = ref('Not retrieved — sign in first.')
11
13
 
12
14
  onMounted(async () => {
13
15
  try {
14
- const res = await fetch('/api/v1/public');
15
- const data = await res.json();
16
- publicResource.value = data.message;
16
+ const res = await apiFetch('/api/v1/public')
17
+ const data = await res.json()
18
+ publicResource.value = data.message
17
19
  } catch {
18
- publicResource.value = 'Error loading public resource.';
20
+ publicResource.value = 'Error loading public resource.'
19
21
  }
20
- });
22
+ })
21
23
 
22
24
  // React to authentication (which settles asynchronously and may flip after mount,
23
25
  // e.g. on return from the login redirect) rather than reading it once on mount.
24
26
  watch(
25
27
  () => kc.authenticated,
26
28
  async (authenticated) => {
27
- if (!authenticated) return;
29
+ if (!authenticated) return
28
30
  try {
29
- await kc.keycloak?.updateToken(30);
30
- const res = await fetch('/api/v1/private', {
31
- headers: { Authorization: `Bearer ${kc.keycloak?.token}` },
32
- });
33
- const data = await res.json();
34
- privateResource.value = data.message;
31
+ const res = await apiFetch('/api/v1/private')
32
+ const data = await res.json()
33
+ privateResource.value = data.message
35
34
  } catch {
36
- privateResource.value = 'Error loading private resource.';
35
+ privateResource.value = 'Error loading private resource.'
37
36
  }
38
37
  },
39
- { immediate: true }
40
- );
38
+ { immediate: true },
39
+ )
41
40
  </script>
42
41
 
43
42
  <template>
@@ -297,6 +297,23 @@ describe('Vue App Generator', () => {
297
297
  expect(app).toContain('kc.keycloak?.login()');
298
298
  }, 30000);
299
299
 
300
+ it('generates a useApi composable that handles token refresh and auth headers', async () => {
301
+ await generator(host, options);
302
+ expect(host.exists('apps/test/src/composables/useApi.ts')).toBeTruthy();
303
+ const useApi = host.read('apps/test/src/composables/useApi.ts').toString();
304
+ // Token refresh and auth header injection are encapsulated here, not at each call site.
305
+ expect(useApi).toContain('updateToken');
306
+ expect(useApi).toContain('Authorization');
307
+ expect(useApi).toContain('apiFetch');
308
+
309
+ // HomeView delegates to the composable — raw token wiring must not leak into views.
310
+ const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
311
+ expect(homeView).toContain('useApi');
312
+ expect(homeView).toContain('apiFetch');
313
+ expect(homeView).not.toContain('updateToken');
314
+ expect(homeView).not.toContain('Authorization');
315
+ }, 30000);
316
+
300
317
  it('index.html is at the Vite entry root and its mount target matches main.ts', async () => {
301
318
  await generator(host, options);
302
319
  // Vite's entry is <projectRoot>/index.html, not src/index.html — a template