@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 +1 -1
- package/src/generators/vue-app/files/AGENTS.md__tmpl__ +4 -3
- package/src/generators/vue-app/files/src/App.spec.ts__tmpl__ +44 -2
- package/src/generators/vue-app/files/src/App.vue__tmpl__ +13 -1
- package/src/generators/vue-app/files/src/main.ts__tmpl__ +10 -1
- package/src/generators/vue-app/files/src/stores/session.ts__tmpl__ +16 -0
- package/src/generators/vue-app/vue-app.spec.ts +31 -1
- package/src/generators/vue-components/files/src/lib/patterns/SessionExpiredBanner.vue__tmpl__ +8 -6
package/package.json
CHANGED
|
@@ -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
|
|
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, {
|
|
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, {
|
|
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(
|
|
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(
|
|
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
|
package/src/generators/vue-components/files/src/lib/patterns/SessionExpiredBanner.vue__tmpl__
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Presentational only —
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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 });
|