@abgov/nx-adsp 13.18.0-beta.1 → 13.18.0-beta.2

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.1",
3
+ "version": "13.18.0-beta.2",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -30,8 +30,9 @@ executors read options from `project.json` and do not prompt.
30
30
 
31
31
  | File | Purpose |
32
32
  |------|---------|
33
- | `src/main.ts` | Entry — registers Pinia, Router, and Keycloak plugin |
34
- | `src/App.vue` | Shell — `AppHeader` with sign-in/out in its `utilities` slot, hero banner, `<RouterView>` wrapped in `AppLayout`, `AppFooter` |
33
+ | `src/main.ts` | Entry — registers Pinia, Router, and Keycloak plugin (incl. `onAuthRefreshError`, which flags the session store as expired) |
34
+ | `src/App.vue` | Shell — `AppHeader` with sign-in/out in its `utilities` slot, `SessionExpiredBanner`, hero banner, `<RouterView>` wrapped in `AppLayout`, `AppFooter` |
35
+ | `src/stores/session.ts` | Pinia store — `expired` flag behind `SessionExpiredBanner`, set from `main.ts`'s `onAuthRefreshError` hook |
35
36
  | `src/router/index.ts` | Routes — `/protected` guarded with `requiresAuth` meta |
36
37
  | `src/views/HomeView.vue` | Public page — calls public and private APIs |
37
38
  | `src/views/ProtectedView.vue` | Authenticated page — shows user info from token |
@@ -168,7 +169,7 @@ across every Vue app in this workspace — fix or extend a wrapper once in
168
169
  | `AppHeader` | `goa-microsite-header` + `goa-app-header`. Takes a `heading` prop; put account/sign-in actions in its `utilities` slot (a bare child with no slot is silently dropped by `goa-app-header`) |
169
170
  | `AppLayout` | The content gutter every view renders inside (see the key files table). Also renders the skip-to-main-content link |
170
171
  | `AppFooter` | `goa-app-footer` with the standard GoA nav/meta links (Services/Contact/Terms of Use, Privacy/Disclaimer/Accessibility) |
