@sbc-connect/nuxt-auth 0.1.7 → 0.1.9

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/.env.example CHANGED
@@ -22,4 +22,10 @@ NUXT_PUBLIC_LD_CLIENT_ID=""
22
22
  NUXT_PUBLIC_IDP_URL="https://dev.loginproxy.gov.bc.ca/auth"
23
23
  NUXT_PUBLIC_IDP_REALM="bcregistry"
24
24
  NUXT_PUBLIC_IDP_CLIENTID="connect-web"
25
- NUXT_PUBLIC_SITEMINDER_LOGOUT_URL="https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi"
25
+ NUXT_PUBLIC_SITEMINDER_LOGOUT_URL="https://logontest7.gov.bc.ca/clp-cgi/logoff.cgi"
26
+
27
+ # Session Timeout
28
+ NUXT_PUBLIC_TOKEN_REFRESH_INTERVAL=30000
29
+ NUXT_PUBLIC_TOKEN_MIN_VALIDITY=120000
30
+ NUXT_PUBLIC_SESSION_INACTIVITY_TIMEOUT=1800000
31
+ NUXT_PUBLIC_SESSION_MODAL_TIMEOUT=120000
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @sbc-connect/nuxt-auth
2
2
 
3
+ ## 0.1.9
4
+
5
+ ### Patch Changes
6
+
7
+ - [#40](https://github.com/bcgov/connect-nuxt/pull/40) [`f4b62e1`](https://github.com/bcgov/connect-nuxt/commit/f4b62e19570ed062399ce7d23ce07abcf682285f) Thanks [@deetz99](https://github.com/deetz99)! - Add login page and auth middleware issue: bcgov/entity#29335
8
+
9
+ - [#38](https://github.com/bcgov/connect-nuxt/pull/38) [`8b8f067`](https://github.com/bcgov/connect-nuxt/commit/8b8f067aba4cda2cd2cd8de5c6f74ccc24eaf822) Thanks [@deetz99](https://github.com/deetz99)! - Add LaunchDarkly composable with user context. issue: bcgov/entity#29335
10
+
11
+ ## 0.1.8
12
+
13
+ ### Patch Changes
14
+
15
+ - [#34](https://github.com/bcgov/connect-nuxt/pull/34) [`b2a0458`](https://github.com/bcgov/connect-nuxt/commit/b2a04587d5408d213d463ef6161b701ca597ef86) Thanks [@deetz99](https://github.com/deetz99)! - - handle user session expiry in auth plugin
16
+
17
+ - onBeforeSessionExpiry route meta
18
+ - onAccountChange route meta
19
+ - resetPiniaStores util and reset on logout
20
+ - add account id to layouts/ConnectAuth breadcrumb slot
21
+
22
+ issue: bcgov/entity#29335
23
+
24
+ - [#32](https://github.com/bcgov/connect-nuxt/pull/32) [`66d83d1`](https://github.com/bcgov/connect-nuxt/commit/66d83d14b2ec7950057dd39a4d876a8c4096923f) Thanks [@kialj876](https://github.com/kialj876)! - Pay layer cleanup bcgov/entity#29337
25
+
26
+ - Updated dependencies [[`66d83d1`](https://github.com/bcgov/connect-nuxt/commit/66d83d14b2ec7950057dd39a4d876a8c4096923f)]:
27
+ - @sbc-connect/nuxt-base@0.1.8
28
+
3
29
  ## 0.1.7
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -16,7 +16,7 @@ This package provides the necessary composables, plugins, and logic to integrate
16
16
  - login() and logout() helper functions.
17
17
  - Automatic token refreshing and session validation.
18
18
 
19
- For detailed usage and documentation, please see the [Auth Layer Docs](../../../docs/packages/layers/auth/intro.md).
19
+ For detailed usage and documentation, please see the [Auth Layer Docs](../../../docs/packages/layers/auth/overview.md).
20
20
 
21
21
  ## Usage
22
22
 
@@ -46,14 +46,7 @@ Create a file named .env in the root of the project.
46
46
  Copy the contents of the .env.example file into your new .env file.
47
47
 
48
48
  ### Local Development
49
- For local development, you will need credentials for the development instance of the authentication provider.
50
-
51
- ```
52
- # .env
53
- NUXT_PUBLIC_AUTH_PROVIDER_URL="https://dev.oidc.gov.bc.ca/auth"
54
- NUXT_PUBLIC_AUTH_PROVIDER_REALM="your-realm-name"
55
- NUXT_PUBLIC_AUTH_PROVIDER_CLIENT_ID="your-client-id"
56
- ```
49
+ Copy the contents of the **.env.example** file into your new .env file.
57
50
 
58
51
  ### Production Environments
59
52
  > [!IMPORTANT]
package/app/app.config.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  export default defineAppConfig({
2
2
  connect: {
3
3
  login: {
4
- redirectPath: '',
4
+ redirect: '',
5
5
  idps: ['bcsc', 'bceid', 'idir']
6
6
  },
7
+ logout: {
8
+ redirect: ''
9
+ },
7
10
  header: {
8
11
  loginMenu: true,
9
12
  createAccount: true,
@@ -26,7 +26,7 @@ defineProps({
26
26
  </span>
27
27
  <span
28
28
  class="line-clamp-1 overflow-hidden text-ellipsis text-xs opacity-75"
29
- :class="{ 'text-line': theme === 'header', 'text-neutral': theme === 'dropdown' }"
29
+ :class="{ 'text-line-muted': theme === 'header', 'text-neutral': theme === 'dropdown' }"
30
30
  >
31
31
  {{ accountName }}
32
32
  </span>
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ const isSmallScreen = useMediaQuery('(max-width: 640px)')
3
+ const rtc = useRuntimeConfig().public
4
+ const modalTimeout = rtc.sessionModalTimeout ? Number(rtc.sessionModalTimeout) : 120000
5
+ const { t } = useI18n()
6
+ const route = useRoute()
7
+
8
+ const emit = defineEmits<{ close: [] }>()
9
+
10
+ const timeRemaining = ref(toValue((modalTimeout) / 1000))
11
+
12
+ const intervalId = setInterval(async () => {
13
+ const value = timeRemaining.value - 1
14
+ timeRemaining.value = value < 0 ? 0 : value
15
+
16
+ if (value === 0) {
17
+ if (route.meta.onBeforeSessionExpired) {
18
+ await route.meta.onBeforeSessionExpired()
19
+ }
20
+ sessionStorage.setItem(ConnectAuthStorageKey.CONNECT_SESSION_EXPIRED, 'true')
21
+ await useConnectAuth().logout()
22
+ }
23
+ }, 1000)
24
+
25
+ const ariaCountdownText = computed(() => {
26
+ if (timeRemaining.value === 30) { // trigger aria alert when 30 seconds remain
27
+ return t('connect.sessionExpiry.content', { count: timeRemaining.value })
28
+ } else if (timeRemaining.value === 2) { // trigger aria alert when session expires
29
+ return t('connect.sessionExpiry.sessionExpired')
30
+ } else {
31
+ return ''
32
+ }
33
+ })
34
+
35
+ function closeModal() {
36
+ clearInterval(intervalId)
37
+ emit('close')
38
+ }
39
+
40
+ onMounted(() => {
41
+ // allow any keypress to close the modal
42
+ window.addEventListener('keydown', closeModal)
43
+ })
44
+
45
+ onUnmounted(() => {
46
+ // cleanup
47
+ window.removeEventListener('keydown', closeModal)
48
+ })
49
+ </script>
50
+
51
+ <template>
52
+ <UModal
53
+ id="session-expired-dialog"
54
+ overlay
55
+ :title="$t('connect.sessionExpiry.title')"
56
+ :description="$t('connect.sessionExpiry.content', { count: timeRemaining })"
57
+ @after:leave="closeModal"
58
+ >
59
+ <template #content>
60
+ <div class="p-10 flex flex-col gap-6">
61
+ <div role="alert">
62
+ <h2
63
+ id="session-expired-dialog-title"
64
+ class="text-xl font-bold text-neutral-highlighted"
65
+ >
66
+ {{ $t('connect.sessionExpiry.title') }}
67
+ </h2>
68
+ </div>
69
+ <div>
70
+ <ConnectI18nHelper
71
+ id="session-expired-dialog-description"
72
+ translation-path="connect.sessionExpiry.content"
73
+ :count="timeRemaining"
74
+ />
75
+
76
+ <div role="alert">
77
+ <span class="sr-only">{{ ariaCountdownText }}</span>
78
+ </div>
79
+ </div>
80
+ <div class="flex flex-wrap items-center justify-center gap-4">
81
+ <UButton
82
+ :block="isSmallScreen"
83
+ :label="$t('connect.sessionExpiry.continueBtn.main')"
84
+ :aria-label="$t('connect.sessionExpiry.continueBtn.aria')"
85
+ size="xl"
86
+ class="font-bold"
87
+ @click="closeModal"
88
+ />
89
+ </div>
90
+ </div>
91
+ </template>
92
+ </UModal>
93
+ </template>
@@ -9,8 +9,7 @@ export const useConnectAuth = () => {
9
9
  * @returns A promise that resolves when login is complete.
10
10
  */
11
11
  function login(idpHint: ConnectIdpHint, redirect?: string): Promise<void> {
12
- const loginRedirectUrl = sessionStorage.getItem(ConnectAuthStorageKeys.LOGIN_REDIRECT_URL)
13
- const redirectUri = redirect ?? loginRedirectUrl ?? window.location.href
12
+ const redirectUri = redirect ?? window.location.href
14
13
 
15
14
  return $connectAuth.login(
16
15
  {
@@ -27,14 +26,13 @@ export const useConnectAuth = () => {
27
26
  */
28
27
  function logout(redirect?: string): Promise<void> {
29
28
  const siteminderUrl = rtc.siteminderLogoutUrl
30
- const logoutRedirectUrl = sessionStorage.getItem(ConnectAuthStorageKeys.LOGOUT_REDIRECT_URL)
31
- let redirectUri = redirect ?? logoutRedirectUrl ?? window.location.href
29
+ let redirectUri = redirect ?? window.location.href
32
30
 
33
31
  if (siteminderUrl) {
34
32
  redirectUri = `${siteminderUrl}?returl=${redirectUri.replace(/(https?:\/\/)|(\/)+/g, '$1$2')}&retnow=1`
35
33
  }
36
34
 
37
- // resetPiniaStores()
35
+ resetPiniaStores()
38
36
  return $connectAuth.logout({
39
37
  redirectUri
40
38
  })
@@ -81,30 +79,10 @@ export const useConnectAuth = () => {
81
79
  })
82
80
  }
83
81
 
84
- function setLoginRedirectUrl(url: string) {
85
- sessionStorage.setItem(ConnectAuthStorageKeys.LOGIN_REDIRECT_URL, url)
86
- }
87
-
88
- function setLogoutRedirectUrl(url: string) {
89
- sessionStorage.setItem(ConnectAuthStorageKeys.LOGOUT_REDIRECT_URL, url)
90
- }
91
-
92
- function clearLoginRedirectUrl() {
93
- sessionStorage.removeItem(ConnectAuthStorageKeys.LOGIN_REDIRECT_URL)
94
- }
95
-
96
- function clearLogoutRedirectUrl() {
97
- sessionStorage.removeItem(ConnectAuthStorageKeys.LOGOUT_REDIRECT_URL)
98
- }
99
-
100
82
  return {
101
83
  login,
102
84
  logout,
103
85
  getToken,
104
- clearLoginRedirectUrl,
105
- clearLogoutRedirectUrl,
106
- setLoginRedirectUrl,
107
- setLogoutRedirectUrl,
108
86
  isAuthenticated,
109
87
  authUser
110
88
  }
@@ -11,8 +11,9 @@ export function useConnectHeaderOptions() {
11
11
  const route = useRoute()
12
12
  const overlay = useOverlay()
13
13
  const { t, locale: { value: locale } } = useNuxtApp().$i18n
14
- const { login, logout, isAuthenticated, authUser } = useConnectAuth()
14
+ const { login, isAuthenticated, authUser } = useConnectAuth()
15
15
  const accountStore = useConnectAccountStore()
16
+ const localePath = useLocalePath()
16
17
 
17
18
  const whatsNew = useStorage<ConnectWhatsNewState>('connect-whats-new', { viewed: false, items: [] })
18
19
  const slideover = overlay.create(ConnectSlideoverWhatsNew)
@@ -38,7 +39,7 @@ export function useConnectHeaderOptions() {
38
39
  options.push({
39
40
  label: t('connect.label.logout'),
40
41
  icon: 'i-mdi-logout-variant',
41
- onSelect: () => logout()
42
+ onSelect: () => navigateTo(localePath('/auth/logout'))
42
43
  })
43
44
  return options
44
45
  })
@@ -87,9 +88,7 @@ export function useConnectHeaderOptions() {
87
88
  onSelect: () => {
88
89
  if (!isActive && account.id) {
89
90
  if (route.meta.onAccountChange) {
90
- // TODO: add route meta option
91
- const allowAccountChange = true
92
- // const allowAccountChange = route.meta.onAccountChange(accountStore.currentAccount, account)
91
+ const allowAccountChange = route.meta.onAccountChange(accountStore.currentAccount, account)
93
92
  if (allowAccountChange) {
94
93
  accountStore.switchCurrentAccount(account.id)
95
94
  }
@@ -136,8 +135,8 @@ export function useConnectHeaderOptions() {
136
135
  return options
137
136
  })
138
137
 
139
- const loginRedirectUrl = ac.login.redirectPath
140
- ? appBaseUrl + locale + ac.login.redirectPath
138
+ const loginRedirectUrl = ac.login.redirect
139
+ ? appBaseUrl + locale + ac.login.redirect
141
140
  : undefined
142
141
 
143
142
  const loginOptionsMap: Record<'bcsc' | 'bceid' | 'idir',
@@ -0,0 +1,245 @@
1
+ import { initialize } from 'launchdarkly-js-client-sdk'
2
+ import type { LDClient, LDFlagSet, LDOptions, LDMultiKindContext } from 'launchdarkly-js-client-sdk'
3
+ import { isEqual } from 'es-toolkit'
4
+
5
+ // default anon context
6
+ const anonymousContext: LDMultiKindContext = {
7
+ kind: 'multi',
8
+ org: { key: 'anonymous' },
9
+ user: { key: 'anonymous' }
10
+ }
11
+
12
+ // state
13
+ // Defined outside of composable, so they are created only once and shared
14
+ const ldClient = shallowRef<LDClient | null>(null)
15
+ const ldFlagSet = shallowRef<LDFlagSet>({})
16
+ const ldContext = shallowRef<LDMultiKindContext>(anonymousContext)
17
+ const ldInitialized = ref(false)
18
+ const isInitializing = ref(false)
19
+
20
+ function _createLdContext(): LDMultiKindContext {
21
+ const appName = useRuntimeConfig().public.appName
22
+ const { authUser, isAuthenticated } = useConnectAuth()
23
+ const account = useConnectAccountStore().currentAccount
24
+
25
+ if (!isAuthenticated.value) {
26
+ return anonymousContext
27
+ }
28
+
29
+ // Create user context
30
+ const user = {
31
+ key: authUser.value.keycloakGuid,
32
+ firstName: authUser.value.firstName,
33
+ lastName: authUser.value.lastName,
34
+ email: authUser.value.email,
35
+ roles: authUser.value.roles,
36
+ loginSource: authUser.value.loginSource,
37
+ appSource: appName
38
+ }
39
+
40
+ // Default org to user key if no account
41
+ let org: Partial<ConnectAccount & { key: string, appSource: string }> = { key: user.key, appSource: appName }
42
+
43
+ // Use account info if available
44
+ if (account.id) {
45
+ org = {
46
+ key: String(account.id),
47
+ accountType: account.accountType,
48
+ accountStatus: account.accountStatus,
49
+ type: account.type,
50
+ label: account.label,
51
+ appSource: appName
52
+ }
53
+ }
54
+
55
+ return { kind: 'multi', org, user }
56
+ }
57
+
58
+ function _updateLdContext() {
59
+ if (!ldClient.value) {
60
+ return
61
+ }
62
+
63
+ const newContext = _createLdContext()
64
+ if (isEqual(ldContext.value, newContext)) {
65
+ return
66
+ }
67
+
68
+ ldContext.value = newContext
69
+ ldClient.value.identify(newContext).then(() => {
70
+ ldFlagSet.value = ldClient.value?.allFlags() || {}
71
+ }).catch((error) => {
72
+ console.error('LaunchDarkly: Failed to update context.', error)
73
+ })
74
+ }
75
+
76
+ /**
77
+ * Initializes the LaunchDarkly client.
78
+ */
79
+ function _init(): void {
80
+ const rtc = useRuntimeConfig().public
81
+
82
+ // Prevent re-initialization
83
+ if (ldInitialized.value || isInitializing.value) {
84
+ return
85
+ }
86
+ // Prevent initialization if missing client ID
87
+ if (!rtc.ldClientId) {
88
+ console.error('LaunchDarkly: ldClientId is not configured.')
89
+ return
90
+ }
91
+
92
+ isInitializing.value = true
93
+
94
+ ldContext.value = _createLdContext()
95
+
96
+ const options: LDOptions = {
97
+ streaming: false,
98
+ useReport: false,
99
+ diagnosticOptOut: true
100
+ }
101
+
102
+ try {
103
+ ldClient.value = initialize(rtc.ldClientId, ldContext.value, options)
104
+
105
+ ldClient.value.on('initialized', () => {
106
+ ldInitialized.value = true
107
+ isInitializing.value = false
108
+ ldFlagSet.value = ldClient.value?.allFlags() || {}
109
+ console.info('LaunchDarkly: Anonymous initialization complete.')
110
+ })
111
+
112
+ ldClient.value.on('error', (error) => {
113
+ console.error('LaunchDarkly: Initialization error.', error)
114
+ isInitializing.value = false
115
+ })
116
+ } catch (error) {
117
+ console.error('LaunchDarkly: Failed to initialize.', error)
118
+ isInitializing.value = false
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Composable for the LaunchDarkly service.
124
+ */
125
+ export const useConnectLaunchDarkly = () => {
126
+ // initialize only once
127
+ if (!ldInitialized.value && !isInitializing.value && import.meta.client) {
128
+ _init()
129
+ }
130
+
131
+ const { isAuthenticated } = useConnectAuth()
132
+ const accountStore = useConnectAccountStore()
133
+
134
+ watch(
135
+ [isAuthenticated, () => accountStore.currentAccount],
136
+ () => {
137
+ _updateLdContext()
138
+ },
139
+ { immediate: true }
140
+ )
141
+
142
+ /**
143
+ * Returns a flag's value. Can operate in two modes.
144
+ * @param name The name of the feature flag.
145
+ * @param defaultValue The value to use until the flag is loaded.
146
+ * @param mode - 'reactive' (default) returns a ref that updates automatically.
147
+ * 'await' returns a promise that resolves when the client is ready.
148
+ * @returns A readonly ref or a promise resolving to the flag's value.
149
+ */
150
+ function getFeatureFlag<T>(
151
+ name: string,
152
+ defaultValue: T,
153
+ mode: 'await'
154
+ ): Promise<T>
155
+ function getFeatureFlag<T>(
156
+ name: string,
157
+ defaultValue: T,
158
+ mode?: 'reactive'
159
+ ): Readonly<Ref<T>>
160
+ function getFeatureFlag<T>(
161
+ name: string,
162
+ defaultValue?: T,
163
+ mode?: 'reactive'
164
+ ): Readonly<Ref<T | undefined>>
165
+ function getFeatureFlag<T>(
166
+ name: string,
167
+ defaultValue?: T,
168
+ mode: 'reactive' | 'await' = 'reactive'
169
+ ): Readonly<Ref<T | undefined>> | Promise<T | undefined> {
170
+ if (mode === 'await') {
171
+ if (!ldClient.value) {
172
+ return Promise.resolve(defaultValue)
173
+ }
174
+ return ldClient.value.waitUntilReady()
175
+ .then(() => ldClient.value ? ldClient.value.variation(name, defaultValue) : defaultValue)
176
+ .catch((error) => {
177
+ console.error(`LaunchDarkly: Error waiting for client while getting flag "${name}".`, error)
178
+ return defaultValue
179
+ })
180
+ }
181
+
182
+ return readonly(computed(() => {
183
+ if (!ldClient.value || !ldInitialized.value || !ldFlagSet.value) {
184
+ return defaultValue
185
+ }
186
+ return ldClient.value.variation(name, defaultValue)
187
+ }))
188
+ }
189
+
190
+ /**
191
+ * Returns a flag's value from the locally stored flag set. Can operate in two modes.
192
+ * @param name The name of the feature flag.
193
+ * @param defaultValue The value to use until the flag is loaded.
194
+ * @param mode - 'reactive' (default) returns a ref that updates automatically.
195
+ * 'await' returns a promise that resolves when the client is ready.
196
+ * @returns A readonly ref or a promise resolving to the flag's value.
197
+ */
198
+ async function getStoredFlag<T>(name: string, defaultValue: T, mode: 'await'): Promise<T>
199
+ function getStoredFlag<T>(name: string, defaultValue?: T, mode?: 'reactive'): Readonly<Ref<T>>
200
+ function getStoredFlag<T>(
201
+ name: string,
202
+ defaultValue: T,
203
+ mode: 'reactive' | 'await' = 'reactive'
204
+ ): Readonly<Ref<T>> | Promise<T> {
205
+ if (mode === 'await') {
206
+ if (!ldClient.value) {
207
+ return Promise.resolve(defaultValue)
208
+ }
209
+ return ldClient.value.waitUntilReady()
210
+ .then(() => ldFlagSet.value[name] ?? defaultValue)
211
+ .catch((error) => {
212
+ console.error(`LaunchDarkly: Error waiting for client while getting stored flag "${name}".`, error)
213
+ return defaultValue
214
+ })
215
+ }
216
+
217
+ // reactive mode
218
+ return readonly(computed(() => {
219
+ if (!ldInitialized.value || !ldFlagSet.value) {
220
+ return defaultValue
221
+ }
222
+ return ldFlagSet.value[name] ?? defaultValue
223
+ }))
224
+ }
225
+
226
+ /**
227
+ * Resets the LaunchDarkly state and closes the client connection.
228
+ */
229
+ const $reset = () => {
230
+ ldInitialized.value = false
231
+ isInitializing.value = false
232
+ ldClient.value?.close()
233
+ ldClient.value = null
234
+ ldFlagSet.value = {}
235
+ }
236
+
237
+ return {
238
+ getFeatureFlag,
239
+ getStoredFlag,
240
+ ldInitialized: readonly(ldInitialized),
241
+ ldFlagSet: readonly(ldFlagSet),
242
+ ldClient: readonly(ldClient),
243
+ $reset
244
+ }
245
+ }
@@ -0,0 +1,3 @@
1
+ export enum ConnectAuthStorageKey {
2
+ CONNECT_SESSION_EXPIRED = 'connect-session-expired'
3
+ }
@@ -1,8 +1,19 @@
1
+ <script setup lang="ts">
2
+ const accountStore = useConnectAccountStore()
3
+ </script>
4
+
1
5
  <template>
2
6
  <ConnectLayout>
3
7
  <template #header>
4
8
  <ConnectHeaderAuth />
5
9
  </template>
10
+ <template #breadcrumb>
11
+ <ConnectBreadcrumb
12
+ :account-id="accountStore.currentAccount.id
13
+ ? String(accountStore.currentAccount.id)
14
+ : undefined"
15
+ />
16
+ </template>
6
17
  <slot />
7
18
  </ConnectLayout>
8
19
  </template>
@@ -0,0 +1,9 @@
1
+ export default defineNuxtRouteMiddleware((to) => {
2
+ const { isAuthenticated } = useConnectAuth()
3
+ const rtc = useRuntimeConfig().public
4
+ const localePath = useLocalePath()
5
+
6
+ if (!isAuthenticated.value) {
7
+ return navigateTo(localePath(`/auth/login?return=${rtc.baseUrl}${to.fullPath.slice(1)}`))
8
+ }
9
+ })
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ import loginImage from '#auth/public/img/BCReg_Generic_Login_image.jpg'
3
+
4
+ const { t, locale } = useI18n()
5
+ const { login } = useConnectAuth()
6
+ const rtc = useRuntimeConfig().public
7
+ const ac = useAppConfig().connect
8
+ const route = useRoute()
9
+
10
+ useHead({
11
+ title: t('connect.page.login.title')
12
+ })
13
+
14
+ definePageMeta({
15
+ layout: 'connect-auth',
16
+ hideBreadcrumbs: true,
17
+ middleware: async (to) => {
18
+ const { $connectAuth, $router, _appConfig } = useNuxtApp()
19
+ if ($connectAuth.authenticated) {
20
+ if (to.query.return) {
21
+ window.location.replace(to.query.return as string)
22
+ return
23
+ }
24
+ await $router.push(_appConfig.connect.login.redirect || '/')
25
+ }
26
+ }
27
+ })
28
+
29
+ const isSessionExpired = sessionStorage.getItem(ConnectAuthStorageKey.CONNECT_SESSION_EXPIRED)
30
+
31
+ const loginOptions = computed(() => {
32
+ const urlReturn = route.query.return
33
+ const redirectUrl = urlReturn !== undefined
34
+ ? urlReturn as string
35
+ : `${rtc.baseUrl}${locale.value}${ac.login.redirect}`
36
+
37
+ const loginOptionsMap: Record<
38
+ 'bcsc' | 'bceid' | 'idir',
39
+ { label: string, icon: string, onClick: () => Promise<void> }
40
+ > = {
41
+ bcsc: {
42
+ label: t('connect.page.login.loginBCSC'),
43
+ icon: 'i-mdi-account-card-details-outline',
44
+ onClick: () => login(ConnectIdpHint.BCSC, redirectUrl)
45
+ },
46
+ bceid: {
47
+ label: t('connect.page.login.loginBCEID'),
48
+ icon: 'i-mdi-two-factor-authentication',
49
+ onClick: () => login(ConnectIdpHint.BCEID, redirectUrl)
50
+ },
51
+ idir: {
52
+ label: t('connect.page.login.loginIDIR'),
53
+ icon: 'i-mdi-account-group-outline',
54
+ onClick: () => login(ConnectIdpHint.IDIR, redirectUrl)
55
+ }
56
+ }
57
+
58
+ return ac.login.idps.map(key => loginOptionsMap[key as keyof typeof loginOptionsMap])
59
+ })
60
+ </script>
61
+
62
+ <template>
63
+ <div class="flex grow flex-col items-center justify-center py-10">
64
+ <div class="flex flex-col items-center gap-10">
65
+ <h1>
66
+ {{ $t('connect.page.login.h1') }}
67
+ </h1>
68
+ <UAlert
69
+ v-if="isSessionExpired"
70
+ color="warning"
71
+ variant="subtle"
72
+ :title="$t('connect.page.login.sessionExpiredAlert.title')"
73
+ :description="$t('connect.page.login.sessionExpiredAlert.description')"
74
+ icon="i-mdi-alert"
75
+ />
76
+ <UCard class="my-auto max-w-md">
77
+ <img
78
+ :src="loginImage"
79
+ class="pb-4"
80
+ :alt="$t('connect.text.imageAltGenericLogin')"
81
+ >
82
+ <div class="space-y-4 pt-2.5">
83
+ <div
84
+ v-for="(option, i) in loginOptions"
85
+ :key="option.label"
86
+ class="flex flex-col items-center gap-1"
87
+ >
88
+ <UButton
89
+ :variant="i === 0 ? 'solid' : 'outline'"
90
+ block
91
+ class="py-2.5"
92
+ v-bind="option"
93
+ />
94
+ </div>
95
+ </div>
96
+ </UCard>
97
+ </div>
98
+ </div>
99
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const rtc = useRuntimeConfig().public
4
+ const ac = useAppConfig().connect
5
+ const { locale } = useI18n()
6
+ const { logout } = useConnectAuth()
7
+
8
+ const redirectUrl = computed(() => {
9
+ const urlReturn = route.query.return
10
+ const url = urlReturn !== undefined
11
+ ? urlReturn as string
12
+ : `${rtc.baseUrl}${locale.value}${ac.login.redirect}`
13
+ return url
14
+ })
15
+
16
+ onMounted(async () => {
17
+ await logout(redirectUrl.value)
18
+ })
19
+ </script>
20
+
21
+ <template>
22
+ <ConnectSpinner fullscreen />
23
+ </template>
@@ -1,4 +1,5 @@
1
1
  import Keycloak from 'keycloak-js'
2
+ import { ConnectModalSessionExpired } from '#components'
2
3
 
3
4
  export default defineNuxtPlugin(async () => {
4
5
  const rtc = useRuntimeConfig().public
@@ -10,6 +11,10 @@ export default defineNuxtPlugin(async () => {
10
11
  clientId: rtc.idpClientid
11
12
  })
12
13
 
14
+ const tokenRefreshInterval = rtc.tokenRefreshInterval ? Number(rtc.tokenRefreshInterval) : 30000
15
+ const tokenMinValidity = rtc.tokenMinValidity ? Number(rtc.tokenMinValidity) / 1000 : 120
16
+ const sessionInactivityTimeout = rtc.sessionInactivityTimeout ? Number(rtc.sessionInactivityTimeout) : 1800000
17
+
13
18
  try {
14
19
  // default behaviour when keycloak session expires
15
20
  // try to update token - log out if token update fails
@@ -18,7 +23,7 @@ export default defineNuxtPlugin(async () => {
18
23
  keycloak.onTokenExpired = async () => {
19
24
  try {
20
25
  console.info('[Auth] Token expired, refreshing token...')
21
- await keycloak.updateToken(minValidity)
26
+ await keycloak.updateToken(tokenMinValidity)
22
27
  console.info('[Auth] Token refreshed.')
23
28
  } catch (error) {
24
29
  console.error('[Auth] Failed to refresh token on expiration; logging out.', error)
@@ -36,34 +41,24 @@ export default defineNuxtPlugin(async () => {
36
41
  console.error('[Auth] Failed to initialize Keycloak adapter: ', error)
37
42
  }
38
43
 
39
- // TODO: add to env
40
- const refreshIntervalTimeout = 30000 // rtc.tokenRefreshInterval as number
41
- const minValidity = 120 // toValue((rtc.tokenMinValidity as number) / 1000) // convert to seconds
42
- const idleTimeout = 1800000 // rtc.sessionIdleTimeout as number
43
-
44
- // const route = useRoute()
45
- const { idle } = useIdle(idleTimeout)
44
+ const { idle } = useIdle(sessionInactivityTimeout)
46
45
 
47
46
  // executed when user is authenticated and idle = true
48
- // TODO: manage session expiry
49
47
  async function sessionExpired() {
50
- // if (route.meta.sessionExpiredFn) { // if route meta provided, override default behaviour
51
- // await route.meta.sessionExpiredFn()
52
- // } else { // open expiry modal
53
- // await useConnectModals().openSessionExpiringModal()
54
- // }
55
- console.info('TODO - MANAGE SESSION EXPIRY')
48
+ const overlay = useOverlay()
49
+ const modal = overlay.create(ConnectModalSessionExpired)
50
+ modal.open()
56
51
  }
57
52
 
58
- // refresh token if expiring within <minValidity> - checks every <refreshIntervalTimeout>
53
+ // refresh token if expiring within <tokenMinValidity> - checks every <tokenRefreshInterval>
59
54
  function scheduleRefreshToken() {
60
55
  console.info('[Auth] Verifying token validity.')
61
56
 
62
57
  setTimeout(async () => {
63
- if (keycloak.isTokenExpired(minValidity)) {
58
+ if (keycloak.isTokenExpired(tokenMinValidity)) {
64
59
  console.info('[Auth] Token set to expire soon. Refreshing token...')
65
60
  try {
66
- await keycloak.updateToken(minValidity)
61
+ await keycloak.updateToken(tokenMinValidity)
67
62
  console.info('[Auth] Token refreshed.')
68
63
  } catch (error) {
69
64
  console.error('[Auth] Failed to refresh token; logging out.', error)
@@ -72,7 +67,7 @@ export default defineNuxtPlugin(async () => {
72
67
  }
73
68
 
74
69
  scheduleRefreshToken()
75
- }, refreshIntervalTimeout)
70
+ }, tokenRefreshInterval)
76
71
  }
77
72
 
78
73
  // Watch for changes in authentication and idle state
@@ -82,8 +77,7 @@ export default defineNuxtPlugin(async () => {
82
77
  [() => keycloak.authenticated, () => idle.value],
83
78
  async ([isAuth, isIdle]) => {
84
79
  if (isAuth) {
85
- // TODO: add storage keys
86
- // sessionStorage.removeItem(ConnectStorageKeys.CONNECT_SESSION_EXPIRED)
80
+ sessionStorage.removeItem(ConnectAuthStorageKey.CONNECT_SESSION_EXPIRED)
87
81
  if (!isIdle) {
88
82
  console.info('[Auth] Starting token refresh schedule.')
89
83
  scheduleRefreshToken()
@@ -11,7 +11,6 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
11
11
  const userFirstName = ref<string>(user.value?.firstName || '-')
12
12
  const userLastName = ref<string>(user.value?.lastName || '')
13
13
  const userFullName = computed(() => `${userFirstName.value} ${userLastName.value}`)
14
- const errors = ref<ConnectApiError[]>([])
15
14
 
16
15
  /**
17
16
  * Checks if the current account or the Keycloak user has any of the specified roles.
@@ -42,35 +41,17 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
42
41
 
43
42
  /** Get user information from AUTH */
44
43
  async function getAuthUserProfile(identifier: string): Promise<{ firstname: string, lastname: string } | undefined> {
45
- try {
46
- return $authApi<{ firstname: string, lastname: string }>(`/users/${identifier}`, {
47
- parseResponse: JSON.parse,
48
- onResponseError({ response }) {
49
- errors.value.push({
50
- statusCode: response.status || 500,
51
- message: response._data?.message || response._data?.description || 'Error fetching user info.',
52
- detail: response._data.detail || '',
53
- category: ConnectErrorCategory.USER_INFO
54
- })
55
- }
56
- })
57
- } catch (e) {
58
- console.error('Error fetching user info.', e)
59
- // logFetchError(e, 'Error fetching user info.')
60
- }
44
+ return $authApi<{ firstname: string, lastname: string }>(`/users/${identifier}`, {
45
+ parseResponse: JSON.parse
46
+ })
61
47
  }
62
48
 
63
49
  /** Update user information in AUTH with current token info */
64
50
  async function updateAuthUserInfo(): Promise<void> {
65
- try {
66
- await $authApi('/users', {
67
- method: 'POST',
68
- body: { isLogin: true }
69
- })
70
- } catch (e) {
71
- console.error('Error updating auth user info', e)
72
- // logFetchError(e, 'Error updating auth user info')
73
- }
51
+ await $authApi('/users', {
52
+ method: 'POST',
53
+ body: { isLogin: true }
54
+ })
74
55
  }
75
56
 
76
57
  /** Set user name information */
@@ -90,25 +71,9 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
90
71
  if (!authUser.value?.keycloakGuid) {
91
72
  return undefined
92
73
  }
93
- try {
94
- // TODO: use orgs fetch instead to get branch name ? $authApi<UserSettings[]>('/users/orgs')
95
- const response = await $authApi<ConnectUserSettings[]>(`/users/${authUser.value.keycloakGuid}/settings`, {
96
- onResponseError({ response }) {
97
- errors.value.push({
98
- statusCode: response.status || 500,
99
- message: response._data?.message || 'Error retrieving user accounts.',
100
- detail: response._data.detail || '',
101
- category: ConnectErrorCategory.ACCOUNT_LIST
102
- })
103
- }
104
- })
105
-
106
- return response?.filter(setting => setting.type === UserSettingsType.ACCOUNT) as ConnectAccount[]
107
- } catch (e) {
108
- console.error('Error retrieving user accounts', e)
109
- // logFetchError(e, 'Error retrieving user accounts')
110
- return undefined
111
- }
74
+ // TODO: use orgs fetch instead to get branch name ? $authApi<UserSettings[]>('/users/orgs')
75
+ const response = await $authApi<ConnectUserSettings[]>(`/users/${authUser.value.keycloakGuid}/settings`)
76
+ return response?.filter(setting => setting.type === UserSettingsType.ACCOUNT) as ConnectAccount[]
112
77
  }
113
78
 
114
79
  /** Set the user account list and current account */
@@ -136,23 +101,8 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
136
101
  if (!accountId || !keycloakGuid) {
137
102
  return
138
103
  }
139
- try {
140
- const response = await $authApi<{ count: number }>(`/users/${keycloakGuid}/org/${accountId}/notifications`, {
141
- onResponseError({ response }) {
142
- errors.value.push({
143
- statusCode: response.status || 500,
144
- message: response._data.message || 'Error retrieving pending approvals.',
145
- detail: response._data.detail || '',
146
- category: ConnectErrorCategory.PENDING_APPROVAL_COUNT
147
- })
148
- }
149
- })
150
-
151
- pendingApprovalCount.value = response?.count || 0
152
- } catch (e) {
153
- console.error('Error retrieving pending approvals', e)
154
- // logFetchError(e, 'Error retrieving pending approvals')
155
- }
104
+ const response = await $authApi<{ count: number }>(`/users/${keycloakGuid}/org/${accountId}/notifications`)
105
+ pendingApprovalCount.value = response?.count || 0
156
106
  }
157
107
 
158
108
  async function checkAccountStatus() {
@@ -188,17 +138,21 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
188
138
  }
189
139
 
190
140
  async function initAccountStore(): Promise<void> {
191
- await Promise.all([
192
- setAccountInfo(),
193
- updateAuthUserInfo(),
194
- setUserName()
195
- ])
196
-
197
- if (currentAccount.value.id) {
141
+ try {
198
142
  await Promise.all([
199
- checkAccountStatus(),
200
- getPendingApprovalCount()
143
+ setAccountInfo(),
144
+ updateAuthUserInfo(),
145
+ setUserName()
201
146
  ])
147
+
148
+ if (currentAccount.value.id) {
149
+ await Promise.all([
150
+ checkAccountStatus(),
151
+ getPendingApprovalCount()
152
+ ])
153
+ }
154
+ } catch (e) {
155
+ logFetchError(e, '[Account Store] - Error during initialization')
202
156
  }
203
157
  }
204
158
 
@@ -207,7 +161,6 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
207
161
  currentAccount.value = {} as ConnectAccount
208
162
  userAccounts.value = []
209
163
  pendingApprovalCount.value = 0
210
- errors.value = []
211
164
  userFirstName.value = user.value?.firstName || '-'
212
165
  userLastName.value = user.value?.lastName || ''
213
166
  }
@@ -217,7 +170,6 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
217
170
  currentAccountName,
218
171
  userAccounts,
219
172
  pendingApprovalCount,
220
- errors,
221
173
  userFullName,
222
174
  checkAccountStatus,
223
175
  setUserName,
@@ -2,9 +2,12 @@ declare module '@nuxt/schema' {
2
2
  interface AppConfigInput {
3
3
  connect?: {
4
4
  login?: {
5
- redirectPath?: string
5
+ redirect?: string
6
6
  idps?: Array<'bcsc' | 'bceid' | 'idir'>
7
7
  }
8
+ logout?: {
9
+ redirect?: string
10
+ }
8
11
  header?: {
9
12
  loginMenu?: boolean
10
13
  createAccount?: boolean
@@ -0,0 +1,8 @@
1
+ declare module '#app' {
2
+ interface PageMeta {
3
+ onBeforeSessionExpired?: () => void | Promise<void>
4
+ onAccountChange?: (oldAccount: ConnectAccount, newAccount: ConnectAccount) => boolean
5
+ }
6
+ }
7
+
8
+ export {}
@@ -0,0 +1,33 @@
1
+ import type { Pinia, Store } from 'pinia'
2
+ import { getActivePinia } from 'pinia'
3
+
4
+ interface ExtendedPinia extends Pinia {
5
+ _s: Map<string, Store>
6
+ }
7
+
8
+ /**
9
+ * Resets specific Pinia stores if store IDs are specified.
10
+ * If no IDs are provided, resets all stores that have a `$reset` method.
11
+ * @param {string[]} storeIds - Array of store IDs to reset. If empty, all stores will be reset.
12
+ */
13
+ export function resetPiniaStores(storeIds: string[] = []): void {
14
+ const pinia = getActivePinia() as ExtendedPinia
15
+
16
+ if (!pinia && import.meta.env.DEV === true) {
17
+ console.warn('No pinia stores found.')
18
+ return
19
+ }
20
+
21
+ // null check still fails so must catch error instead
22
+ pinia._s.forEach((store) => {
23
+ if (storeIds.length === 0 || storeIds.includes(store.$id)) {
24
+ try {
25
+ store.$reset()
26
+ } catch {
27
+ if (import.meta.env.DEV === true) {
28
+ console.warn(`Store "${store.$id}" does not implement $reset. Skipping reset.`)
29
+ }
30
+ }
31
+ }
32
+ })
33
+ }
@@ -0,0 +1,23 @@
1
+ // cant use await inside definePageMeta so must use separate util to set page meta if await required
2
+ /**
3
+ * Sets the `onBeforeSessionExpired` callback for the current route.
4
+ *
5
+ * This function allows you to provide a callback (`cb`) that will be called before the session expires.
6
+ * The callback can either be synchronous (returning `void`) or asynchronous (returning a `Promise`).
7
+ *
8
+ * @param {() => T | Promise<T>} cb - The callback function to execute before session expiry.
9
+ *
10
+ * @example
11
+ * setOnBeforeSessionExpired(() => {
12
+ * // Do something before session expiry
13
+ * });
14
+ *
15
+ * @example
16
+ * setOnBeforeSessionExpired(() => submitApplication());
17
+ */
18
+ export function setOnBeforeSessionExpired<T>(cb: () => T | Promise<T>) {
19
+ const route = useRoute()
20
+ route.meta.onBeforeSessionExpired = async () => {
21
+ await cb()
22
+ }
23
+ }
@@ -21,7 +21,30 @@ export default {
21
21
  teamMembers: 'Team Members',
22
22
  transactions: 'Transactions'
23
23
  },
24
+ page: {
25
+ login: {
26
+ h1: 'SBC Connect Account Login',
27
+ title: 'Log in - SBC Connect',
28
+ loginBCSC: 'Login with BC Services Card',
29
+ loginBCEID: 'Login with BCeID',
30
+ loginIDIR: 'Login with IDIR',
31
+ sessionExpiredAlert: {
32
+ title: 'Session Expired',
33
+ description: 'Your session has expired. Please log in again to continue.'
34
+ }
35
+ }
36
+ },
37
+ sessionExpiry: {
38
+ title: 'Session Expiring Soon',
39
+ content: 'Your session is about to expire due to inactivity. You will be logged out in {boldStart}0{boldEnd} seconds. Press any key to continue your session. | Your session is about to expire due to inactivity. You will be logged out in {boldStart}1{boldEnd} second. Press any key to continue your session. | Your session is about to expire due to inactivity. You will be logged out in {boldStart}{count}{boldEnd} seconds. Press any key to continue your session.',
40
+ sessionExpired: 'Session Expired',
41
+ continueBtn: {
42
+ main: 'Continue Session',
43
+ aria: 'Your session is about to expire, press any key to continue your session.'
44
+ }
45
+ },
24
46
  text: {
47
+ imageAltGenericLogin: 'Generic Login Image',
25
48
  notifications: {
26
49
  none: 'No Notifications',
27
50
  teamMemberApproval: '{count} team member requires approval to access this account. | {count} team members require approval to access this account.'
package/nuxt.config.ts CHANGED
@@ -71,7 +71,11 @@ export default defineNuxtConfig({
71
71
  authApiUrl: '',
72
72
  authApiVersion: '',
73
73
  xApiKey: '',
74
- authWebUrl: ''
74
+ authWebUrl: '',
75
+ tokenRefreshInterval: '',
76
+ tokenMinValidity: '',
77
+ sessionInactivityTimeout: '',
78
+ sessionModalTimeout: ''
75
79
  }
76
80
  }
77
81
  })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sbc-connect/nuxt-auth",
3
3
  "type": "module",
4
- "version": "0.1.7",
4
+ "version": "0.1.9",
5
5
  "repository": "github:bcgov/connect-nuxt",
6
6
  "license": "BSD-3-Clause",
7
7
  "main": "./nuxt.config.ts",
@@ -12,8 +12,8 @@
12
12
  "nuxt": "^4.0.2",
13
13
  "typescript": "^5.8.3",
14
14
  "vue-tsc": "^3.0.4",
15
+ "@sbc-connect/playwright-config": "0.0.6",
15
16
  "@sbc-connect/eslint-config": "0.0.6",
16
- "@sbc-connect/playwright-config": "0.0.5",
17
17
  "@sbc-connect/vitest-config": "0.0.6"
18
18
  },
19
19
  "dependencies": {
@@ -21,7 +21,7 @@
21
21
  "keycloak-js": "^26.2.0",
22
22
  "pinia": "^3.0.3",
23
23
  "pinia-plugin-persistedstate": "^4.4.1",
24
- "@sbc-connect/nuxt-base": "0.1.7"
24
+ "@sbc-connect/nuxt-base": "0.1.8"
25
25
  },
26
26
  "scripts": {
27
27
  "preinstall": "npx only-allow pnpm",
@@ -33,7 +33,8 @@
33
33
  "lint": "eslint .",
34
34
  "lint:fix": "eslint --fix .",
35
35
  "test": "pnpm run test:unit; pnpm run test:e2e",
36
- "test:unit": "vitest run",
36
+ "test:unit": "vitest run --passWithNoTests",
37
+ "test:unit:watch": "vitest",
37
38
  "test:e2e": "npx playwright test",
38
39
  "typecheck": "npx nuxt typecheck"
39
40
  }
@@ -1,5 +0,0 @@
1
- export enum ConnectAuthStorageKeys {
2
- LOGIN_REDIRECT_URL = 'sbc-connect-login-redirect-url',
3
- LOGOUT_REDIRECT_URL = 'sbc-connect-logout-redirect-url',
4
- CONNECT_SESSION_EXPIRED = 'connect-session-expired'
5
- }
@@ -1,6 +0,0 @@
1
- export enum ConnectErrorCategory {
2
- USER_ACCOUNTS = 'user-accounts',
3
- ACCOUNT_LIST = 'account-list',
4
- PENDING_APPROVAL_COUNT = 'pending-approval-count',
5
- USER_INFO = 'user-info'
6
- }
@@ -1,6 +0,0 @@
1
- export interface ConnectApiError {
2
- category: ConnectErrorCategory
3
- detail?: string | string[]
4
- message: string
5
- statusCode: number
6
- }