@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
|
@@ -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.
|
|
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
|
-
//
|
|
245
|
-
|
|
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
|
-
//
|
|
248
|
-
const res = await
|
|
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
|
|
7
|
-
//
|
|
8
|
-
const kc = useKeycloak()
|
|
9
|
-
const
|
|
10
|
-
const
|
|
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
|
|
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
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|