@sbc-connect/nuxt-auth 0.3.0 → 0.4.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/app/app.config.ts +4 -3
  3. package/app/components/Connect/Account/Create/Name.vue +73 -0
  4. package/app/components/Connect/Account/Create/index.vue +15 -21
  5. package/app/components/Connect/Modal/InvalidIdp.vue +45 -0
  6. package/app/components/Connect/TermsOfUse/Form.vue +1 -1
  7. package/app/composables/useAuthApi.ts +96 -2
  8. package/app/composables/useConnectAccountFlowRedirect.ts +0 -4
  9. package/app/composables/useConnectAppConfig.ts +31 -0
  10. package/app/composables/useConnectAuthModals.ts +76 -0
  11. package/app/enums/connect-access-type.ts +3 -0
  12. package/app/enums/connect-payment-method.ts +3 -0
  13. package/app/enums/connect-preset-type.ts +4 -0
  14. package/app/enums/connect-product-code.ts +3 -0
  15. package/app/enums/connect-product-type.ts +3 -0
  16. package/app/interfaces/app-config-shapes.ts +33 -0
  17. package/app/interfaces/connect-account-address.ts +1 -0
  18. package/app/interfaces/connect-account.ts +13 -0
  19. package/app/middleware/03.app-config-presets.global.ts +13 -0
  20. package/app/middleware/04.idp-enforcement.global.ts +39 -0
  21. package/app/middleware/connect-auth.ts +8 -1
  22. package/app/pages/auth/account/select.vue +17 -3
  23. package/app/pages/auth/login.vue +18 -1
  24. package/app/stores/connect-account.ts +64 -11
  25. package/app/types/auth-app-config.d.ts +11 -18
  26. package/app/utils/schemas/account.ts +26 -4
  27. package/i18n/locales/en-CA.ts +18 -0
  28. package/package.json +14 -15
  29. package/app/composables/useConnectTosModals.ts +0 -46
  30. package/app/middleware/03.allowed-idps.global.ts +0 -20
  31. package/modules/auth-assets/index.ts +0 -15
  32. package/modules/auth-assets/runtime/assets/connect-auth-tw.css +0 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @sbc-connect/nuxt-auth
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#118](https://github.com/bcgov/connect-nuxt/pull/118) [`41e58f4`](https://github.com/bcgov/connect-nuxt/commit/41e58f44e6d216e7de568bccb2e240b2d89b8408) Thanks [@deetz99](https://github.com/deetz99)! - Remove now unnecessary css module workaround.
8
+
9
+ - [#120](https://github.com/bcgov/connect-nuxt/pull/120) [`9f42868`](https://github.com/bcgov/connect-nuxt/commit/9f428681207188292d52cf6d370035f54bdb6ab4) Thanks [@cameron-eyds](https://github.com/cameron-eyds)! - Idp Enforcement Modal and bcsc user welcome msg
10
+
11
+ - [#117](https://github.com/bcgov/connect-nuxt/pull/117) [`e3da105`](https://github.com/bcgov/connect-nuxt/commit/e3da1056247f5c502578dc70f95375e66e3cc1da) Thanks [@deetz99](https://github.com/deetz99)! - Update all dependencies
12
+
13
+ - [#119](https://github.com/bcgov/connect-nuxt/pull/119) [`ee5ba9e`](https://github.com/bcgov/connect-nuxt/commit/ee5ba9e6f7302e8ea11fd0ebfa887c4f6269b0ee) Thanks [@cameron-eyds](https://github.com/cameron-eyds)! - implements Dynamic app.config and idp enforcement
14
+
15
+ - [#114](https://github.com/bcgov/connect-nuxt/pull/114) [`fe3eef1`](https://github.com/bcgov/connect-nuxt/commit/fe3eef1b39cc5883cc5e8560efe0dd89d04d0455) Thanks [@cameron-eyds](https://github.com/cameron-eyds)! - Implements Acct Creation submissions, contact updates and name lookup
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [[`41e58f4`](https://github.com/bcgov/connect-nuxt/commit/41e58f44e6d216e7de568bccb2e240b2d89b8408), [`e3da105`](https://github.com/bcgov/connect-nuxt/commit/e3da1056247f5c502578dc70f95375e66e3cc1da), [`fe3eef1`](https://github.com/bcgov/connect-nuxt/commit/fe3eef1b39cc5883cc5e8560efe0dd89d04d0455)]:
20
+ - @sbc-connect/nuxt-forms@0.4.0
21
+ - @sbc-connect/nuxt-base@0.6.0
22
+
3
23
  ## 0.3.0
4
24
 
5
25
  ### Minor Changes
package/app/app.config.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { ConnectIdpHint } from '#auth/app/enums/connect-idp-hint'
2
+ import type { AppConfigInput } from 'nuxt/schema'
2
3
 
3
4
  export default defineAppConfig({
4
5
  connect: {
5
6
  login: {
6
7
  redirect: '/',
7
8
  idps: [ConnectIdpHint.BCSC, ConnectIdpHint.BCEID, ConnectIdpHint.IDIR],
8
- skipAccountRedirect: false
9
- // idpEnforcement: 'strict' - future potentially
9
+ skipAccountRedirect: false,
10
+ idpEnforcement: false
10
11
  },
11
12
  logout: {
12
13
  redirect: ''
@@ -18,4 +19,4 @@ export default defineAppConfig({
18
19
  accountOptionsMenu: true
19
20
  }
20
21
  }
21
- })
22
+ } satisfies AppConfigInput) // validates input shape without losing inference
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import type { FormError, InputProps } from '@nuxt/ui'
3
+
4
+ defineProps<{
5
+ error?: FormError<string>
6
+ }>()
7
+
8
+ const authApi = useAuthApi()
9
+ const { accountFormState } = useConnectAccountStore()
10
+
11
+ const isLoading = ref(false)
12
+ const statusCode = defineModel<number | undefined>('statusCode')
13
+
14
+ /** Validate accountName and trigger validation API call */
15
+ const validateName = useDebounceFn(async (accountName: string) => {
16
+ if (accountName.length) {
17
+ isLoading.value = true
18
+ try {
19
+ const { refetch } = authApi.verifyAccountName(accountName)
20
+ const { data } = await refetch()
21
+ statusCode.value = data?.status
22
+ } catch (err: unknown) {
23
+ statusCode.value = err?.response?.status || 500
24
+ } finally {
25
+ isLoading.value = false
26
+ }
27
+ } else {
28
+ statusCode.value = undefined
29
+ }
30
+ }, 1000)
31
+
32
+ watch(() => accountFormState.accountName, validateName)
33
+
34
+ // Compute UInput props based on loading state and status code
35
+ const uInputProps = computed<InputProps>(() => {
36
+ const iconMap: Record<number, { class: string, name: string }> = {
37
+ 204: { class: 'text-success size-7', name: 'i-mdi-check' },
38
+ 200: { class: 'text-error size-7', name: 'i-mdi-close' },
39
+ 500: { class: 'text-error size-7', name: 'i-mdi-close' }
40
+ }
41
+
42
+ const icon = statusCode.value ? iconMap[statusCode.value] : null
43
+
44
+ return {
45
+ loading: isLoading.value,
46
+ trailing: true,
47
+ ui: {
48
+ trailingIcon: icon?.class || 'text-primary size-7'
49
+ },
50
+ trailingIcon: icon?.name
51
+ }
52
+ })
53
+
54
+ // Provide custom props for UInput
55
+ provide('UInput-props-account-name-input', uInputProps)
56
+ </script>
57
+
58
+ <template>
59
+ <ConnectFormFieldWrapper
60
+ class="pt-2 my-6"
61
+ :label="$t('connect.page.createAccount.accountNameLabel')"
62
+ orientation="horizontal"
63
+ :error
64
+ >
65
+ <ConnectFormInput
66
+ v-model="accountFormState.accountName"
67
+ name="accountName"
68
+ input-id="account-name-input"
69
+ :label="$t('connect.page.createAccount.accountNameLabel')"
70
+ :help="$t('connect.page.createAccount.accountNameHelp')"
71
+ />
72
+ </ConnectFormFieldWrapper>
73
+ </template>
@@ -3,9 +3,10 @@ import type { Form, FormError } from '@nuxt/ui'
3
3
  import type { AccountProfileSchema } from '#auth/app/utils/schemas/account'
4
4
  import { getAccountCreateSchema } from '#auth/app/utils/schemas/account'
5
5
 
6
- const { accountFormState, userFullName } = useConnectAccountStore()
7
- const accountProfileSchema = getAccountCreateSchema()
6
+ const statusCode = ref<number | undefined>(undefined)
8
7
 
8
+ const { accountFormState, submitCreateAccount, userFullName } = useConnectAccountStore()
9
+ const accountProfileSchema = computed(() => getAccountCreateSchema(statusCode.value))
9
10
  const formRef = useTemplateRef<Form<AccountProfileSchema>>('account-create-form')
10
11
 
11
12
  const formErrors = computed<{
@@ -44,6 +45,7 @@ async function validate() {
44
45
  :schema="accountProfileSchema"
45
46
  :state="accountFormState"
46
47
  @error="onFormSubmitError"
48
+ @submit="submitCreateAccount()"
47
49
  >
48
50
  <!-- Legal Name -->
49
51
  <ConnectFormFieldWrapper
@@ -61,20 +63,10 @@ async function validate() {
61
63
  <USeparator orientation="horizontal" class="my-8" />
62
64
 
63
65
  <!-- Account Name -->
64
- <ConnectFormFieldWrapper
65
- class="pt-2 my-6"
66
- :label="$t('connect.page.createAccount.accountNameLabel')"
67
- orientation="horizontal"
66
+ <ConnectAccountCreateName
67
+ v-model:status-code="statusCode"
68
68
  :error="formErrors.accountName"
69
- >
70
- <ConnectFormInput
71
- v-model="accountFormState.accountName"
72
- name="accountName"
73
- input-id="account-name-input"
74
- :label="$t('connect.page.createAccount.accountNameLabel')"
75
- :help="$t('connect.page.createAccount.accountNameHelp')"
76
- />
77
- </ConnectFormFieldWrapper>
69
+ />
78
70
 
79
71
  <!-- Account Email -->
80
72
  <ConnectFormFieldWrapper
@@ -98,16 +90,18 @@ async function validate() {
98
90
  :error="formErrors.phone"
99
91
  >
100
92
  <div class="flex flex-row gap-2">
101
- <ConnectFormPhoneCountryCode
102
- v-model:country-calling-code="accountFormState.phone.countryCode"
103
- v-model:country-iso2="accountFormState.phone.countryIso2"
104
- :is-invalid="!accountFormState.phone.countryIso2"
105
- class="w-40 mt-[-20px]"
106
- />
93
+ <!-- Disabling country code selection until Auth Model Supports individual property -->
94
+ <!-- <ConnectFormPhoneCountryCode -->
95
+ <!-- v-model:country-calling-code="accountFormState.phone.countryCode" -->
96
+ <!-- v-model:country-iso2="accountFormState.phone.countryIso2" -->
97
+ <!-- :is-invalid="!accountFormState.phone.countryIso2" -->
98
+ <!-- class="w-40 mt-[-20px]" -->
99
+ <!-- /> -->
107
100
  <ConnectFormInput
108
101
  v-model="accountFormState.phone.phoneNumber"
109
102
  name="phone.phoneNumber"
110
103
  input-id="phone-number-input"
104
+ class="flex-2"
111
105
  :label="$t('connect.page.createAccount.phonePlaceholder')"
112
106
  mask="(###) ###-####"
113
107
  />
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ currentIdp: ConnectLoginSource
4
+ }>()
5
+ const emit = defineEmits<{ close: [] }>()
6
+
7
+ function closeModal() {
8
+ emit('close')
9
+ }
10
+ </script>
11
+
12
+ <template>
13
+ <UModal
14
+ id="invalid-idp-dialog"
15
+ overlay
16
+ :dismissible="false"
17
+ >
18
+ <template #content>
19
+ <div class="p-10 flex flex-col gap-6">
20
+ <div role="alert">
21
+ <h2
22
+ id="invalid-idp-title"
23
+ class="text-xl font-bold text-neutral-highlighted"
24
+ >
25
+ {{ $t('connect.invalidIdp.title') }} {{ props.currentIdp }}
26
+ </h2>
27
+ </div>
28
+ <div>
29
+ <div role="alert">
30
+ <span>{{ $t('connect.invalidIdp.content') }}</span>
31
+ </div>
32
+ </div>
33
+ <div class="flex flex-wrap items-center justify-center gap-4">
34
+ <UButton
35
+ :label="$t('connect.label.logout')"
36
+ :aria-label="$t('connect.label.logout')"
37
+ size="xl"
38
+ class="font-bold"
39
+ @click="closeModal"
40
+ />
41
+ </div>
42
+ </div>
43
+ </template>
44
+ </UModal>
45
+ </template>
@@ -3,7 +3,7 @@ import { z } from 'zod'
3
3
  import type { FormErrorEvent, Form } from '@nuxt/ui'
4
4
 
5
5
  const { t } = useI18n()
6
- const { openDeclineTosModal } = useConnectTosModals()
6
+ const { openDeclineTosModal } = useConnectAuthModals()
7
7
 
8
8
  const props = defineProps<{
9
9
  hasReachedBottom: boolean
@@ -1,3 +1,5 @@
1
+ import type { ConnectCreateAccount } from '#auth/app/interfaces/connect-account'
2
+
1
3
  export const useAuthApi = () => {
2
4
  const { $authApi } = useNuxtApp()
3
5
  const queryCache = useQueryCache()
@@ -13,6 +15,95 @@ export const useAuthApi = () => {
13
15
  return query()
14
16
  }
15
17
 
18
+ /**
19
+ * Validates whether an account name is available by sending a request to the AUTH service.
20
+ * @param {string} accountName - The account name to validate for uniqueness.
21
+ */
22
+ function verifyAccountName(accountName: string) {
23
+ const query = defineQuery({
24
+ key: ['auth-account-name', accountName],
25
+ query: () => $authApi.raw(`/orgs?validateName=true&name=${encodeURIComponent(accountName)}`),
26
+ staleTime: 300000
27
+ })
28
+ return query()
29
+ }
30
+
31
+ /**
32
+ * Creates an account by POSTing the given payload to `/orgs`.
33
+ * @returns Object containing mutation state and `createAccount` function.
34
+ */
35
+ const useCreateAccount = defineMutation(() => {
36
+ const { mutateAsync, ...mutation } = useMutation({
37
+ mutation: (vars: { payload: ConnectCreateAccount, successCb?: () => Promise<unknown> }) => {
38
+ return $authApi<ConnectAuthProfile>('/orgs', {
39
+ method: 'POST',
40
+ body: vars.payload
41
+ })
42
+ },
43
+ onError: (error) => {
44
+ // TODO: FUTURE - add api error message to modal content - remove console.error
45
+ console.error('ERROR: ', error)
46
+ useConnectAuthModals().openCreateAccountModal()
47
+ },
48
+ onSuccess: async (_, _vars) => {
49
+ await queryCache.invalidateQueries({ key: ['auth-user-profile'], exact: true })
50
+ if (_vars.successCb) {
51
+ await _vars.successCb()
52
+ }
53
+ }
54
+ })
55
+
56
+ return {
57
+ ...mutation,
58
+ createAccount: mutateAsync
59
+ }
60
+ })
61
+
62
+ /**
63
+ * Updates a users contact by PUTing the given payload to `/users/contacts`.
64
+ * @returns Object containing mutation state and `updateUserContact` function.
65
+ */
66
+ const useUpdateUserContact = defineMutation(() => {
67
+ const { mutateAsync, ...mutation } = useMutation({
68
+ mutation: (vars: {
69
+ email: string
70
+ phone: string
71
+ phoneExtension: string | undefined
72
+ successCb?: () => Promise<unknown>
73
+ errorCb?: (error: unknown) => Promise<unknown>
74
+ }) => {
75
+ return $authApi<ConnectAuthProfile>('/users/contacts', {
76
+ method: 'PUT',
77
+ body: {
78
+ email: vars.email,
79
+ phone: vars.phone,
80
+ phoneExtension: vars.phoneExtension
81
+ }
82
+ })
83
+ },
84
+ onError: async (error, _vars) => {
85
+ // TODO: FUTURE - add api error message to modal content - remove console.error
86
+ console.error('ERROR: ', error)
87
+ await useConnectAuthModals().openUpdateUserContactModal()
88
+
89
+ if (_vars.errorCb) {
90
+ await queryCache.invalidateQueries({ key: ['auth-user-profile'], exact: true })
91
+ await _vars.errorCb(error)
92
+ }
93
+ },
94
+ onSuccess: async (_, _vars) => {
95
+ if (_vars.successCb) {
96
+ await _vars.successCb()
97
+ }
98
+ }
99
+ })
100
+
101
+ return {
102
+ ...mutation,
103
+ updateUserContact: mutateAsync
104
+ }
105
+ })
106
+
16
107
  async function getTermsOfUse() {
17
108
  const query = defineQuery({
18
109
  key: ['auth-terms-of-use'],
@@ -36,7 +127,7 @@ export const useAuthApi = () => {
36
127
  onError: (error) => {
37
128
  // TODO: FUTURE - add api error message to modal content - remove console.error
38
129
  console.error('ERROR: ', error)
39
- useConnectTosModals().openPatchTosErrorModal()
130
+ useConnectAuthModals().openPatchTosErrorModal()
40
131
  },
41
132
  onSuccess: async (_, _vars) => {
42
133
  await queryCache.invalidateQueries({ key: ['auth-user-profile'], exact: true })
@@ -55,6 +146,9 @@ export const useAuthApi = () => {
55
146
  return {
56
147
  getAuthUserProfile,
57
148
  getTermsOfUse,
58
- usePatchTermsOfUse
149
+ useCreateAccount,
150
+ usePatchTermsOfUse,
151
+ useUpdateUserContact,
152
+ verifyAccountName
59
153
  }
60
154
  }
@@ -13,10 +13,6 @@ export const useConnectAccountFlowRedirect = () => {
13
13
  delete query.return
14
14
  }
15
15
 
16
- if (query.allowedIdps) {
17
- delete query.allowedIdps
18
- }
19
-
20
16
  if (externalRedirectUrl) {
21
17
  return navigateTo(
22
18
  {
@@ -0,0 +1,31 @@
1
+ import { useAppConfig } from '#imports'
2
+
3
+ export const useConnectAppConfig = () => {
4
+ /**
5
+ * Merge preset overrides (from app.config.connectOverrides) into the provided baseConfig.
6
+ * If no override for the preset is found, baseConfig is returned unchanged.
7
+ */
8
+ function mergeAppConfigOverrides(
9
+ baseConfig: ConnectConfig,
10
+ presetName: ConnectPresetType
11
+ ): ConnectConfig {
12
+ const appConfig = useAppConfig()
13
+ const overrides = appConfig.connectOverrides?.[presetName] ?? null
14
+
15
+ return {
16
+ ...baseConfig,
17
+ // Apply shallow merge for each subtree when present
18
+ ...(overrides?.login
19
+ ? { login: { ...baseConfig.login, ...overrides?.login } }
20
+ : { login: baseConfig.login }),
21
+ ...(overrides?.header
22
+ ? { header: { ...baseConfig.header, ...overrides?.header } }
23
+ : { header: baseConfig.header }),
24
+ logout: baseConfig.logout
25
+ }
26
+ }
27
+
28
+ return {
29
+ mergeAppConfigOverrides
30
+ }
31
+ }
@@ -0,0 +1,76 @@
1
+ export const useConnectAuthModals = () => {
2
+ const { baseModal } = useConnectModal()
3
+ const { logout } = useConnectAuth()
4
+ const t = useNuxtApp().$i18n.t
5
+
6
+ function openDeclineTosModal() {
7
+ baseModal.open({
8
+ title: `${t('connect.label.declineTermsOfUse')}?`,
9
+ description: t('connect.text.declineTOSCantAccessService'),
10
+ dismissible: true,
11
+ buttons: [
12
+ {
13
+ label: t('connect.label.declineTermsOfUse'),
14
+ onClick: async () => await logout()
15
+ },
16
+ {
17
+ label: t('connect.label.cancel'),
18
+ shouldClose: true,
19
+ variant: 'outline'
20
+ }
21
+ ]
22
+ })
23
+ }
24
+
25
+ // TODO: better error text
26
+ function openPatchTosErrorModal() {
27
+ baseModal.open({
28
+ title: `${t('connect.text.patchTosError.title')}`,
29
+ description: t('connect.text.patchTosError.description'),
30
+ dismissible: true,
31
+ buttons: [
32
+ {
33
+ label: t('connect.label.close'),
34
+ shouldClose: true
35
+ }
36
+ ]
37
+ // contact info ???
38
+ // include api error message?
39
+ })
40
+ }
41
+
42
+ function openCreateAccountModal() {
43
+ baseModal.open({
44
+ title: `${t('connect.text.accountCreationError.title')}`,
45
+ description: t('connect.text.accountCreationError.description'),
46
+ dismissible: true,
47
+ buttons: [
48
+ {
49
+ label: t('connect.label.close'),
50
+ shouldClose: true
51
+ }
52
+ ]
53
+ })
54
+ }
55
+
56
+ function openUpdateUserContactModal() {
57
+ baseModal.open({
58
+ title: `${t('connect.text.userContactUpdateError.title')}`,
59
+ description: t('connect.text.userContactUpdateError.description'),
60
+ dismissible: true,
61
+ buttons: [
62
+ {
63
+ label: t('connect.label.close'),
64
+ shouldClose: true
65
+ }
66
+ ]
67
+ })
68
+ }
69
+
70
+ return {
71
+ openCreateAccountModal,
72
+ openDeclineTosModal,
73
+ openPatchTosErrorModal,
74
+ openUpdateUserContactModal
75
+ }
76
+ }
@@ -0,0 +1,3 @@
1
+ export enum ConnectAccessType {
2
+ REGULAR = 'REGULAR'
3
+ }
@@ -0,0 +1,3 @@
1
+ export enum ConnectPaymentMethod {
2
+ DIRECT_PAY = 'DIRECT_PAY'
3
+ }
@@ -0,0 +1,4 @@
1
+ export enum ConnectPresetType {
2
+ DEFAULT = 'default',
3
+ BCSC_USER = 'bcscUser'
4
+ }
@@ -0,0 +1,3 @@
1
+ export enum ConnectProductCode {
2
+ BUSINESS = 'BUSINESS'
3
+ }
@@ -0,0 +1,3 @@
1
+ export enum ConnectProductTypeCode {
2
+ PREMIUM = 'PREMIUM'
3
+ }
@@ -0,0 +1,33 @@
1
+ export interface ConnectLoginConfig {
2
+ redirect: string
3
+ idps: ConnectIdpHint[]
4
+ skipAccountRedirect: boolean
5
+ idpEnforcement: boolean
6
+ alert?: {
7
+ title?: string
8
+ message?: string
9
+ }
10
+ }
11
+
12
+ export interface ConnectLogoutConfig {
13
+ redirect: string
14
+ }
15
+
16
+ export interface ConnectHeaderConfig {
17
+ loginMenu: boolean
18
+ createAccount: boolean
19
+ notifications: boolean
20
+ accountOptionsMenu: boolean
21
+ }
22
+
23
+ export interface ConnectConfig {
24
+ login: ConnectLoginConfig
25
+ logout: ConnectLogoutConfig
26
+ header: ConnectHeaderConfig
27
+ }
28
+
29
+ export interface ConnectPresetOverrides {
30
+ login?: Partial<ConnectLoginConfig>
31
+ header?: Partial<ConnectHeaderConfig>
32
+ logout?: Partial<ConnectLogoutConfig>
33
+ }
@@ -0,0 +1 @@
1
+ export type ConnectAccountAddress = Pick<ConnectAddress, Exclude<keyof ConnectAddress, 'locationDescription'>>
@@ -8,3 +8,16 @@ export interface ConnectAccount {
8
8
  urlpath: string
9
9
  urlorigin: string
10
10
  }
11
+
12
+ export interface ConnectCreateAccount {
13
+ accessType: ConnectAccessType
14
+ isBusinessAccount: boolean
15
+ mailingAddress: ConnectAccountAddress
16
+ name: string
17
+ paymentInfo: {
18
+ paymentMethod: ConnectPaymentMethod
19
+ }
20
+ productSubscriptions: Array<{
21
+ productCode: ConnectProductCode
22
+ }>
23
+ }
@@ -0,0 +1,13 @@
1
+ import { useAppConfig } from '#imports'
2
+ import { useConnectAppConfig } from '#auth/app/composables/useConnectAppConfig'
3
+
4
+ export default defineNuxtRouteMiddleware((to) => {
5
+ const appConfig = useAppConfig()
6
+ const { mergeAppConfigOverrides } = useConnectAppConfig()
7
+
8
+ // Build and assign app.config presets
9
+ appConfig.connect = mergeAppConfigOverrides(
10
+ appConfig.connect as ConnectConfig,
11
+ to.query.preset as ConnectPresetType
12
+ )
13
+ })
@@ -0,0 +1,39 @@
1
+ import { useConnectAuth } from '#auth/app/composables/useConnectAuth'
2
+ import { withQuery } from 'ufo'
3
+ import type { ConnectIdpHint } from '#imports'
4
+ import { useAppConfig } from '#imports'
5
+ import { ConnectModalInvalidIdp } from '#components'
6
+
7
+ export default defineNuxtRouteMiddleware(async (to) => {
8
+ const localePath = useLocalePath()
9
+ const appConfig = useAppConfig()
10
+ const { authUser, logout } = useConnectAuth()
11
+
12
+ // IDP Enforcement Config
13
+ const connectConfig = appConfig.connect as ConnectConfig
14
+ const idpEnforcement = connectConfig?.login?.idpEnforcement
15
+ const allowedIdps = connectConfig?.login?.idps
16
+
17
+ // Idp overlay
18
+ const overlay = useOverlay()
19
+ const modal = overlay.create(ConnectModalInvalidIdp)
20
+
21
+ /** Show Invalid IDP Modal and Logout on modal close */
22
+ async function showInvalidIdpModal() {
23
+ // Prompt user with invalid IDP modal
24
+ await modal.open({ currentIdp: authUser.value?.loginSource })
25
+
26
+ // Logout and Preserve any query param
27
+ const pathWithQuery = withQuery(localePath('/auth/login'), to.query)
28
+
29
+ const url = `${window.location.origin}${pathWithQuery}`
30
+ return await logout(url)
31
+ }
32
+
33
+ if (idpEnforcement && authUser.value?.loginSource) {
34
+ // User's IDP is not allowed, log them out and redirect to login page
35
+ if (!allowedIdps?.includes(authUser.value?.loginSource.toLowerCase() as unknown as ConnectIdpHint)) {
36
+ showInvalidIdpModal()
37
+ }
38
+ }
39
+ })
@@ -6,7 +6,14 @@ export default defineNuxtRouteMiddleware(async (to) => {
6
6
  const { finalRedirect } = useConnectAccountFlowRedirect()
7
7
 
8
8
  if (!isAuthenticated.value && !rtc.playwright) {
9
- return navigateTo(localePath(`/auth/login?return=${rtc.baseUrl}${to.fullPath.slice(1)}`))
9
+ return navigateTo({
10
+ path: localePath('/auth/login'),
11
+ query: {
12
+ // include preset when present
13
+ ...(to.query.preset ? { preset: String(to.query.preset) } : {}),
14
+ return: `${rtc.baseUrl}${to.fullPath.slice(1)}`
15
+ }
16
+ })
10
17
  }
11
18
 
12
19
  if (isAuthenticated.value) {
@@ -5,11 +5,14 @@ definePageMeta({
5
5
  middleware: 'connect-auth'
6
6
  })
7
7
 
8
+ const route = useRoute()
8
9
  const rtc = useRuntimeConfig().public
9
10
  const accountStore = useConnectAccountStore()
10
11
  const { authUser } = useConnectAuth()
11
12
  const { finalRedirect } = useConnectAccountFlowRedirect()
12
13
  const { clearAccountState } = useConnectAccountStore()
14
+ const { isLoading } = storeToRefs(useConnectAccountStore())
15
+ const isAccountCreateRoute = computed(() => route.path.includes('create'))
13
16
 
14
17
  const addNew = ref(false)
15
18
  const pageTitle = computed(() =>
@@ -26,7 +29,8 @@ function selectAndRedirect(id: number) {
26
29
  }
27
30
 
28
31
  onBeforeMount(() => {
29
- if (accountStore.userAccounts.length === 0 && authUser.value.loginSource === ConnectLoginSource.BCSC) {
32
+ if ((accountStore.userAccounts.length === 0 && authUser.value.loginSource === ConnectLoginSource.BCSC)
33
+ || isAccountCreateRoute.value) {
30
34
  addNew.value = true
31
35
  }
32
36
  })
@@ -63,7 +67,11 @@ const toggleCreateNewAccount = () => {
63
67
  </ConnectTransitionFade>
64
68
 
65
69
  <!-- Select Account Actions -->
66
- <div v-if="!addNew" class="flex justify-center">
70
+ <div
71
+ v-if="!addNew"
72
+ class="flex justify-center"
73
+ data-testid="select-account-button-wrapper"
74
+ >
67
75
  <UButton
68
76
  v-if="authUser.loginSource === ConnectLoginSource.BCSC"
69
77
  variant="outline"
@@ -88,10 +96,15 @@ const toggleCreateNewAccount = () => {
88
96
  </div>
89
97
 
90
98
  <!-- Create Account Actions -->
91
- <div v-if="addNew" class="flex justify-end gap-x-3">
99
+ <div
100
+ v-if="addNew"
101
+ class="flex justify-end gap-x-3"
102
+ data-testid="create-account-button-wrapper"
103
+ >
92
104
  <UButton
93
105
  variant="outline"
94
106
  :label="$t('connect.label.back')"
107
+ :disabled="isLoading"
95
108
  trailing
96
109
  size="xl"
97
110
  class="w-full justify-center sm:w-min sm:justify-normal"
@@ -99,6 +112,7 @@ const toggleCreateNewAccount = () => {
99
112
  />
100
113
  <UButton
101
114
  :label="$t('connect.label.saveAndContinue')"
115
+ :loading="isLoading"
102
116
  form="account-create-form"
103
117
  class="w-full justify-center sm:w-min sm:justify-normal"
104
118
  trailing
@@ -46,6 +46,20 @@ const loginOptions = computed(() => {
46
46
  <template>
47
47
  <div class="flex grow flex-col items-center justify-center py-10">
48
48
  <div class="flex flex-col items-center gap-10">
49
+ <!-- Alert message from app config -->
50
+ <UAlert
51
+ v-if="ac.alert"
52
+ class="max-w-[35em]"
53
+ color="warning"
54
+ variant="subtle"
55
+ data-testid="login-alert"
56
+ :title="ac.alert.title"
57
+ :description="ac.alert.message"
58
+ icon="i-mdi-check-circle"
59
+ :ui="{
60
+ icon: 'text-success',
61
+ }"
62
+ />
49
63
  <h1>
50
64
  {{ $t('connect.page.login.h1') }}
51
65
  </h1>
@@ -57,7 +71,10 @@ const loginOptions = computed(() => {
57
71
  :description="$t('connect.page.login.sessionExpiredAlert.description')"
58
72
  icon="i-mdi-alert"
59
73
  />
60
- <UCard class="my-auto max-w-md">
74
+ <UCard
75
+ class="my-auto max-w-md"
76
+ data-testid="login-card"
77
+ >
61
78
  <img
62
79
  :src="loginImage"
63
80
  class="pb-4"
@@ -1,11 +1,14 @@
1
1
  import { getAccountCreateSchema } from '#auth/app/utils/schemas/account'
2
2
  import type { AccountProfileSchema } from '#auth/app/utils/schemas/account'
3
+ import type { ConnectCreateAccount } from '#auth/app/interfaces/connect-account'
3
4
 
4
5
  export const useConnectAccountStore = defineStore('connect-auth-account-store', () => {
5
6
  const { $authApi } = useNuxtApp()
6
- const authApi = useAuthApi()
7
7
  const rtc = useRuntimeConfig().public
8
8
  const { authUser } = useConnectAuth()
9
+ const { useCreateAccount, useUpdateUserContact, getAuthUserProfile } = useAuthApi()
10
+ const { finalRedirect } = useConnectAccountFlowRedirect()
11
+
9
12
  // selected user account
10
13
  const currentAccount = ref<ConnectAccount>({} as ConnectAccount)
11
14
  const userAccounts = ref<ConnectAccount[]>([])
@@ -15,9 +18,13 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
15
18
  const userFirstName = ref<string>(user.value?.firstName || '-')
16
19
  const userLastName = ref<string>(user.value?.lastName || '')
17
20
  const userFullName = computed(() => `${userFirstName.value} ${userLastName.value}`)
21
+
22
+ // Create account
23
+ const isLoading = ref<boolean>(false)
24
+ const { createAccount } = useCreateAccount()
25
+ const { updateUserContact } = useUpdateUserContact()
18
26
  const createAccountProfileSchema = getAccountCreateSchema()
19
27
  const accountFormState = reactive<AccountProfileSchema>(createAccountProfileSchema.parse({}))
20
-
21
28
  /**
22
29
  * Checks if the current account or the Keycloak user has any of the specified roles.
23
30
  *
@@ -53,9 +60,53 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
53
60
  })
54
61
  }
55
62
 
63
+ /** Map AccountFormState -> CreateAccountPayload */
64
+ function createAccountPayload(): ConnectCreateAccount {
65
+ return {
66
+ accessType: ConnectAccessType.REGULAR,
67
+ mailingAddress: {
68
+ city: accountFormState.address.city,
69
+ country: accountFormState.address.country,
70
+ region: accountFormState.address.region,
71
+ postalCode: accountFormState.address.postalCode,
72
+ street: accountFormState.address.street,
73
+ streetAdditional: accountFormState.address.streetAdditional || '',
74
+ deliveryInstructions: accountFormState.address.locationDescription || ''
75
+ },
76
+ name: accountFormState.accountName,
77
+ paymentInfo: { paymentMethod: ConnectPaymentMethod.DIRECT_PAY },
78
+ productSubscriptions: [{ productCode: ConnectProductCode.BUSINESS }]
79
+ }
80
+ }
81
+
82
+ /** Submit create account and user contact update requests */
83
+ async function submitCreateAccount(): Promise<void> {
84
+ try {
85
+ isLoading.value = true
86
+ // Create Account
87
+ const payload = createAccountPayload()
88
+ await createAccount({
89
+ payload,
90
+ // Update User Contact Info on create account success
91
+ successCb: async () => await updateUserContact({
92
+ email: accountFormState.emailAddress,
93
+ phone: accountFormState.phone.phoneNumber,
94
+ phoneExtension: accountFormState.phone.ext,
95
+ successCb: async () => await finalRedirect(useRoute()),
96
+ errorCb: async () => await finalRedirect(useRoute())
97
+ })
98
+ })
99
+ } catch (error) {
100
+ // Error handled in useAuthApi
101
+ console.error('Account Create Submission Error: ', error)
102
+ } finally {
103
+ isLoading.value = false
104
+ }
105
+ }
106
+
56
107
  /** Set user name information */
57
108
  async function setUserName() {
58
- const { data, refresh } = await authApi.getAuthUserProfile()
109
+ const { data, refresh } = await getAuthUserProfile()
59
110
  await refresh()
60
111
  if (data.value?.firstname && data.value?.lastname) {
61
112
  userFirstName.value = data.value.firstname
@@ -172,21 +223,23 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
172
223
 
173
224
  return {
174
225
  accountFormState,
226
+ checkAccountStatus,
175
227
  clearAccountState,
228
+ submitCreateAccount,
229
+ isLoading,
176
230
  currentAccount,
177
231
  currentAccountName,
178
- userAccounts,
179
- pendingApprovalCount,
180
- userFullName,
181
- checkAccountStatus,
182
- setUserName,
232
+ getPendingApprovalCount,
233
+ getUserAccounts,
183
234
  hasRoles,
235
+ initAccountStore,
184
236
  isCurrentAccount,
237
+ pendingApprovalCount,
185
238
  setAccountInfo,
186
- getUserAccounts,
239
+ setUserName,
187
240
  switchCurrentAccount,
188
- getPendingApprovalCount,
189
- initAccountStore,
241
+ userAccounts,
242
+ userFullName,
190
243
  $reset
191
244
  }
192
245
  },
@@ -1,23 +1,16 @@
1
- declare module '@nuxt/schema' {
1
+ declare module 'nuxt/schema' {
2
+ /** What users can write in app.config.ts */
2
3
  interface AppConfigInput {
3
- connect?: {
4
- login?: {
5
- redirect?: string
6
- idps?: ConnectValidIdps
7
- skipAccountRedirect?: boolean
8
- // idpEnforcement: 'strict' | 'none' - future potentially
9
- }
10
- logout?: {
11
- redirect?: string
12
- }
13
- header?: {
14
- loginMenu?: boolean
15
- createAccount?: boolean
16
- notifications?: boolean
17
- accountOptionsMenu?: boolean
18
- }
19
- }
4
+ connect?: Partial<ConnectConfig>
5
+ connectOverrides?: Record<string, ConnectPresetOverrides | null>
20
6
  }
7
+
8
+ /** What useAppConfig() returns */
9
+ interface AppConfig {
10
+ connect: ConnectConfig
11
+ connectOverrides?: Record<string, ConnectPresetOverrides | null>
12
+ }
13
+
21
14
  }
22
15
 
23
16
  export {}
@@ -1,6 +1,30 @@
1
1
  import { z } from 'zod'
2
2
  import { getRequiredAddressSchema } from '#forms/app/utils'
3
3
 
4
+ export function getAccountNameSchema(status?: number | undefined) {
5
+ const t = useNuxtApp().$i18n.t
6
+
7
+ return z.string().min(1, t('connect.validation.requiredAccountName'))
8
+ .superRefine((val, ctx) => {
9
+ // Only run if we have a value and a statusCode
10
+ if (val && status !== undefined) {
11
+ if (status === 200) {
12
+ ctx.addIssue({
13
+ code: 'custom',
14
+ message: t('connect.validation.duplicateAccountName')
15
+ })
16
+ }
17
+ if (status === 500) {
18
+ ctx.addIssue({
19
+ code: 'custom',
20
+ message: t('connect.validation.requestError')
21
+ })
22
+ }
23
+ // 204 (No Content) => valid -> no issue
24
+ }
25
+ })
26
+ }
27
+
4
28
  /**
5
29
  * Phone schema: country dialing code + local phone + optional extension.
6
30
  * - countryCode: E.164 dialing code (e.g., "+1", "+44").
@@ -27,9 +51,7 @@ export function getPhoneSchema() {
27
51
  * Account create schema — single address + name + email + phone
28
52
  * Mirrors your .default(...) pattern.
29
53
  */
30
- export function getAccountCreateSchema() {
31
- const t = useNuxtApp().$i18n.t
32
-
54
+ export function getAccountCreateSchema(status: number | undefined = undefined) {
33
55
  return z.object({
34
56
  address: getRequiredAddressSchema().default({
35
57
  street: '',
@@ -40,7 +62,7 @@ export function getAccountCreateSchema() {
40
62
  country: 'CA',
41
63
  locationDescription: ''
42
64
  }),
43
- accountName: z.string().min(1, t('connect.validation.requiredAccountName')).default(''),
65
+ accountName: getAccountNameSchema(status).default(''),
44
66
  emailAddress: z.string().email().default(''),
45
67
  phone: getPhoneSchema().default({
46
68
  countryIso2: 'CA',
@@ -73,6 +73,10 @@ export default {
73
73
  aria: 'Your session is about to expire, press any key to continue your session.'
74
74
  }
75
75
  },
76
+ invalidIdp: {
77
+ title: 'You\'re logged in with your',
78
+ content: 'To continue, you must sign in with your BC Services Card. Please log out and sign in again using your BC Services Card credentials.'
79
+ },
76
80
  text: {
77
81
  alertExistingAccountFound: '{boldStart}Note:{boldEnd} It looks like you already have an account with Service BC Connect. You can use an existing account to proceed or create a new one.',
78
82
  alertUnableToLoadTermsOfUse: 'Unable to load Terms of Use, please try again later.',
@@ -82,10 +86,24 @@ export default {
82
86
  notifications: {
83
87
  none: 'No Notifications',
84
88
  teamMemberApproval: '{count} team member requires approval to access this account. | {count} team members require approval to access this account.'
89
+ },
90
+ patchTosError: {
91
+ title: 'Terms of Use Update Error',
92
+ description: 'Unable to update Terms of Use at this time. Please try again later.'
93
+ },
94
+ accountCreationError: {
95
+ title: 'Account Creation Error',
96
+ description: 'Unable to create your account at this time. Please try again later.'
97
+ },
98
+ userContactUpdateError: {
99
+ title: 'User Contact Update Error',
100
+ description: 'Unable to update your contact information at this time. Please try again later.'
85
101
  }
86
102
  },
87
103
  validation: {
88
104
  acceptTermsOfUse: 'Please accept the Terms of Use.',
105
+ duplicateAccountName: 'An account with this name already exists.',
106
+ requestError: 'Request error, please try again.',
89
107
  termsOfUseScrollToBottom: 'Please scroll to the bottom of the page to accept the Terms of Use.',
90
108
  phoneNumberFormat: 'Phone must be in the format (123) 123-1231',
91
109
  phoneExtFormat: 'Extension must be digits only',
package/package.json CHANGED
@@ -1,30 +1,29 @@
1
1
  {
2
2
  "name": "@sbc-connect/nuxt-auth",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "repository": "github:bcgov/connect-nuxt",
6
6
  "license": "BSD-3-Clause",
7
7
  "main": "./nuxt.config.ts",
8
8
  "devDependencies": {
9
9
  "@axe-core/playwright": "4.11.0",
10
- "@vitest/coverage-v8": "3.2.4",
11
10
  "dotenv": "17.2.3",
12
- "nuxt": "4.1.3",
11
+ "nuxt": "4.2.2",
13
12
  "typescript": "5.9.3",
14
- "vue-tsc": "3.1.2",
15
- "@sbc-connect/eslint-config": "0.0.8",
16
- "@sbc-connect/playwright-config": "0.0.8",
17
- "@sbc-connect/vitest-config": "0.0.6"
13
+ "vue-tsc": "3.2.1",
14
+ "@sbc-connect/playwright-config": "0.1.0",
15
+ "@sbc-connect/vitest-config": "0.1.0",
16
+ "@sbc-connect/eslint-config": "0.0.8"
18
17
  },
19
18
  "dependencies": {
20
- "@pinia/colada": "^0.17.9",
21
- "@pinia/colada-nuxt": "^0.2.4",
22
- "@pinia/nuxt": "^0.11.2",
23
- "keycloak-js": "^26.2.1",
24
- "pinia": "^3.0.3",
25
- "pinia-plugin-persistedstate": "^4.5.0",
26
- "@sbc-connect/nuxt-base": "0.5.0",
27
- "@sbc-connect/nuxt-forms": "0.3.0"
19
+ "@pinia/colada": "0.20.0",
20
+ "@pinia/colada-nuxt": "0.3.0",
21
+ "@pinia/nuxt": "0.11.3",
22
+ "keycloak-js": "26.2.2",
23
+ "pinia": "3.0.4",
24
+ "pinia-plugin-persistedstate": "4.7.1",
25
+ "@sbc-connect/nuxt-base": "0.6.0",
26
+ "@sbc-connect/nuxt-forms": "0.4.0"
28
27
  },
29
28
  "scripts": {
30
29
  "preinstall": "npx only-allow pnpm",
@@ -1,46 +0,0 @@
1
- export const useConnectTosModals = () => {
2
- const { baseModal } = useConnectModal()
3
- const { logout } = useConnectAuth()
4
- const t = useNuxtApp().$i18n.t
5
-
6
- function openDeclineTosModal() {
7
- baseModal.open({
8
- title: `${t('connect.label.declineTermsOfUse')}?`,
9
- description: t('connect.text.declineTOSCantAccessService'),
10
- dismissible: true,
11
- buttons: [
12
- {
13
- label: t('connect.label.declineTermsOfUse'),
14
- onClick: async () => await logout()
15
- },
16
- {
17
- label: t('connect.label.cancel'),
18
- shouldClose: true,
19
- variant: 'outline'
20
- }
21
- ]
22
- })
23
- }
24
-
25
- // TODO: better error text
26
- function openPatchTosErrorModal() {
27
- baseModal.open({
28
- title: "Can't update TOS at this time",
29
- description: 'Please try again later',
30
- dismissible: true,
31
- buttons: [
32
- {
33
- label: t('connect.label.close'),
34
- shouldClose: true
35
- }
36
- ]
37
- // contact info ???
38
- // include api error message?
39
- })
40
- }
41
-
42
- return {
43
- openDeclineTosModal,
44
- openPatchTosErrorModal
45
- }
46
- }
@@ -1,20 +0,0 @@
1
- export default defineNuxtRouteMiddleware((to) => {
2
- const ac = useAppConfig().connect.login
3
- const validIdps = getValidIdps()
4
- const allowedIdps = to.query.allowedIdps as string | undefined
5
-
6
- if (allowedIdps) {
7
- const idpArray = allowedIdps
8
- .split(',')
9
- .filter(idp =>
10
- validIdps.includes(idp as ConnectValidIdpOption)
11
- ) as ConnectValidIdps
12
-
13
- if (idpArray.length) {
14
- // updateAppConfig util doesn't seem to be updating correctly
15
- // https://nuxt.com/docs/4.x/api/utils/update-app-config
16
- // assign directly
17
- ac.idps = idpArray
18
- }
19
- }
20
- })
@@ -1,15 +0,0 @@
1
- import { defineNuxtModule, createResolver } from 'nuxt/kit'
2
-
3
- export default defineNuxtModule({
4
- meta: {
5
- name: 'auth-assets',
6
- configKey: 'authAssets'
7
- },
8
- defaults: {},
9
- async setup(_options, _nuxt) {
10
- console.info('Setting up **auth** assets module')
11
- const resolver = createResolver(import.meta.url)
12
-
13
- _nuxt.options.css.push(resolver.resolve('./runtime/assets/connect-auth-tw.css'))
14
- }
15
- })
@@ -1,2 +0,0 @@
1
- @import "#connect-theme";
2
- @source "../../../../app";