@sbc-connect/nuxt-auth 0.1.32 → 0.2.1
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/CHANGELOG.md +32 -0
- package/app/app.config.ts +6 -2
- package/app/components/Connect/Account/Existing/Alert.vue +10 -0
- package/app/components/Connect/Account/Existing/List/Item.vue +54 -0
- package/app/components/Connect/Account/Existing/List/index.vue +30 -0
- package/app/components/Connect/TermsOfUse/Content.vue +28 -0
- package/app/components/Connect/TermsOfUse/Form.vue +101 -0
- package/app/composables/useAuthApi.ts +60 -0
- package/app/composables/useConnectAccountFlowRedirect.ts +39 -0
- package/app/composables/useConnectTosModals.ts +46 -0
- package/app/interfaces/connect-auth-contact.ts +15 -0
- package/app/interfaces/connect-auth-profile.ts +24 -0
- package/app/interfaces/connect-terms-of-use.ts +6 -0
- package/app/middleware/03.allowed-idps.global.ts +20 -0
- package/app/middleware/connect-auth.ts +16 -1
- package/app/middleware/connect-login-page.ts +26 -0
- package/app/pages/auth/account/select.vue +72 -0
- package/app/pages/auth/login.vue +8 -24
- package/app/pages/auth/terms-of-use.vue +77 -0
- package/app/stores/connect-account.ts +6 -12
- package/app/types/auth-app-config.d.ts +3 -1
- package/app/types/connect-valid-idps.ts +5 -0
- package/app/utils/constants/index.ts +1 -0
- package/app/utils/constants/valid-idps.ts +7 -0
- package/app/utils/index.ts +1 -0
- package/i18n/locales/en-CA.ts +22 -1
- package/nuxt.config.ts +2 -1
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @sbc-connect/nuxt-auth
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`f5d34df`](https://github.com/bcgov/connect-nuxt/commit/f5d34df0bbb2b37cbeda8a9dd39c6bce098c564f)]:
|
|
8
|
+
- @sbc-connect/nuxt-base@0.4.0
|
|
9
|
+
|
|
10
|
+
## 0.2.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- [#104](https://github.com/bcgov/connect-nuxt/pull/104) [`5f1b383`](https://github.com/bcgov/connect-nuxt/commit/5f1b3839ed993654a7f20fe161047ba38ab86274) Thanks [@deetz99](https://github.com/deetz99)! - - Terms of Use page
|
|
15
|
+
|
|
16
|
+
- check user terms of use in auth middleware
|
|
17
|
+
- useAuthApi composable using pinia-colada for api methods
|
|
18
|
+
- terms of use error/decline modals
|
|
19
|
+
- auth contact, auth profile and terms of use interfaces
|
|
20
|
+
- update account store to use pinia-colada query for setUserName method
|
|
21
|
+
- related i18n translations
|
|
22
|
+
- add '@pinia/colada-nuxt' nuxt module
|
|
23
|
+
|
|
24
|
+
- [#102](https://github.com/bcgov/connect-nuxt/pull/102) [`d714785`](https://github.com/bcgov/connect-nuxt/commit/d71478573f1fe11be34d5d588d68fb75eb5fd159) Thanks [@deetz99](https://github.com/deetz99)! - - allowed-idps middleware
|
|
25
|
+
- login-page middleware
|
|
26
|
+
- add explicit typing for allowed idps in app config
|
|
27
|
+
- util getValidIdps constant
|
|
28
|
+
- Choose existing account page (WIP)
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [[`d714785`](https://github.com/bcgov/connect-nuxt/commit/d71478573f1fe11be34d5d588d68fb75eb5fd159), [`5f1b383`](https://github.com/bcgov/connect-nuxt/commit/5f1b3839ed993654a7f20fe161047ba38ab86274)]:
|
|
33
|
+
- @sbc-connect/nuxt-base@0.3.0
|
|
34
|
+
|
|
3
35
|
## 0.1.32
|
|
4
36
|
|
|
5
37
|
### Patch Changes
|
package/app/app.config.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { ConnectIdpHint } from '#auth/app/enums/connect-idp-hint'
|
|
2
|
+
|
|
1
3
|
export default defineAppConfig({
|
|
2
4
|
connect: {
|
|
3
5
|
login: {
|
|
4
|
-
redirect: '',
|
|
5
|
-
idps: [
|
|
6
|
+
redirect: '/',
|
|
7
|
+
idps: [ConnectIdpHint.BCSC, ConnectIdpHint.BCEID, ConnectIdpHint.IDIR],
|
|
8
|
+
skipAccountRedirect: false
|
|
9
|
+
// idpEnforcement: 'strict' - future potentially
|
|
6
10
|
},
|
|
7
11
|
logout: {
|
|
8
12
|
redirect: ''
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
account: ConnectAccount
|
|
4
|
+
}>()
|
|
5
|
+
|
|
6
|
+
defineEmits<{
|
|
7
|
+
select: [id: number]
|
|
8
|
+
}>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<li class="flex flex-col items-start justify-between gap-4 py-8 first:pt-0 last:pb-0 sm:flex-row sm:items-center">
|
|
13
|
+
<div
|
|
14
|
+
class="flex flex-row items-center gap-4 sm:gap-6"
|
|
15
|
+
:class="{
|
|
16
|
+
'opacity-50': account.accountStatus !== AccountStatus.ACTIVE,
|
|
17
|
+
}"
|
|
18
|
+
>
|
|
19
|
+
<UAvatar
|
|
20
|
+
:alt="account.label[0]"
|
|
21
|
+
:ui="{
|
|
22
|
+
root: 'bg-blue-300 rounded-sm',
|
|
23
|
+
fallback: 'text-white font-bold text-xl',
|
|
24
|
+
}"
|
|
25
|
+
/>
|
|
26
|
+
<div class="flex w-full flex-col text-left">
|
|
27
|
+
<span class="text-lg font-bold text-neutral-highlighted">
|
|
28
|
+
{{ account.label }}
|
|
29
|
+
</span>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="flex w-full flex-col gap-4 sm:w-fit sm:flex-row">
|
|
33
|
+
<div class="my-auto flex gap-2">
|
|
34
|
+
<UBadge
|
|
35
|
+
v-if="account.accountStatus !== AccountStatus.ACTIVE"
|
|
36
|
+
:label="$t('badge.inactiveAccount')"
|
|
37
|
+
class="bg-[#fff7e3] px-3 text-center font-bold text-neutral"
|
|
38
|
+
/>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<UButton
|
|
42
|
+
:label="$t('connect.label.useThisAccount')"
|
|
43
|
+
:aria-label="$t('connect.label.useThisAccountAria', { name: account.label })"
|
|
44
|
+
:icon="'i-mdi-chevron-right'"
|
|
45
|
+
trailing
|
|
46
|
+
:disabled="account.accountStatus !== AccountStatus.ACTIVE"
|
|
47
|
+
size="xl"
|
|
48
|
+
data-testid="choose-existing-account-button"
|
|
49
|
+
class="w-full justify-center sm:w-min sm:justify-normal"
|
|
50
|
+
@click="$emit('select', account.id)"
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
</li>
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
accounts: ConnectAccount[]
|
|
4
|
+
}>()
|
|
5
|
+
|
|
6
|
+
defineEmits<{
|
|
7
|
+
select: [id: number]
|
|
8
|
+
}>()
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<section class="space-y-4">
|
|
13
|
+
<ConnectI18nHelper
|
|
14
|
+
as="h2"
|
|
15
|
+
class="font-normal"
|
|
16
|
+
translation-path="connect.label.yourExistingAccounts"
|
|
17
|
+
:count="accounts.length"
|
|
18
|
+
/>
|
|
19
|
+
<ul
|
|
20
|
+
class="bg-white flex flex-col divide-y divide-line-muted rounded p-8 max-h-[50dvh] overflow-y-auto"
|
|
21
|
+
>
|
|
22
|
+
<ConnectAccountExistingListItem
|
|
23
|
+
v-for="account in accounts"
|
|
24
|
+
:key="account.id"
|
|
25
|
+
:account
|
|
26
|
+
@select="$emit('select', $event)"
|
|
27
|
+
/>
|
|
28
|
+
</ul>
|
|
29
|
+
</section>
|
|
30
|
+
</template>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
content?: string
|
|
4
|
+
}>()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div
|
|
9
|
+
v-sanitize="content"
|
|
10
|
+
class="tos-content max-w-full wrap-break-word space-y-6 *:space-y-4"
|
|
11
|
+
/>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<style scoped>
|
|
15
|
+
@reference "#connect-theme";
|
|
16
|
+
|
|
17
|
+
.tos-content :deep(header) {
|
|
18
|
+
@apply font-bold text-neutral-highlighted;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.tos-content :deep(ul) {
|
|
22
|
+
@apply space-y-1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.tos-content :deep(ul li span:first-child) {
|
|
26
|
+
@apply mr-1;
|
|
27
|
+
}
|
|
28
|
+
</style>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { FormErrorEvent, Form } from '@nuxt/ui'
|
|
4
|
+
|
|
5
|
+
const { t } = useI18n()
|
|
6
|
+
const { openDeclineTosModal } = useConnectTosModals()
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
hasReachedBottom: boolean
|
|
10
|
+
disableButtons: boolean
|
|
11
|
+
loading: boolean
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const state = reactive({
|
|
15
|
+
agreeToTerms: false
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const formRef = useTemplateRef<Form<unknown>>('form-ref')
|
|
19
|
+
|
|
20
|
+
const schema = z.object({
|
|
21
|
+
agreeToTerms: z.boolean()
|
|
22
|
+
}).superRefine((val, ctx) => {
|
|
23
|
+
if (!val.agreeToTerms) {
|
|
24
|
+
ctx.addIssue({
|
|
25
|
+
code: 'custom',
|
|
26
|
+
message: t('connect.validation.acceptTermsOfUse'),
|
|
27
|
+
path: ['agreeToTerms']
|
|
28
|
+
})
|
|
29
|
+
} else if (!props.hasReachedBottom) {
|
|
30
|
+
ctx.addIssue({
|
|
31
|
+
code: 'custom',
|
|
32
|
+
message: t('connect.validation.termsOfUseScrollToBottom'),
|
|
33
|
+
path: ['agreeToTerms']
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
function onFormSubmitError(event: FormErrorEvent) {
|
|
39
|
+
if (event?.errors?.[0]?.id) {
|
|
40
|
+
const element = document.getElementById(event.errors[0].id)
|
|
41
|
+
if (element) {
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
element.focus({ preventScroll: true })
|
|
44
|
+
}, 0)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// reset form errors if user reaches bottom of page
|
|
50
|
+
watch(() => props.hasReachedBottom, (newVal) => {
|
|
51
|
+
if (newVal) {
|
|
52
|
+
formRef.value?.clear()
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<UForm
|
|
59
|
+
ref="form-ref"
|
|
60
|
+
class="sticky bottom-0 flex w-full flex-col items-start justify-between
|
|
61
|
+
gap-4 border-t border-line bg-shade py-4 sm:flex-row sm:items-center sm:gap-0 sm:pb-8"
|
|
62
|
+
:state
|
|
63
|
+
:schema
|
|
64
|
+
@error="onFormSubmitError"
|
|
65
|
+
>
|
|
66
|
+
<UFormField
|
|
67
|
+
v-slot="{ error }"
|
|
68
|
+
name="agreeToTerms"
|
|
69
|
+
:ui="{
|
|
70
|
+
error: 'text-base',
|
|
71
|
+
}"
|
|
72
|
+
>
|
|
73
|
+
<UCheckbox
|
|
74
|
+
ref="checkboxRef"
|
|
75
|
+
v-model="state.agreeToTerms"
|
|
76
|
+
:label="$t('connect.text.iHaveReadAndAcceptTermsOfUse')"
|
|
77
|
+
:ui="{
|
|
78
|
+
label: error ? 'text-lg text-error' : 'text-lg',
|
|
79
|
+
}"
|
|
80
|
+
/>
|
|
81
|
+
</UFormField>
|
|
82
|
+
<div class="flex w-full gap-4 sm:w-fit">
|
|
83
|
+
<UButton
|
|
84
|
+
class="flex-1 sm:flex-none"
|
|
85
|
+
:ui="{ base: 'flex justify-center items-center' }"
|
|
86
|
+
:label="$t('connect.label.accept')"
|
|
87
|
+
:disabled="disableButtons"
|
|
88
|
+
:loading
|
|
89
|
+
type="submit"
|
|
90
|
+
/>
|
|
91
|
+
<UButton
|
|
92
|
+
class="flex-1 sm:flex-none"
|
|
93
|
+
:ui="{ base: 'flex justify-center items-center' }"
|
|
94
|
+
:label="$t('connect.label.decline')"
|
|
95
|
+
variant="outline"
|
|
96
|
+
:disabled="disableButtons"
|
|
97
|
+
@click="openDeclineTosModal"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</UForm>
|
|
101
|
+
</template>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export const useAuthApi = () => {
|
|
2
|
+
const { $authApi } = useNuxtApp()
|
|
3
|
+
const queryCache = useQueryCache()
|
|
4
|
+
|
|
5
|
+
async function getAuthUserProfile() {
|
|
6
|
+
const query = defineQuery({
|
|
7
|
+
key: ['auth-user-profile'],
|
|
8
|
+
query: () => $authApi<ConnectAuthProfile>('/users/@me', {
|
|
9
|
+
parseResponse: JSON.parse
|
|
10
|
+
}),
|
|
11
|
+
staleTime: 300000
|
|
12
|
+
})
|
|
13
|
+
return query()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function getTermsOfUse() {
|
|
17
|
+
const query = defineQuery({
|
|
18
|
+
key: ['auth-terms-of-use'],
|
|
19
|
+
query: () => $authApi<ConnectTermsOfUse>('/documents/termsofuse'),
|
|
20
|
+
staleTime: 300000
|
|
21
|
+
})
|
|
22
|
+
return query()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const usePatchTermsOfUse = defineMutation(() => {
|
|
26
|
+
const { mutateAsync, ...mutation } = useMutation({
|
|
27
|
+
mutation: (vars: { accepted: boolean, version: string, successCb?: () => Promise<unknown> }) => {
|
|
28
|
+
return $authApi<ConnectAuthProfile>('/users/@me', {
|
|
29
|
+
method: 'PATCH',
|
|
30
|
+
body: {
|
|
31
|
+
istermsaccepted: vars.accepted,
|
|
32
|
+
termsversion: vars.version
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
},
|
|
36
|
+
onError: (error) => {
|
|
37
|
+
// TODO: FUTURE - add api error message to modal content - remove console.error
|
|
38
|
+
console.error('ERROR: ', error)
|
|
39
|
+
useConnectTosModals().openPatchTosErrorModal()
|
|
40
|
+
},
|
|
41
|
+
onSuccess: async (_, _vars) => {
|
|
42
|
+
await queryCache.invalidateQueries({ key: ['auth-user-profile'], exact: true })
|
|
43
|
+
if (_vars.successCb) {
|
|
44
|
+
await _vars.successCb()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...mutation,
|
|
51
|
+
patchTermsOfUse: mutateAsync
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
getAuthUserProfile,
|
|
57
|
+
getTermsOfUse,
|
|
58
|
+
usePatchTermsOfUse
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { RouteLocationNormalizedGeneric } from '#vue-router'
|
|
2
|
+
|
|
3
|
+
export const useConnectAccountFlowRedirect = () => {
|
|
4
|
+
function finalRedirect(route: RouteLocationNormalizedGeneric) {
|
|
5
|
+
const localePath = useLocalePath()
|
|
6
|
+
const ac = useAppConfig().connect.login
|
|
7
|
+
const externalRedirectUrl = route.query.return as string | undefined
|
|
8
|
+
const internalRedirectUrl = ac.redirect
|
|
9
|
+
|
|
10
|
+
const query = { ...route.query }
|
|
11
|
+
|
|
12
|
+
if (query.return) {
|
|
13
|
+
delete query.return
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (query.allowedIdps) {
|
|
17
|
+
delete query.allowedIdps
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (externalRedirectUrl) {
|
|
21
|
+
return navigateTo(
|
|
22
|
+
{
|
|
23
|
+
path: appendUrlParam(externalRedirectUrl, 'accountid', useConnectAccountStore().currentAccount.id),
|
|
24
|
+
query
|
|
25
|
+
},
|
|
26
|
+
{ external: true }
|
|
27
|
+
)
|
|
28
|
+
} else {
|
|
29
|
+
return navigateTo({
|
|
30
|
+
path: localePath(internalRedirectUrl),
|
|
31
|
+
query
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
finalRedirect
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ConnectAuthContact {
|
|
2
|
+
email: string
|
|
3
|
+
phone: string
|
|
4
|
+
phoneExtension: string
|
|
5
|
+
city?: string
|
|
6
|
+
country?: string
|
|
7
|
+
street?: string
|
|
8
|
+
streetAdditional?: string
|
|
9
|
+
postalCode?: string
|
|
10
|
+
region?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ConnectAuthContacts {
|
|
14
|
+
contacts: ConnectAuthContact[]
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface ConnectAuthProfile {
|
|
2
|
+
contacts: ConnectAuthContact[]
|
|
3
|
+
created: ApiDateTimeUtc
|
|
4
|
+
id: number
|
|
5
|
+
idpUserid: string
|
|
6
|
+
keycloakGuid: string
|
|
7
|
+
lastname: string
|
|
8
|
+
firstname?: string
|
|
9
|
+
loginSource: ConnectLoginSource
|
|
10
|
+
loginTime: ApiDateTimeUtc
|
|
11
|
+
modified: ApiDateTimeUtc
|
|
12
|
+
modifiedBy: string
|
|
13
|
+
userStatus: number
|
|
14
|
+
type: string // PUBLIC_USER - // TODO: get enum?
|
|
15
|
+
userTerms: ConnectUserTerms
|
|
16
|
+
username: string
|
|
17
|
+
verified: boolean
|
|
18
|
+
version: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ConnectUserTerms {
|
|
22
|
+
isTermsOfUseAccepted: boolean
|
|
23
|
+
termsOfUseAcceptedVersion: string
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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,12 +1,27 @@
|
|
|
1
|
-
export default defineNuxtRouteMiddleware((to) => {
|
|
1
|
+
export default defineNuxtRouteMiddleware(async (to) => {
|
|
2
2
|
const { isAuthenticated } = useConnectAuth()
|
|
3
3
|
const rtc = useRuntimeConfig().public
|
|
4
4
|
const localePath = useLocalePath()
|
|
5
|
+
const authApi = useAuthApi()
|
|
6
|
+
const { finalRedirect } = useConnectAccountFlowRedirect()
|
|
5
7
|
|
|
6
8
|
if (!isAuthenticated.value && !rtc.playwright) {
|
|
7
9
|
return navigateTo(localePath(`/auth/login?return=${rtc.baseUrl}${to.fullPath.slice(1)}`))
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
if (isAuthenticated.value) {
|
|
13
|
+
const { data, refresh } = await authApi.getAuthUserProfile()
|
|
14
|
+
await refresh()
|
|
15
|
+
const hasAccepted = data.value?.userTerms.isTermsOfUseAccepted
|
|
16
|
+
const isTosPage = to.meta.connectTosPage === true
|
|
17
|
+
if (!hasAccepted && !isTosPage) {
|
|
18
|
+
const query = { ...to.query }
|
|
19
|
+
return navigateTo({ path: localePath('/auth/terms-of-use'), query })
|
|
20
|
+
} else if (hasAccepted && isTosPage) {
|
|
21
|
+
return finalRedirect(to)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
if (rtc.playwright) {
|
|
11
26
|
const { $connectAuth } = useNuxtApp()
|
|
12
27
|
const { currentAccount } = storeToRefs(useConnectAccountStore())
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
2
|
+
const { isAuthenticated, authUser } = useConnectAuth()
|
|
3
|
+
const accountStore = useConnectAccountStore()
|
|
4
|
+
const localePath = useLocalePath()
|
|
5
|
+
const { finalRedirect } = useConnectAccountFlowRedirect()
|
|
6
|
+
|
|
7
|
+
const numAccounts = accountStore.userAccounts.length
|
|
8
|
+
|
|
9
|
+
if (isAuthenticated.value) {
|
|
10
|
+
if (numAccounts === 1) {
|
|
11
|
+
return finalRedirect(to)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (numAccounts === 0 && authUser.value.loginSource === ConnectLoginSource.BCSC) {
|
|
15
|
+
return navigateTo({
|
|
16
|
+
path: localePath('/auth/account/create'),
|
|
17
|
+
query: to.query
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return navigateTo({
|
|
22
|
+
path: localePath('/auth/account/select'),
|
|
23
|
+
query: to.query
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
definePageMeta({
|
|
3
|
+
layout: 'connect-auth',
|
|
4
|
+
alias: ['/auth/account/create'],
|
|
5
|
+
middleware: 'connect-auth'
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
const rtc = useRuntimeConfig().public
|
|
9
|
+
const accountStore = useConnectAccountStore()
|
|
10
|
+
const { authUser } = useConnectAuth()
|
|
11
|
+
const { finalRedirect } = useConnectAccountFlowRedirect()
|
|
12
|
+
|
|
13
|
+
const addNew = ref(false)
|
|
14
|
+
|
|
15
|
+
function selectAndRedirect(id: number) {
|
|
16
|
+
accountStore.switchCurrentAccount(id)
|
|
17
|
+
finalRedirect(useRoute())
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
onBeforeMount(() => {
|
|
21
|
+
if (accountStore.userAccounts.length === 0 && authUser.value.loginSource === ConnectLoginSource.BCSC) {
|
|
22
|
+
addNew.value = true
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<UContainer class="max-w-6xl">
|
|
29
|
+
<ConnectTransitionFade>
|
|
30
|
+
<div class="space-y-6 sm:space-y-10">
|
|
31
|
+
<h1>{{ !addNew ? $t('connect.label.existingAccountFound') : $t('connect.label.sbcAccountCreation') }}</h1>
|
|
32
|
+
<ConnectAccountExistingAlert v-if="!addNew" />
|
|
33
|
+
</div>
|
|
34
|
+
</ConnectTransitionFade>
|
|
35
|
+
|
|
36
|
+
<ConnectTransitionFade>
|
|
37
|
+
<ConnectAccountExistingList
|
|
38
|
+
v-if="addNew === false"
|
|
39
|
+
:accounts="accountStore.userAccounts"
|
|
40
|
+
@select="selectAndRedirect"
|
|
41
|
+
/>
|
|
42
|
+
|
|
43
|
+
<div v-else class="h-[66dvh] bg-white rounded border-2 border-black flex items-center justify-center text-3xl">
|
|
44
|
+
Create Account Form Here
|
|
45
|
+
</div>
|
|
46
|
+
</ConnectTransitionFade>
|
|
47
|
+
|
|
48
|
+
<div class="flex justify-center">
|
|
49
|
+
<UButton
|
|
50
|
+
v-if="authUser.loginSource === ConnectLoginSource.BCSC"
|
|
51
|
+
variant="outline"
|
|
52
|
+
:label="$t('connect.label.createNewAccount')"
|
|
53
|
+
icon="i-mdi-chevron-right"
|
|
54
|
+
trailing
|
|
55
|
+
size="xl"
|
|
56
|
+
class="w-full justify-center sm:w-min sm:justify-normal"
|
|
57
|
+
@click="addNew = !addNew"
|
|
58
|
+
/>
|
|
59
|
+
<UButton
|
|
60
|
+
v-else
|
|
61
|
+
variant="outline"
|
|
62
|
+
:label="$t('connect.label.createNewAccount')"
|
|
63
|
+
icon="i-mdi-chevron-right"
|
|
64
|
+
trailing
|
|
65
|
+
size="xl"
|
|
66
|
+
class="w-full justify-center sm:w-min sm:justify-normal"
|
|
67
|
+
external
|
|
68
|
+
:to="rtc.authWebUrl + 'setup-account'"
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
</UContainer>
|
|
72
|
+
</template>
|
package/app/pages/auth/login.vue
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import loginImage from '#auth/public/img/BCReg_Generic_Login_image.jpg'
|
|
3
3
|
|
|
4
|
-
const { t
|
|
4
|
+
const { t } = useI18n()
|
|
5
5
|
const { login } = useConnectAuth()
|
|
6
|
-
const
|
|
7
|
-
const ac = useAppConfig().connect
|
|
8
|
-
const route = useRoute()
|
|
6
|
+
const ac = useAppConfig().connect.login
|
|
9
7
|
|
|
10
8
|
useHead({
|
|
11
9
|
title: t('connect.page.login.title')
|
|
@@ -14,48 +12,34 @@ useHead({
|
|
|
14
12
|
definePageMeta({
|
|
15
13
|
layout: 'connect-auth',
|
|
16
14
|
hideBreadcrumbs: true,
|
|
17
|
-
middleware:
|
|
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
|
-
}
|
|
15
|
+
middleware: 'connect-login-page'
|
|
27
16
|
})
|
|
28
17
|
|
|
29
18
|
const isSessionExpired = sessionStorage.getItem(ConnectAuthStorageKey.CONNECT_SESSION_EXPIRED)
|
|
30
19
|
|
|
31
20
|
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
21
|
const loginOptionsMap: Record<
|
|
38
|
-
|
|
22
|
+
ConnectValidIdpOption,
|
|
39
23
|
{ label: string, icon: string, onClick: () => Promise<void> }
|
|
40
24
|
> = {
|
|
41
25
|
bcsc: {
|
|
42
26
|
label: t('connect.page.login.loginBCSC'),
|
|
43
27
|
icon: 'i-mdi-account-card-details-outline',
|
|
44
|
-
onClick: () => login(ConnectIdpHint.BCSC
|
|
28
|
+
onClick: () => login(ConnectIdpHint.BCSC)
|
|
45
29
|
},
|
|
46
30
|
bceid: {
|
|
47
31
|
label: t('connect.page.login.loginBCEID'),
|
|
48
32
|
icon: 'i-mdi-two-factor-authentication',
|
|
49
|
-
onClick: () => login(ConnectIdpHint.BCEID
|
|
33
|
+
onClick: () => login(ConnectIdpHint.BCEID)
|
|
50
34
|
},
|
|
51
35
|
idir: {
|
|
52
36
|
label: t('connect.page.login.loginIDIR'),
|
|
53
37
|
icon: 'i-mdi-account-group-outline',
|
|
54
|
-
onClick: () => login(ConnectIdpHint.IDIR
|
|
38
|
+
onClick: () => login(ConnectIdpHint.IDIR)
|
|
55
39
|
}
|
|
56
40
|
}
|
|
57
41
|
|
|
58
|
-
return ac.
|
|
42
|
+
return ac.idps.map(key => loginOptionsMap[key as keyof typeof loginOptionsMap])
|
|
59
43
|
})
|
|
60
44
|
</script>
|
|
61
45
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ConnectTermsOfUseContent, ConnectTermsOfUseForm } from '#components'
|
|
3
|
+
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const authApi = useAuthApi()
|
|
6
|
+
const { finalRedirect } = useConnectAccountFlowRedirect()
|
|
7
|
+
|
|
8
|
+
definePageMeta({
|
|
9
|
+
layout: 'connect-auth',
|
|
10
|
+
hideBreadcrumbs: true,
|
|
11
|
+
middleware: 'connect-auth',
|
|
12
|
+
connectTosPage: true
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
useHead({
|
|
16
|
+
title: t('connect.page.termsOfUse.title')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const { data, status } = await authApi.getTermsOfUse()
|
|
20
|
+
const { patchTermsOfUse, isLoading } = authApi.usePatchTermsOfUse()
|
|
21
|
+
|
|
22
|
+
const formRef = useTemplateRef<InstanceType<typeof ConnectTermsOfUseForm>>('form-ref')
|
|
23
|
+
const contentRef = useTemplateRef<InstanceType<typeof ConnectTermsOfUseContent>>('content-ref')
|
|
24
|
+
|
|
25
|
+
// track if user has scrolled to bottom of page
|
|
26
|
+
const { bottom: tosBottom } = useElementBounding(contentRef)
|
|
27
|
+
const { top: formTop } = useElementBounding(formRef)
|
|
28
|
+
const hasReachedBottom = computed(() => formTop.value >= tosBottom.value)
|
|
29
|
+
|
|
30
|
+
const disableButtons = computed<boolean>(() => {
|
|
31
|
+
return isLoading.value || status.value === 'pending' || status.value === 'error' || !data.value?.content
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// TODO: - FUTURE - add help/contact info to alert?
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<template>
|
|
38
|
+
<UContainer
|
|
39
|
+
class="max-w-6xl relative grow flex flex-col"
|
|
40
|
+
>
|
|
41
|
+
<h1 class="sticky top-0 w-full border-b border-line bg-shade pb-2 pt-4 text-center z-10 inset-x-0">
|
|
42
|
+
{{ $t('connect.page.termsOfUse.h1') }}
|
|
43
|
+
</h1>
|
|
44
|
+
|
|
45
|
+
<div class="relative grow flex flex-col justify-center">
|
|
46
|
+
<ConnectSpinner v-if="status === 'pending'" />
|
|
47
|
+
|
|
48
|
+
<UAlert
|
|
49
|
+
v-else-if="status === 'error'"
|
|
50
|
+
icon="i-mdi-alert"
|
|
51
|
+
:title="$t('connect.text.alertUnableToLoadTermsOfUse')"
|
|
52
|
+
color="error"
|
|
53
|
+
variant="subtle"
|
|
54
|
+
:close-button="null"
|
|
55
|
+
:ui="{ title: 'text-base' }"
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
<ConnectTermsOfUseContent
|
|
59
|
+
v-else
|
|
60
|
+
ref="content-ref"
|
|
61
|
+
:content="data?.content"
|
|
62
|
+
/>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<ConnectTermsOfUseForm
|
|
66
|
+
ref="form-ref"
|
|
67
|
+
:disable-buttons="disableButtons"
|
|
68
|
+
:has-reached-bottom="hasReachedBottom"
|
|
69
|
+
:loading="isLoading"
|
|
70
|
+
@submit="patchTermsOfUse({
|
|
71
|
+
accepted: true,
|
|
72
|
+
version: data!.versionId,
|
|
73
|
+
successCb: async () => await finalRedirect(useRoute()),
|
|
74
|
+
})"
|
|
75
|
+
/>
|
|
76
|
+
</UContainer>
|
|
77
|
+
</template>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const useConnectAccountStore = defineStore('connect-auth-account-store', () => {
|
|
2
2
|
const { $authApi } = useNuxtApp()
|
|
3
|
+
const authApi = useAuthApi()
|
|
3
4
|
const rtc = useRuntimeConfig().public
|
|
4
5
|
const { authUser } = useConnectAuth()
|
|
5
6
|
// selected user account
|
|
@@ -39,13 +40,6 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
|
|
|
39
40
|
return accountId === currentAccount.value.id
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
/** Get user information from AUTH */
|
|
43
|
-
async function getAuthUserProfile(identifier: string): Promise<{ firstname: string, lastname: string } | undefined> {
|
|
44
|
-
return $authApi<{ firstname: string, lastname: string }>(`/users/${identifier}`, {
|
|
45
|
-
parseResponse: JSON.parse
|
|
46
|
-
})
|
|
47
|
-
}
|
|
48
|
-
|
|
49
43
|
/** Update user information in AUTH with current token info */
|
|
50
44
|
async function updateAuthUserInfo(): Promise<void> {
|
|
51
45
|
await $authApi('/users', {
|
|
@@ -56,10 +50,11 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
|
|
|
56
50
|
|
|
57
51
|
/** Set user name information */
|
|
58
52
|
async function setUserName() {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
const { data, refresh } = await authApi.getAuthUserProfile()
|
|
54
|
+
await refresh()
|
|
55
|
+
if (data.value?.firstname && data.value?.lastname) {
|
|
56
|
+
userFirstName.value = data.value.firstname
|
|
57
|
+
userLastName.value = data.value.lastname
|
|
63
58
|
return
|
|
64
59
|
}
|
|
65
60
|
userFirstName.value = user.value?.firstName || '-'
|
|
@@ -175,7 +170,6 @@ export const useConnectAccountStore = defineStore('connect-auth-account-store',
|
|
|
175
170
|
setUserName,
|
|
176
171
|
hasRoles,
|
|
177
172
|
isCurrentAccount,
|
|
178
|
-
getAuthUserProfile,
|
|
179
173
|
setAccountInfo,
|
|
180
174
|
getUserAccounts,
|
|
181
175
|
switchCurrentAccount,
|
|
@@ -3,7 +3,9 @@ declare module '@nuxt/schema' {
|
|
|
3
3
|
connect?: {
|
|
4
4
|
login?: {
|
|
5
5
|
redirect?: string
|
|
6
|
-
idps?:
|
|
6
|
+
idps?: ConnectValidIdps
|
|
7
|
+
skipAccountRedirect?: boolean
|
|
8
|
+
// idpEnforcement: 'strict' | 'none' - future potentially
|
|
7
9
|
}
|
|
8
10
|
logout?: {
|
|
9
11
|
redirect?: string
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './valid-idps'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './constants'
|
package/i18n/locales/en-CA.ts
CHANGED
|
@@ -3,23 +3,32 @@ export default {
|
|
|
3
3
|
/* Ordering should be alphabetical unless otherwise specified */
|
|
4
4
|
connect: {
|
|
5
5
|
label: {
|
|
6
|
+
accept: 'Accept',
|
|
6
7
|
accountInfo: 'Account Info',
|
|
7
8
|
accountOptionsMenu: 'Account Options Menu',
|
|
8
9
|
accountSettings: 'Account Settings',
|
|
9
10
|
bceid: 'BCeID',
|
|
10
11
|
bcsc: 'BC Services Card',
|
|
11
12
|
createAccount: 'Create Account',
|
|
13
|
+
createNewAccount: 'Create New Account',
|
|
14
|
+
decline: 'Decline',
|
|
15
|
+
declineTermsOfUse: 'Decline Terms of Use',
|
|
12
16
|
editProfile: 'Edit Profile',
|
|
17
|
+
existingAccountFound: 'Existing Account Found',
|
|
13
18
|
idir: 'IDIR',
|
|
14
19
|
logout: 'Log out',
|
|
15
20
|
login: 'Log in',
|
|
16
21
|
mainMenu: 'Main Menu',
|
|
17
22
|
notifications: 'Notifications',
|
|
18
23
|
notificationsAria: 'Notifications, {count} unread',
|
|
24
|
+
sbcAccountCreation: 'Service BC Account Creation',
|
|
19
25
|
selectLoginMethod: 'Select log in method',
|
|
20
26
|
switchAccount: 'Switch Account',
|
|
21
27
|
teamMembers: 'Team Members',
|
|
22
|
-
transactions: 'Transactions'
|
|
28
|
+
transactions: 'Transactions',
|
|
29
|
+
useThisAccount: 'Use this Account',
|
|
30
|
+
useThisAccountAria: 'Use this Account, {name}',
|
|
31
|
+
yourExistingAccounts: '{boldStart}Your Existing Accounts{boldEnd} ({count})'
|
|
23
32
|
},
|
|
24
33
|
page: {
|
|
25
34
|
login: {
|
|
@@ -32,6 +41,10 @@ export default {
|
|
|
32
41
|
title: 'Session Expired',
|
|
33
42
|
description: 'Your session has expired. Please log in again to continue.'
|
|
34
43
|
}
|
|
44
|
+
},
|
|
45
|
+
termsOfUse: {
|
|
46
|
+
h1: 'Terms of Use',
|
|
47
|
+
title: 'Terms of Use - Service BC Connect'
|
|
35
48
|
}
|
|
36
49
|
},
|
|
37
50
|
sessionExpiry: {
|
|
@@ -44,11 +57,19 @@ export default {
|
|
|
44
57
|
}
|
|
45
58
|
},
|
|
46
59
|
text: {
|
|
60
|
+
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.',
|
|
61
|
+
alertUnableToLoadTermsOfUse: 'Unable to load Terms of Use, please try again later.',
|
|
62
|
+
declineTOSCantAccessService: 'By declining the Terms of Use, you won’t be able to access this service. Do you wish to proceed?',
|
|
63
|
+
iHaveReadAndAcceptTermsOfUse: 'I have read and accept the Terms of Use',
|
|
47
64
|
imageAltGenericLogin: 'Generic Login Image',
|
|
48
65
|
notifications: {
|
|
49
66
|
none: 'No Notifications',
|
|
50
67
|
teamMemberApproval: '{count} team member requires approval to access this account. | {count} team members require approval to access this account.'
|
|
51
68
|
}
|
|
69
|
+
},
|
|
70
|
+
validation: {
|
|
71
|
+
acceptTermsOfUse: 'Please accept the Terms of Use.',
|
|
72
|
+
termsOfUseScrollToBottom: 'Please scroll to the bottom of the page to accept the Terms of Use.'
|
|
52
73
|
}
|
|
53
74
|
}
|
|
54
75
|
}
|
package/nuxt.config.ts
CHANGED
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
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"repository": "github:bcgov/connect-nuxt",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
"@sbc-connect/vitest-config": "0.0.6"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@pinia/colada": "^0.17.9",
|
|
21
|
+
"@pinia/colada-nuxt": "^0.2.4",
|
|
20
22
|
"@pinia/nuxt": "^0.11.2",
|
|
21
23
|
"keycloak-js": "^26.2.1",
|
|
22
24
|
"pinia": "^3.0.3",
|
|
23
25
|
"pinia-plugin-persistedstate": "^4.5.0",
|
|
24
|
-
"@sbc-connect/nuxt-base": "0.
|
|
26
|
+
"@sbc-connect/nuxt-base": "0.4.0"
|
|
25
27
|
},
|
|
26
28
|
"scripts": {
|
|
27
29
|
"preinstall": "npx only-allow pnpm",
|