171
- | `SessionExpiredBanner` | A `v-model:show` banner with `signIn`/`dismiss` emits available in the shared library, not yet wired into this app's `App.vue`. Wiring it to a real Keycloak session-expiry signal needs an `onAuthLogout` callback added to `main.ts`'s Keycloak plugin config; do that before using it |
172
+ | `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 |
172
173
 
173
174
  These are shared across every Vue app in this workspace — fix or extend one once
174
175
  in `libs/vue-components/src/lib/patterns/`, the same way as the `Goab*` wrappers.
@@ -16,8 +16,10 @@ vi.mock('@dsb-norge/vue-keycloak-js', async () => {
16
16
  });
17
17
 
18
18
  import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
19
+ import { createPinia } from 'pinia';
19
20
  import { createMemoryHistory, createRouter } from 'vue-router';
20
21
  import App from './App.vue';
22
+ import { useSessionStore } from './stores/session';
21
23
 
22
24
  // App.vue reads useRoute() for the layout-width feature (route.meta.layout,
23
25
  // default 'page') — that needs a real router installed via `global.plugins`,
@@ -35,7 +37,9 @@ const router = createRouter({
35
37
 
36
38
  describe('App shell header', () => {
37
39
  it('shows Sign in when unauthenticated', () => {
38
- const wrapper = mount(App, { global: { plugins: [router], stubs: { RouterView: true } } });
40
+ const wrapper = mount(App, {
41
+ global: { plugins: [router, createPinia()], stubs: { RouterView: true } },
42
+ });
39
43
  const group = wrapper.find('goa-button-group');
40
44
  expect(group.exists()).toBe(true);
41
45
  expect(group.html()).toContain('Sign in');
@@ -45,9 +49,47 @@ describe('App shell header', () => {
45
49
  // The bug: destructuring useKeycloak() froze `keycloak` at undefined, so
46
50
  // login() was a permanent no-op. Reactive access must reach the instance.
47
51
  const kc = useKeycloak();
48
- const wrapper = mount(App, { global: { plugins: [router], stubs: { RouterView: true } } });
52
+ const wrapper = mount(App, {
53
+ global: { plugins: [router, createPinia()], stubs: { RouterView: true } },
54
+ });
49
55
  const btn = wrapper.findAll('goa-button').find((b) => b.text().includes('Sign in'));
50
56
  await btn?.trigger('_click');
51
57
  expect(kc.keycloak?.login).toHaveBeenCalled();
52
58
  });
53
59
  });
60
+
61
+ describe('App shell session-expired banner', () => {
62
+ it('is hidden until the session store flags an expired session', async () => {
63
+ const pinia = createPinia();
64
+ const wrapper = mount(App, {
65
+ global: { plugins: [router, pinia], stubs: { RouterView: true } },
66
+ });
67
+ expect(wrapper.find('.session-expired-banner').exists()).toBe(false);
68
+
69
+ useSessionStore(pinia).markExpired();
70
+ await wrapper.vm.$nextTick();
71
+ expect(wrapper.find('.session-expired-banner').exists()).toBe(true);
72
+ });
73
+
74
+ it('Sign in on the banner dismisses it and calls keycloak.login', async () => {
75
+ const kc = useKeycloak();
76
+ const pinia = createPinia();
77
+ const wrapper = mount(App, {
78
+ global: { plugins: [router, pinia], stubs: { RouterView: true } },
79
+ });
80
+ useSessionStore(pinia).markExpired();
81
+ await wrapper.vm.$nextTick();
82
+
83
+ // SessionExpiredBanner's "Sign in" is the GoabButton wrapper, which re-exposes
84
+ // goa-button's `_click` as a plain `click` on itself -- but the underlying DOM
85
+ // node vue-test-utils sees is still the raw <goa-button>, so trigger `_click`
86
+ // (same as AppHeader's raw Sign in button above), not `click`.
87
+ const btn = wrapper
88
+ .findAll('goa-button')
89
+ .find((b) => b.text().includes('Sign in') && b.attributes('type') === 'primary');
90
+ await btn?.trigger('_click');
91
+
92
+ expect(kc.keycloak?.login).toHaveBeenCalled();
93
+ expect(useSessionStore(pinia).expired).toBe(false);
94
+ });
95
+ });
@@ -2,13 +2,15 @@
2
2
  import { computed } from 'vue';
3
3
  import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
4
4
  import { RouterView, useRoute } from 'vue-router';
5
- import { AppHeader, AppLayout, AppFooter } from '<%= goaImportPath %>';
5
+ import { AppHeader, AppLayout, AppFooter, SessionExpiredBanner } from '<%= goaImportPath %>';
6
+ import { useSessionStore } from './stores/session';
6
7
 
7
8
  // Keep the reactive instance — do NOT destructure. useKeycloak() returns a
8
9
  // readonly(reactive()); destructuring snapshots each field at setup time, so
9
10
  // `keycloak` would be frozen at its initial `undefined` (login() a no-op) and
10
11
  // `authenticated` would never flip. Access fields on `kc` to stay reactive.
11
12
  const kc = useKeycloak();
13
+ const session = useSessionStore();
12
14
 
13
15
  // Content width per view, from route meta (default 'page'). Views opt into a
14
16
  // different width with `meta: { layout: 'wide' | 'form' }` in the router.
@@ -19,6 +21,11 @@ const layout = computed(
19
21
 
20
22
  function login() { kc.keycloak?.login(); }
21
23
  function logout() { kc.keycloak?.logout({ redirectUri: window.location.origin }); }
24
+
25
+ function signInAgain() {
26
+ session.dismiss();
27
+ kc.keycloak?.login();
28
+ }
22
29
  </script>
23
30
 
24
31
  <template>
@@ -38,6 +45,11 @@ function logout() { kc.keycloak?.logout({ redirectUri: window.location.origin })
38
45
  </goa-button-group>
39
46
  </template>
40
47
  </AppHeader>
48
+ <SessionExpiredBanner
49
+ v-model:show="session.expired"
50
+ @sign-in="signInAgain"
51
+ @dismiss="session.dismiss"
52
+ />
41
53
  <goa-hero-banner heading="<%= projectName %>" backgroundurl="/assets/banner.jpg" />
42
54
  <AppLayout :variant="layout">
43
55
  <RouterView />
@@ -12,10 +12,12 @@ import keycloakPlugin from '@dsb-norge/vue-keycloak-js';
12
12
  import App from './App.vue';
13
13
  import router from './router';
14
14
  import { environment } from './environments/environment';
15
+ import { useSessionStore } from './stores/session';
15
16
 
16
17
  const app = createApp(App);
18
+ const pinia = createPinia();
17
19
 
18
- app.use(createPinia());
20
+ app.use(pinia);
19
21
  app.use(router);
20
22
  app.use(keycloakPlugin, {
21
23
  config: {
@@ -41,6 +43,13 @@ app.use(keycloakPlugin, {
41
43
  pkceMethod: 'S256',
42
44
  checkLoginIframe: false,
43
45
  },
46
+ // Not onAuthLogout: keycloak-js only invokes that via the session-status
47
+ // iframe (disabled above by checkLoginIframe: false) or in Cordova mode --
48
+ // it would never fire here. The wrapper's own background auto-refresh
49
+ // (autoRefreshToken, on by default) periodically calls updateToken();
50
+ // onAuthRefreshError fires when that fails because the refresh token itself
51
+ // has expired, which is the real, always-on "session expired" signal.
52
+ onAuthRefreshError: () => useSessionStore(pinia).markExpired(),
44
53
  });
45
54
 
46
55
  app.mount('#app');
@@ -0,0 +1,16 @@
1
+ import { defineStore } from 'pinia';
2
+
3
+ // Tracks whether the user's session has expired, surfaced via
4
+ // SessionExpiredBanner in App.vue. Set from main.ts's onAuthRefreshError hook
5
+ // -- see that file for why that hook, not onAuthLogout, is the real signal.
6
+ export const useSessionStore = defineStore('session', {
7
+ state: () => ({ expired: false }),
8
+ actions: {
9
+ markExpired() {
10
+ this.expired = true;
11
+ },
12
+ dismiss() {
13
+ this.expired = false;
14
+ },
15
+ },
16
+ });
@@ -178,11 +178,41 @@ describe('Vue App Generator', () => {
178
178
  }
179
179
 
180
180
  const app = host.read('apps/test/src/App.vue').toString();
181
- expect(app).toContain("import { AppHeader, AppLayout, AppFooter } from '@proj/vue-components';");
181
+ expect(app).toContain(
182
+ "import { AppHeader, AppLayout, AppFooter, SessionExpiredBanner } from '@proj/vue-components';",
183
+ );
182
184
  expect(app).toContain('<AppHeader heading="test">');
183
185
  expect(app).toContain('<AppFooter />');
184
186
  }, 30000);
185
187
 
188
+ it('wires SessionExpiredBanner to a session store fed by onAuthRefreshError (not onAuthLogout)', async () => {
189
+ await generator(host, options);
190
+
191
+ expect(host.exists('apps/test/src/stores/session.ts')).toBeTruthy();
192
+ const store = host.read('apps/test/src/stores/session.ts').toString();
193
+ expect(store).toContain("defineStore('session'");
194
+ expect(store).toContain('markExpired');
195
+
196
+ // Strip // comments — main.ts legitimately explains, in prose, why
197
+ // onAuthLogout is the wrong hook; assert against the actual init code.
198
+ const mainTsCode = host
199
+ .read('apps/test/src/main.ts')
200
+ .toString()
201
+ .split('\n')
202
+ .filter((l) => !l.trim().startsWith('//'))
203
+ .join('\n');
204
+ expect(mainTsCode).toContain('onAuthRefreshError');
205
+ // Regression guard: onAuthLogout only fires via the session-status iframe
206
+ // (disabled here) or Cordova mode — it would never fire in this app.
207
+ expect(mainTsCode).not.toContain('onAuthLogout');
208
+ expect(mainTsCode).toContain('useSessionStore(pinia).markExpired()');
209
+
210
+ const app = host.read('apps/test/src/App.vue').toString();
211
+ expect(app).toContain('v-model:show="session.expired"');
212
+ expect(app).toContain('@sign-in="signInAgain"');
213
+ expect(app).toContain('@dismiss="session.dismiss"');
214
+ }, 30000);
215
+
186
216
  it('inits Keycloak with no hidden iframes so init never hangs', async () => {
187
217
  await generator(host, options);
188
218
  // Strip // comments — they legitimately reference the disabled iframe options
@@ -1,10 +1,12 @@
1
1
  <script setup lang="ts">
2
- // Presentational only — not yet wired to a real Keycloak session-expiry signal
3
- // by any generator. @dsb-norge/vue-keycloak-js has no reactive "expired" field
4
- // on the useKeycloak() instance; the closest real hook is the `onAuthLogout`
5
- // callback configured at plugin-install time in main.ts. Wiring that up is a
6
- // separate change (new shared reactive state + main.ts plumbing); this
7
- // component just renders the banner once some caller flips `show`.
2
+ // Presentational only — flipping `show` is the consuming app's job, not this
3
+ // component's. vue-app wires it to its session store's `expired` flag, itself
4
+ // set from main.ts's Keycloak plugin `onAuthRefreshError` hook NOT
5
+ // `onAuthLogout`, which keycloak-js only fires via the session-status iframe
6
+ // (typically disabled, e.g. via checkLoginIframe: false) or Cordova mode, so
7
+ // it would never fire in a normal web deployment. onAuthRefreshError fires
8
+ // from the wrapper's own background auto-refresh failing because the refresh
9
+ // token itself expired — the real, always-on signal.
8
10
  import GoabButton from '../primitives/GoabButton.vue';
9
11
 
10
12
  const show = defineModel<boolean>('show', { default: false });