@nuxt-customer-portal/core 0.3.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.
- package/CHANGELOG.md +6 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/app/composables/useAutoPagination.ts +30 -0
- package/app/composables/useOrganization.ts +117 -0
- package/app/composables/usePaginatedResource.ts +167 -0
- package/app/composables/usePortalFeatures.ts +74 -0
- package/app/composables/usePortalSession.ts +20 -0
- package/app/composables/useUserStore.ts +460 -0
- package/app/middleware/auth.global.ts +32 -0
- package/app/plugins/auth.ts +28 -0
- package/app/plugins/portal-core-feature.ts +3 -0
- package/app/types/index.d.ts +75 -0
- package/app/utils/auth-client.ts +31 -0
- package/app/utils/permission-utils.ts +21 -0
- package/app/utils/random.ts +9 -0
- package/i18n/locales/en.json +81 -0
- package/i18n/locales/nl.json +81 -0
- package/migrations/0000_baseline.sql +119 -0
- package/migrations/0001_organization_types.sql +12 -0
- package/migrations/0002_invitation_created_at.sql +1 -0
- package/migrations/0003_system_admin_role.sql +5 -0
- package/migrations/0004_provider_organization_type.sql +16 -0
- package/migrations/0005_portal_email_settings.sql +15 -0
- package/migrations/meta/0000_snapshot.json +880 -0
- package/migrations/meta/_journal.json +13 -0
- package/nuxt.config.ts +55 -0
- package/package.json +66 -0
- package/portal.manifest.mjs +8 -0
- package/server/api/auth/[...].ts +18 -0
- package/server/api/auth/permissions.get.ts +64 -0
- package/server/api/notifications.ts +294 -0
- package/server/db/core-schema.ts +1 -0
- package/server/db/schema/auth-schema.ts +198 -0
- package/server/db/schema/index.ts +1 -0
- package/server/middleware/api-docs-auth.ts +26 -0
- package/server/middleware/auth.ts +61 -0
- package/server/portal.ts +212 -0
- package/server/routes/api-docs/openapi.json.get.ts +22 -0
- package/server/utils/admin-email-allowlist.ts +12 -0
- package/server/utils/auth.ts +326 -0
- package/server/utils/business-hooks.ts +28 -0
- package/server/utils/db.ts +18 -0
- package/server/utils/email-template.html +105 -0
- package/server/utils/email-texts.ts +110 -0
- package/server/utils/email.ts +37 -0
- package/server/utils/invitation-management.ts +29 -0
- package/server/utils/openapi-auth.ts +67 -0
- package/server/utils/openapi-contracts.ts +129 -0
- package/server/utils/openapi-order.ts +136 -0
- package/server/utils/permissions.ts +179 -0
- package/server/utils/portal-email.ts +424 -0
- package/server/utils/query-builder.ts +270 -0
- package/server/utils/route-meta.ts +28 -0
- package/shared/core-feature.ts +152 -0
- package/shared/feature-registry.ts +62 -0
- package/shared/invitation-validation.ts +8 -0
- package/shared/organization-avatar.ts +11 -0
- package/shared/permissions.ts +26 -0
- package/shared/portal-session.ts +17 -0
- package/shared/types/feature.ts +130 -0
- package/shared/types/index.ts +40 -0
- package/shared/types/sections/api.ts +134 -0
- package/shared/types/sections/auth.ts +71 -0
- package/shared/types/sections/database.ts +86 -0
- package/shared/types/sections/queryBuilder.ts +31 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nuxt Customer Portal contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Portal core layer
|
|
2
|
+
|
|
3
|
+
Portal core is the stable boundary between the host application and reusable feature layers.
|
|
4
|
+
|
|
5
|
+
Feature layers register a `PortalFeatureDefinition` containing navigation, dashboard widgets, surfaces, and an organization-role policy. Client code consumes authentication and active-organization state through `usePortalSession`; server handlers import database, session, tenant, and authorization adapters from `@nuxt-customer-portal/core/server`.
|
|
6
|
+
|
|
7
|
+
Feature layers must not import host paths through `~/` or `~~/`. Drizzle schema files are the exception to alias-based imports because Drizzle Kit does not resolve Nuxt aliases; local schemas reference portal core’s physical schema while the host composes all schemas into one migration stream.
|
|
8
|
+
|
|
9
|
+
Portal-core and authentication database objects remain in PostgreSQL's `public`
|
|
10
|
+
schema. Each feature layer owns a separate PostgreSQL schema named after the
|
|
11
|
+
layer, with hyphens converted to underscores (for example,
|
|
12
|
+
`service-requests` uses `service_requests`). Feature tables may reference
|
|
13
|
+
portal-core tables across schemas.
|
|
14
|
+
|
|
15
|
+
## Reusable pagination
|
|
16
|
+
|
|
17
|
+
`usePaginatedResource<Item, Filters>` owns request cancellation, stale-response
|
|
18
|
+
protection, pagination state, append/replace behavior, deduplication, and errors.
|
|
19
|
+
A feature supplies only its fetch adapter and stable item key:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const products = usePaginatedResource<Product, ProductFilters>({
|
|
23
|
+
pageSize: 20,
|
|
24
|
+
getKey: (product) => product.id,
|
|
25
|
+
fetchPage: ({ filters, page, pageSize, signal }) =>
|
|
26
|
+
$fetch('/api/products', {
|
|
27
|
+
query: { ...filters, page, pageSize },
|
|
28
|
+
signal
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Use `useAutoPagination` with a list container and trailing sentinel when the next
|
|
34
|
+
page should load automatically. Conventional pagination controls can use the
|
|
35
|
+
same `pagination` state alongside it.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface AutoPaginationOptions {
|
|
2
|
+
sentinel: Ref<HTMLElement | null>
|
|
3
|
+
scrollContainer?: Ref<HTMLElement | null>
|
|
4
|
+
canLoadMore: MaybeRefOrGetter<boolean>
|
|
5
|
+
loading: MaybeRefOrGetter<boolean>
|
|
6
|
+
loadMore: () => unknown
|
|
7
|
+
rootMargin?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const useAutoPagination = (options: AutoPaginationOptions) => {
|
|
11
|
+
const { stop } = useIntersectionObserver(
|
|
12
|
+
options.sentinel,
|
|
13
|
+
([entry]) => {
|
|
14
|
+
if (!entry?.isIntersecting) {
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
if (toValue(options.loading) || !toValue(options.canLoadMore)) {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
void options.loadMore()
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
root: options.scrollContainer,
|
|
24
|
+
rootMargin: options.rootMargin ?? '0px 0px 240px 0px',
|
|
25
|
+
threshold: 0
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return { stop }
|
|
30
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
|
|
2
|
+
import type { MemberRole, Organization, OrganizationType } from '@nuxt-customer-portal/core/shared/types/index'
|
|
3
|
+
|
|
4
|
+
export const useOrganization = () => {
|
|
5
|
+
// Get current user's organization
|
|
6
|
+
const getCurrentOrganization = async () => {
|
|
7
|
+
const { data: member } = await authClient.organization.getActiveMember()
|
|
8
|
+
return member?.organizationId
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Check if user is organization owner
|
|
12
|
+
const isOrganizationOwner = async () => {
|
|
13
|
+
const { data: roleData } = await authClient.organization.getActiveMemberRole()
|
|
14
|
+
return roleData?.role === 'owner'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check if user is organization admin
|
|
18
|
+
const isOrganizationAdmin = async () => {
|
|
19
|
+
const { data: roleData } = await authClient.organization.getActiveMemberRole()
|
|
20
|
+
return roleData?.role === 'admin' || roleData?.role === 'owner'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// List all user organizations
|
|
24
|
+
const getUserOrganizations = async () => {
|
|
25
|
+
const { data } = await authClient.organization.list()
|
|
26
|
+
return data
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const createOrganization = async (data: {
|
|
30
|
+
name: string
|
|
31
|
+
slug: string
|
|
32
|
+
organizationType: OrganizationType
|
|
33
|
+
keepCurrentActiveOrganization?: boolean
|
|
34
|
+
}) => {
|
|
35
|
+
return await authClient.organization.create(data)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const updateAdminOrganization = async (
|
|
39
|
+
organizationId: string,
|
|
40
|
+
data: { name: string; slug: string; officialCompanyName: string; logo: string; avatarLogo?: string }
|
|
41
|
+
) => {
|
|
42
|
+
return await $fetch<Organization>(`/api/admin/organizations/${organizationId}`, {
|
|
43
|
+
method: 'PATCH',
|
|
44
|
+
body: data
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const getActiveMember = async () => {
|
|
49
|
+
return await authClient.organization.getActiveMember()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const listMembers = async (organizationId: string) => {
|
|
53
|
+
return await authClient.organization.listMembers({
|
|
54
|
+
query: { organizationId }
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const removeMember = async (memberIdOrEmail: string, organizationId: string) => {
|
|
59
|
+
return await authClient.organization.removeMember({
|
|
60
|
+
memberIdOrEmail,
|
|
61
|
+
organizationId
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const inviteMember = async (email: string, organizationId: string, role: MemberRole = 'member', resend = false) => {
|
|
66
|
+
return await authClient.organization.inviteMember({
|
|
67
|
+
email,
|
|
68
|
+
organizationId,
|
|
69
|
+
role,
|
|
70
|
+
resend
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const listInvitations = async (organizationId: string) => {
|
|
75
|
+
return await authClient.organization.listInvitations({
|
|
76
|
+
query: { organizationId }
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resendInvitation = async (invitationId: string, organizationId: string, email: string, role: MemberRole) => {
|
|
81
|
+
return await authClient.organization.inviteMember({
|
|
82
|
+
email,
|
|
83
|
+
organizationId,
|
|
84
|
+
role,
|
|
85
|
+
resend: true
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cancelInvitation = async (invitationId: string) => {
|
|
90
|
+
return await authClient.organization.cancelInvitation({
|
|
91
|
+
invitationId
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const acceptInvitation = async (invitationId: string) => {
|
|
96
|
+
return await authClient.organization.acceptInvitation({
|
|
97
|
+
invitationId
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
getCurrentOrganization,
|
|
103
|
+
isOrganizationOwner,
|
|
104
|
+
isOrganizationAdmin,
|
|
105
|
+
getUserOrganizations,
|
|
106
|
+
createOrganization,
|
|
107
|
+
updateAdminOrganization,
|
|
108
|
+
getActiveMember,
|
|
109
|
+
listMembers,
|
|
110
|
+
removeMember,
|
|
111
|
+
inviteMember,
|
|
112
|
+
listInvitations,
|
|
113
|
+
resendInvitation,
|
|
114
|
+
cancelInvitation,
|
|
115
|
+
acceptInvitation
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
export interface PaginationState {
|
|
2
|
+
total: number
|
|
3
|
+
page: number
|
|
4
|
+
pageSize: number
|
|
5
|
+
pageCount: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PaginatedResult<Item> {
|
|
9
|
+
items: Item[]
|
|
10
|
+
pagination: PaginationState
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PaginatedFetchContext<Filters> {
|
|
14
|
+
filters: Filters
|
|
15
|
+
page: number
|
|
16
|
+
pageSize: number
|
|
17
|
+
signal: AbortSignal
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PaginatedResourceOptions<Item, Filters> {
|
|
21
|
+
fetchPage: (context: PaginatedFetchContext<Filters>) => Promise<PaginatedResult<Item>>
|
|
22
|
+
getKey: (item: Item) => PropertyKey
|
|
23
|
+
pageSize?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const usePaginatedResource = <Item, Filters extends object>(
|
|
27
|
+
options: PaginatedResourceOptions<Item, Filters>
|
|
28
|
+
) => {
|
|
29
|
+
const defaultPageSize = options.pageSize ?? 20
|
|
30
|
+
const items = ref<Item[]>([]) as Ref<Item[]>
|
|
31
|
+
const pagination = ref<PaginationState>({
|
|
32
|
+
total: 0,
|
|
33
|
+
page: 1,
|
|
34
|
+
pageSize: defaultPageSize,
|
|
35
|
+
pageCount: 0
|
|
36
|
+
})
|
|
37
|
+
const pending = ref(false)
|
|
38
|
+
const loadingNextPage = ref(false)
|
|
39
|
+
const loadingPreviousPage = ref(false)
|
|
40
|
+
const error = ref<Error | null>(null)
|
|
41
|
+
const firstLoadedPage = ref(1)
|
|
42
|
+
const lastLoadedPage = ref(1)
|
|
43
|
+
|
|
44
|
+
let requestSequence = 0
|
|
45
|
+
let abortController: AbortController | undefined
|
|
46
|
+
let activeFilters: Filters | undefined
|
|
47
|
+
|
|
48
|
+
const loadPage = async (
|
|
49
|
+
filters: Filters,
|
|
50
|
+
loadOptions: {
|
|
51
|
+
mode?: 'replace' | 'append' | 'prepend'
|
|
52
|
+
page?: number
|
|
53
|
+
pageSize?: number
|
|
54
|
+
} = {}
|
|
55
|
+
) => {
|
|
56
|
+
const sequence = ++requestSequence
|
|
57
|
+
const page = loadOptions.page ?? 1
|
|
58
|
+
const pageSize = loadOptions.pageSize ?? defaultPageSize
|
|
59
|
+
const mode = loadOptions.mode ?? 'replace'
|
|
60
|
+
activeFilters = filters
|
|
61
|
+
abortController?.abort()
|
|
62
|
+
abortController = new AbortController()
|
|
63
|
+
pending.value = true
|
|
64
|
+
loadingNextPage.value = mode === 'append'
|
|
65
|
+
loadingPreviousPage.value = mode === 'prepend'
|
|
66
|
+
error.value = null
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const result = await options.fetchPage({
|
|
70
|
+
filters,
|
|
71
|
+
page,
|
|
72
|
+
pageSize,
|
|
73
|
+
signal: abortController.signal
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (sequence !== requestSequence) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (mode === 'append') {
|
|
80
|
+
const existingKeys = new Set(items.value.map(options.getKey))
|
|
81
|
+
items.value = [...items.value, ...result.items.filter((item) => !existingKeys.has(options.getKey(item)))]
|
|
82
|
+
lastLoadedPage.value = Math.max(lastLoadedPage.value, result.pagination.page)
|
|
83
|
+
} else if (mode === 'prepend') {
|
|
84
|
+
const existingKeys = new Set(items.value.map(options.getKey))
|
|
85
|
+
items.value = [...result.items.filter((item) => !existingKeys.has(options.getKey(item))), ...items.value]
|
|
86
|
+
firstLoadedPage.value = Math.min(firstLoadedPage.value, result.pagination.page)
|
|
87
|
+
} else {
|
|
88
|
+
items.value = result.items
|
|
89
|
+
firstLoadedPage.value = result.pagination.page
|
|
90
|
+
lastLoadedPage.value = result.pagination.page
|
|
91
|
+
}
|
|
92
|
+
pagination.value = result.pagination
|
|
93
|
+
return result
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
if (sequence !== requestSequence || (cause instanceof Error && cause.name === 'AbortError')) {
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
error.value = cause instanceof Error ? cause : new Error('Failed to load data')
|
|
99
|
+
throw cause
|
|
100
|
+
} finally {
|
|
101
|
+
if (sequence === requestSequence) {
|
|
102
|
+
pending.value = false
|
|
103
|
+
loadingNextPage.value = false
|
|
104
|
+
loadingPreviousPage.value = false
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const loadNextPage = (filters: Filters = activeFilters as Filters) => {
|
|
110
|
+
if (!filters || pending.value || lastLoadedPage.value >= pagination.value.pageCount) {
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
return loadPage(filters, {
|
|
114
|
+
mode: 'append',
|
|
115
|
+
page: lastLoadedPage.value + 1,
|
|
116
|
+
pageSize: pagination.value.pageSize
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const loadPreviousPage = (filters: Filters = activeFilters as Filters) => {
|
|
121
|
+
if (!filters || pending.value || firstLoadedPage.value <= 1) {
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
return loadPage(filters, {
|
|
125
|
+
mode: 'prepend',
|
|
126
|
+
page: firstLoadedPage.value - 1,
|
|
127
|
+
pageSize: pagination.value.pageSize
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const reset = () => {
|
|
132
|
+
abortController?.abort()
|
|
133
|
+
requestSequence++
|
|
134
|
+
items.value = []
|
|
135
|
+
pagination.value = {
|
|
136
|
+
total: 0,
|
|
137
|
+
page: 1,
|
|
138
|
+
pageSize: defaultPageSize,
|
|
139
|
+
pageCount: 0
|
|
140
|
+
}
|
|
141
|
+
pending.value = false
|
|
142
|
+
loadingNextPage.value = false
|
|
143
|
+
loadingPreviousPage.value = false
|
|
144
|
+
error.value = null
|
|
145
|
+
firstLoadedPage.value = 1
|
|
146
|
+
lastLoadedPage.value = 1
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
onScopeDispose(() => abortController?.abort())
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
items: readonly(items),
|
|
153
|
+
pagination: readonly(pagination),
|
|
154
|
+
pending: readonly(pending),
|
|
155
|
+
loadingNextPage: readonly(loadingNextPage),
|
|
156
|
+
loadingPreviousPage: readonly(loadingPreviousPage),
|
|
157
|
+
firstLoadedPage: readonly(firstLoadedPage),
|
|
158
|
+
lastLoadedPage: readonly(lastLoadedPage),
|
|
159
|
+
hasNextPage: computed(() => lastLoadedPage.value < pagination.value.pageCount),
|
|
160
|
+
hasPreviousPage: computed(() => firstLoadedPage.value > 1),
|
|
161
|
+
error: readonly(error),
|
|
162
|
+
loadPage,
|
|
163
|
+
loadNextPage,
|
|
164
|
+
loadPreviousPage,
|
|
165
|
+
reset
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PortalFeatureDefinition } from '@nuxt-customer-portal/core/shared/types/feature'
|
|
2
|
+
import {
|
|
3
|
+
mergePortalModuleMenuContributions,
|
|
4
|
+
sortPortalDashboardWidgets,
|
|
5
|
+
upsertPortalFeature
|
|
6
|
+
} from '@nuxt-customer-portal/core/shared/feature-registry'
|
|
7
|
+
|
|
8
|
+
export const usePortalFeatures = () => {
|
|
9
|
+
const features = useState<PortalFeatureDefinition[]>('portal-features', () => [])
|
|
10
|
+
const enabledModules = useState<string[] | null>('portal-enabled-modules', () => null)
|
|
11
|
+
|
|
12
|
+
const featureEnabled = (feature: PortalFeatureDefinition) => {
|
|
13
|
+
if (!enabledModules.value) {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
if (!['timesheets', 'invoices', 'service-requests', 'invoice-timesheets'].includes(feature.id)) {
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
return enabledModules.value.includes(feature.id)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const registerFeature = (feature: PortalFeatureDefinition) => {
|
|
23
|
+
features.value = upsertPortalFeature(features.value, feature)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const navigation = computed(() =>
|
|
27
|
+
features.value
|
|
28
|
+
.filter(featureEnabled)
|
|
29
|
+
.flatMap((feature) => feature.navigation ?? [])
|
|
30
|
+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const dashboardWidgets = computed(() =>
|
|
34
|
+
sortPortalDashboardWidgets(
|
|
35
|
+
features.value.filter(featureEnabled).flatMap((feature) => feature.dashboardWidgets ?? [])
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const modules = computed(() => {
|
|
40
|
+
const enabledFeatures = features.value.filter(featureEnabled)
|
|
41
|
+
return mergePortalModuleMenuContributions(
|
|
42
|
+
enabledFeatures.flatMap((feature) => feature.modules ?? []),
|
|
43
|
+
enabledFeatures.flatMap((feature) => feature.moduleMenuItems ?? [])
|
|
44
|
+
).sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const surfaces = computed(() =>
|
|
48
|
+
features.value
|
|
49
|
+
.filter(featureEnabled)
|
|
50
|
+
.flatMap((feature) => feature.surfaces ?? [])
|
|
51
|
+
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id))
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const contributionsFor = (surface: string) =>
|
|
55
|
+
computed(() => surfaces.value.filter((contribution) => contribution.surface === surface))
|
|
56
|
+
|
|
57
|
+
const clientIntegrations = computed(() =>
|
|
58
|
+
features.value
|
|
59
|
+
.filter(featureEnabled)
|
|
60
|
+
.flatMap((feature) => (feature.clientIntegration ? [feature.clientIntegration] : []))
|
|
61
|
+
.sort((left, right) => left.moduleId.localeCompare(right.moduleId))
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
features: readonly(features),
|
|
66
|
+
navigation,
|
|
67
|
+
modules,
|
|
68
|
+
dashboardWidgets,
|
|
69
|
+
surfaces,
|
|
70
|
+
clientIntegrations,
|
|
71
|
+
contributionsFor,
|
|
72
|
+
registerFeature
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const usePortalSession = () => {
|
|
2
|
+
const userStore = useUserStore()
|
|
3
|
+
const {
|
|
4
|
+
activeOrganizationId,
|
|
5
|
+
activeOrganizationRole,
|
|
6
|
+
activeOrganizationType,
|
|
7
|
+
currentUser,
|
|
8
|
+
isAdmin,
|
|
9
|
+
isAuthenticated
|
|
10
|
+
} = storeToRefs(userStore)
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
activeOrganizationId: readonly(activeOrganizationId),
|
|
14
|
+
activeOrganizationRole: readonly(activeOrganizationRole),
|
|
15
|
+
activeOrganizationType: readonly(activeOrganizationType),
|
|
16
|
+
currentUser: readonly(currentUser),
|
|
17
|
+
isSystemAdmin: readonly(isAdmin),
|
|
18
|
+
isAuthenticated: readonly(isAuthenticated)
|
|
19
|
+
}
|
|
20
|
+
}
|