@abgov/nx-adsp 13.18.0-beta.9 → 13.18.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Nx plugin for bootstrapping [ADSP](https://govalta.github.io/adsp-monorepo/) applications for the Government of Alberta.
4
4
 
5
- The plugin provides generators for Node/Express services, React and Angular frontends, .NET services, and fullstack solutions. When `@abgov/nx-oc` is also installed in the workspace, OpenShift deployment YAML is automatically included in the generated output.
5
+ The plugin provides generators for Node/Express services, React, Angular, and Vue 3 frontends, .NET services, and fullstack solutions. When `@abgov/nx-oc` is also installed in the workspace, OpenShift deployment YAML is automatically included in the generated output.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -10,17 +10,18 @@ Install only the framework peers for the generators you use — they are declare
10
10
  as **optional** peer dependencies, so a workspace that only builds Vue apps
11
11
  doesn't need `@nx/react`, `@nx/angular`, etc.
12
12
 
13
- | Generator | Required peer dependency |
14
- | ------------------------------- | ---------------------------------------- |
15
- | `react-app` | `@nx/react` |
16
- | `angular-app` | `@nx/angular` |
17
- | `vue-app` | `@nx/vue` |
18
- | `dotnet-service` | `@nx-dotnet/core` |
19
- | `react-dotnet` | `@nx/react`, `@nx-dotnet/core` |
20
- | `express-service` | `@nx/express` |
21
- | `pevn`, `mevn` (full-stack) | `@nx/express`, `@nx/vue` |
22
- | `pern`, `pean` (full-stack) | `@nx/express`, `@nx/react`/`@nx/angular` |
23
- | `react-form`, `react-task-list` | existing React project in the workspace |
13
+ | Generator | Required peer dependency |
14
+ | ---------------------------------------------------------------------------- | ------------------------------------------- |
15
+ | `react-app` | `@nx/react` |
16
+ | `angular-app` | `@nx/angular` |
17
+ | `vue-app` | `@nx/vue` |
18
+ | `dotnet-service` | `@nx-dotnet/core` |
19
+ | `react-dotnet` | `@nx/react`, `@nx-dotnet/core` |
20
+ | `express-service` | `@nx/express` |
21
+ | `pevn`, `mevn` (full-stack) | `@nx/express`, `@nx/vue` |
22
+ | `pern`, `pean` (full-stack) | `@nx/express`, `@nx/react`/`@nx/angular` |
23
+ | `react-form`, `react-task-list` | existing React project in the workspace |
24
+ | `vue-detail-view`, `vue-workspace-view`, `vue-admin-crud`, `vue-intake-view` | existing `vue-app` project in the workspace |
24
25
 
25
26
  ## Installation
26
27
 
@@ -185,6 +186,30 @@ Accepts the same options as `react-app`.
185
186
 
186
187
  ---
187
188
 
189
+ ### `vue-app`
190
+
191
+ Creates a Vue 3 frontend application configured for ADSP, using GoA web components
192
+ (`@abgov/web-components`) and `@dsb-norge/vue-keycloak-js` for authentication. Requires
193
+ `@nx/vue`.
194
+
195
+ ```bash
196
+ npx nx g @abgov/nx-adsp:vue-app my-app --env dev --tenant my-tenant
197
+ ```
198
+
199
+ Accepts the same options as `react-app`, plus:
200
+
201
+ | Option | Required | Description |
202
+ | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
203
+ | `layout` | No | Top-level app shell: `header` (default) is a `goa-app-header` + hero banner + footer (public-facing); `internal` is a `goa-work-side-menu` shell with no header/banner/footer (staff-facing). Pair two runs against the same `--pairedProject` for a public+internal frontend pairing over one backend |
204
+
205
+ Every generated app provisions a shared `vue-components` library — `Goab*` `v-model` wrappers
206
+ over the design system plus reusable app-shell pattern components (`AppLayout`, `AppHeader`,
207
+ `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`) — see the generated app's own `AGENTS.md`
208
+ for the full contract. Four more generators retrofit common view shapes into an existing
209
+ `vue-app` project; see below.
210
+
211
+ ---
212
+
188
213
  ### `dotnet-service`
189
214
 
190
215
  Creates an ASP.NET Core backend service configured for ADSP. Requires `@nx-dotnet/core`.
@@ -241,6 +266,103 @@ Accepts the same options as `react-form`.
241
266
 
242
267
  ---
243
268
 
269
+ ### `vue-detail-view`
270
+
271
+ Adds a record-detail view (loading/error/loaded states, optional status badge, back button) to
272
+ an existing `vue-app` project, built on the shared `RecordDetailShell` pattern component.
273
+
274
+ ```bash
275
+ npx nx g @abgov/nx-adsp:vue-detail-view my-app --name=application-detail --resource=applications --route=/applications/:id --fields='[{"key":"status","label":"Status","type":"badge"}]'
276
+ ```
277
+
278
+ | Option | Required | Description |
279
+ | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
280
+ | `project` | Yes | The `vue-app` project to add the view to |
281
+ | `name` | Yes | View name, e.g. `application-detail` generates `src/views/ApplicationDetailView.vue` |
282
+ | `resource` | Yes | API resource path segment — the view fetches `/api/<resource>/:id` |
283
+ | `route` | Yes | Route path added to `router/index.ts`, e.g. `/applications/:id`. Must contain a `:id` param |
284
+ | `fields` | Yes | JSON array of fields rendered in the record's info list, in display order: `{ key, label, type?: "text"\|"date"\|"currency"\|"badge" }` — a JSON string, not a comma-separated CLI array (see note below) |
285
+ | `heading` | No | Page heading. Defaults to the view name, title-cased |
286
+ | `requiresAuth` | No | Whether the generated route requires authentication. Defaults to `true` |
287
+
288
+ ---
289
+
290
+ ### `vue-workspace-view`
291
+
292
+ Adds a staff-facing, paginated list view (a debounced search filter bar + sortable columns) to
293
+ an existing `vue-app` project, built on the shared `WorkspaceTable` pattern component.
294
+
295
+ ```bash
296
+ npx nx g @abgov/nx-adsp:vue-workspace-view my-app --name=applications --resource=applications --route=/applications --detailRoute=/applications --columns='[{"key":"status","label":"Status","type":"badge","sortable":true}]'
297
+ ```
298
+
299
+ | Option | Required | Description |
300
+ | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
301
+ | `project` | Yes | The `vue-app` project to add the view to |
302
+ | `name` | Yes | View name, e.g. `applications` generates `src/views/ApplicationsListView.vue` |
303
+ | `resource` | Yes | API resource path segment — fetches `/api/<resource>?page=&limit=&search=&sortBy=&sortDir=` |
304
+ | `route` | Yes | Route path added to `router/index.ts`, e.g. `/applications` |
305
+ | `columns` | Yes | JSON array of table columns, in display order: `{ key, label, type?: "text"\|"date"\|"currency"\|"badge", sortable? }` — a JSON string (see note below) |
306
+ | `detailRoute` | No | If set, each row gets a "View" action linking to `` `${detailRoute}/${row.id}` `` — typically a `vue-detail-view`'s route with the `:id` segment dropped |
307
+ | `filterable` | No | Whether to generate a debounced search input above the table. Defaults to `true` |
308
+ | `heading` | No | Page heading. Defaults to the view name, title-cased |
309
+ | `pageSize` | No | Rows per page. Defaults to `20` |
310
+ | `requiresAuth` | No | Whether the generated route requires authentication. Defaults to `true` |
311
+
312
+ ---
313
+
314
+ ### `vue-admin-crud`
315
+
316
+ Adds a simple admin CRUD screen pair (a `WorkspaceTable` list view with a Create action and
317
+ per-row Edit, plus a create/update Edit view) to an existing `vue-app` project — suited to small
318
+ lookup-table style admin screens, not large paginated workspaces (see `vue-workspace-view` for
319
+ that).
320
+
321
+ ```bash
322
+ npx nx g @abgov/nx-adsp:vue-admin-crud my-app --name=regions --resource=regions --route=/regions --fields='[{"key":"name","label":"Name"},{"key":"active","label":"Active","type":"checkbox"}]'
323
+ ```
324
+
325
+ | Option | Required | Description |
326
+ | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
327
+ | `project` | Yes | The `vue-app` project to add the views to |
328
+ | `name` | Yes | View name, e.g. `regions` generates `src/views/RegionsListView.vue` and `src/views/RegionsEditView.vue` |
329
+ | `resource` | Yes | API resource path segment — fetches `/api/<resource>` (list), `/api/<resource>/:id` (load one), `POST /api/<resource>` (create), `PUT /api/<resource>/:id` (update) |
330
+ | `route` | Yes | List route path added to `router/index.ts`, e.g. `/regions`. The edit/create route is added as `` `${route}/:id` `` (visiting `${route}/new` creates) |
331
+ | `fields` | Yes | JSON array of fields, in display/form order: `{ key, label, type?: "text"\|"checkbox", required? }` — a JSON string (see note below) |
332
+ | `heading` | No | List page heading. Defaults to the view name, title-cased |
333
+ | `singularLabel` | No | Singular label used in "Create <label>"/"Edit <label>" headings and buttons. Defaults to `--heading` (override for irregular plurals) |
334
+ | `requiresAuth` | No | Whether the generated routes require authentication. Defaults to `true` |
335
+
336
+ ---
337
+
338
+ ### `vue-intake-view`
339
+
340
+ Adds a route-per-step intake wizard (`Stepper` + `StepErrorSummary`, a required read-only
341
+ review step, and a confirmation page) to an existing `vue-app` project. Cross-step state is
342
+ server-persisted — each step PUTs/POSTs to `/api/<resource>/:id` and refetches on mount, so
343
+ there's no client-side draft caching. Every field is currently a plain text input.
344
+
345
+ ```bash
346
+ npx nx g @abgov/nx-adsp:vue-intake-view my-app --name=application --resource=applications --route=/applications --steps='[{"key":"personal-info","label":"Personal information","fields":[{"key":"fullName","label":"Full name"}]}]'
347
+ ```
348
+
349
+ | Option | Required | Description |
350
+ | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
351
+ | `project` | Yes | The `vue-app` project to add the views to |
352
+ | `name` | Yes | Base name for the generated views, e.g. `application` generates `<Step>StepView.vue` per step plus `ApplicationReviewView.vue`/`ApplicationConfirmationView.vue` |
353
+ | `resource` | Yes | API resource path segment. Each step fetches/saves `/api/<resource>/:id`; the review step's Submit posts `/api/<resource>/:id/submit` |
354
+ | `route` | Yes | Base route, e.g. `/applications`. Steps become `/applications/:id/<step-key>`, plus `/review` and `/confirmation`. Start a new intake at `/applications/new/<first-step-key>` |
355
+ | `steps` | Yes | JSON array of steps, in order: `{ key, label, fields: [{ key, label, required? }] }` — a JSON string (see note below) |
356
+ | `requiresAuth` | No | Whether the generated routes require authentication. Defaults to `true` |
357
+
358
+ ---
359
+
360
+ > **Note on JSON-string options (`fields`/`columns`/`steps`):** Nx's own CLI arg coercion only
361
+ > supports comma-separated primitive lists for `array`-typed schema options, not JSON — a JSON
362
+ > string is the only CLI syntax that survives it, so these options are declared as `string` and
363
+ > parsed internally. A plain array also works when the generator is invoked programmatically
364
+ > (e.g. from a script), bypassing CLI parsing entirely.
365
+
244
366
  ## Authentication
245
367
 
246
368
  Most generators call ADSP APIs during generation, which needs an ADSP access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.18.0-beta.9",
3
+ "version": "13.18.0",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -34,11 +34,15 @@ executors read options from `project.json` and do not prompt.
34
34
  <% if (layout === 'internal') { %>
35
35
  | `src/App.vue` | Shell — `AppSideMenu` (side-menu shell, Sign in/out as an account item), `SessionExpiredBanner`, `<RouterView>` wrapped in `AppLayout` |
36
36
  <% } else { %>
37
- | `src/App.vue` | Shell — `AppHeader` with sign-in/out in its `utilities` slot, `SessionExpiredBanner`, hero banner, `<RouterView>` wrapped in `AppLayout`, `AppFooter` |
37
+ | `src/App.vue` | Shell — `AppHeader` with sign-in/out in its `utilities` slot, `SessionExpiredBanner`, `<RouterView>` wrapped in `AppLayout`, `AppFooter` |
38
38
  <% } %>
39
39
  | `src/stores/session.ts` | Pinia store — `expired` flag behind `SessionExpiredBanner`, set from `main.ts`'s `onAuthRefreshError` hook |
40
40
  | `src/router/index.ts` | Routes — `/protected` guarded with `requiresAuth` meta |
41
+ <% if (layout === 'internal') { %>
41
42
  | `src/views/HomeView.vue` | Public page — calls public and private APIs |
43
+ <% } else { %>
44
+ | `src/views/HomeView.vue` | Public page — hero banner (page content, not app-shell — real GoA services show it once on the landing page, not on every route) + calls public and private APIs |
45
+ <% } %>
42
46
  | `src/views/ProtectedView.vue` | Authenticated page — shows user info from token |
43
47
  | `src/environments/environment.ts` | Access URL, realm, client ID — pre-set from ADSP tenant |
44
48
  | `vite.config.ts` | Vite config — `isCustomElement` marks `goa-*` as web components |
@@ -172,7 +176,7 @@ from **`<%= goaImportPath %>`**, not hand-rolled markup:
172
176
 
173
177
  | Component | Purpose |
174
178
  |---|---|
175
- | `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 |
176
180
  | `AppLayout` | The content gutter every view renders inside (see the key files table) |
177
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 |
178
182
 
@@ -230,20 +234,105 @@ mark what's meant to be edited.
230
234
  { path: '/queue', component: () => import('../views/QueueView.vue'), meta: { layout: 'wide' } }
231
235
  { path: '/apply', component: () => import('../views/ApplyView.vue'), meta: { layout: 'form' } }
232
236
  ```
233
- 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.
234
274
 
235
275
  ## Backend API calls (proxy setup)
236
276
 
237
- 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.
238
282
 
239
283
  ```typescript
240
- // correct
241
- const res = await fetch('/api/v1/my-resource');
284
+ import { useApi } from '../composables/useApi' // adjust depth for nested components (../../…)
242
285
 
243
- // wrong bypasses proxy, won't work in production
244
- const res = await fetch('http://localhost:3333/my-service/v1/my-resource');
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')
290
+
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
+ )
245
331
  ```
246
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
+
247
336
  ## Testing
248
337
 
249
338
  Tests live alongside source files (`*.spec.ts` / `*.spec.vue`) and run with Vitest:
@@ -86,7 +86,6 @@ function onAccountItemClick() {
86
86
  @sign-in="signInAgain"
87
87
  @dismiss="session.dismiss"
88
88
  />
89
- <goa-hero-banner heading="<%= projectName %>" backgroundurl="/assets/banner.jpg" />
90
89
  <main id="main-content">
91
90
  <AppLayout :variant="contentWidth">
92
91
  <RouterView />
@@ -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,47 +1,50 @@
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>
44
43
  <section>
44
+ <% if (layout !== 'internal') { %>
45
+ <goa-hero-banner heading="<%= projectName %>" backgroundurl="/assets/banner.jpg" />
46
+ <goa-spacer vspacing="l" />
47
+ <% } %>
45
48
  <h2>Welcome to <%= projectName %>!</h2>
46
49
  <p>Don't panic. Start editing the project to build your digital service.</p>
47
50
  <h3>A few things you might want to do next:</h3>
@@ -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
@@ -313,17 +330,35 @@ describe('Vue App Generator', () => {
313
330
 
314
331
  it('static assets live in public/ so Vite serves them at the referenced URLs', async () => {
315
332
  await generator(host, options);
316
- // App.vue's <goa-hero-banner backgroundurl="/assets/banner.jpg"> and
333
+ // HomeView's <goa-hero-banner backgroundurl="/assets/banner.jpg"> and
317
334
  // index.html's favicon.ico are absolute-URL string refs, so they must be in
318
335
  // the Vite publicDir (public/) — a src/assets file is not served at /assets.
319
- const appVue = host.read('apps/test/src/App.vue').toString();
320
- const bannerUrl = appVue.match(/backgroundurl="([^"]+)"/)?.[1];
336
+ const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
337
+ const bannerUrl = homeView.match(/backgroundurl="([^"]+)"/)?.[1];
321
338
  expect(bannerUrl).toBe('/assets/banner.jpg');
322
339
  expect(host.exists('apps/test/public/assets/banner.jpg')).toBeTruthy();
323
340
  expect(host.exists('apps/test/src/assets/banner.jpg')).toBeFalsy();
324
341
  expect(host.exists('apps/test/public/favicon.ico')).toBeTruthy();
325
342
  }, 30000);
326
343
 
344
+ it('the hero banner is home-page content, not part of the persistent app shell', async () => {
345
+ // Real GovAlta-Pronghorn source (both the canonical template and an
346
+ // independently-evolved production app) puts goa-hero-banner only inside
347
+ // HomeView -- never in App.vue/AppLayout -- so it shows once on the
348
+ // landing page, not repeated on every interior route.
349
+ await generator(host, options);
350
+ const app = host.read('apps/test/src/App.vue').toString();
351
+ expect(app).not.toContain('goa-hero-banner');
352
+ const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
353
+ expect(homeView).toContain('goa-hero-banner');
354
+ }, 30000);
355
+
356
+ it('--layout=internal has no hero banner anywhere, including on HomeView', async () => {
357
+ await generator(host, { ...options, layout: 'internal' });
358
+ const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
359
+ expect(homeView).not.toContain('goa-hero-banner');
360
+ }, 30000);
361
+
327
362
  it('vite.config.ts marks goa-* elements as custom elements', async () => {
328
363
  await generator(host, options);
329
364
  const viteConfig = host.read('apps/test/vite.config.ts').toString();