@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/app/composables/useAutoPagination.ts +30 -0
  5. package/app/composables/useOrganization.ts +117 -0
  6. package/app/composables/usePaginatedResource.ts +167 -0
  7. package/app/composables/usePortalFeatures.ts +74 -0
  8. package/app/composables/usePortalSession.ts +20 -0
  9. package/app/composables/useUserStore.ts +460 -0
  10. package/app/middleware/auth.global.ts +32 -0
  11. package/app/plugins/auth.ts +28 -0
  12. package/app/plugins/portal-core-feature.ts +3 -0
  13. package/app/types/index.d.ts +75 -0
  14. package/app/utils/auth-client.ts +31 -0
  15. package/app/utils/permission-utils.ts +21 -0
  16. package/app/utils/random.ts +9 -0
  17. package/i18n/locales/en.json +81 -0
  18. package/i18n/locales/nl.json +81 -0
  19. package/migrations/0000_baseline.sql +119 -0
  20. package/migrations/0001_organization_types.sql +12 -0
  21. package/migrations/0002_invitation_created_at.sql +1 -0
  22. package/migrations/0003_system_admin_role.sql +5 -0
  23. package/migrations/0004_provider_organization_type.sql +16 -0
  24. package/migrations/0005_portal_email_settings.sql +15 -0
  25. package/migrations/meta/0000_snapshot.json +880 -0
  26. package/migrations/meta/_journal.json +13 -0
  27. package/nuxt.config.ts +55 -0
  28. package/package.json +66 -0
  29. package/portal.manifest.mjs +8 -0
  30. package/server/api/auth/[...].ts +18 -0
  31. package/server/api/auth/permissions.get.ts +64 -0
  32. package/server/api/notifications.ts +294 -0
  33. package/server/db/core-schema.ts +1 -0
  34. package/server/db/schema/auth-schema.ts +198 -0
  35. package/server/db/schema/index.ts +1 -0
  36. package/server/middleware/api-docs-auth.ts +26 -0
  37. package/server/middleware/auth.ts +61 -0
  38. package/server/portal.ts +212 -0
  39. package/server/routes/api-docs/openapi.json.get.ts +22 -0
  40. package/server/utils/admin-email-allowlist.ts +12 -0
  41. package/server/utils/auth.ts +326 -0
  42. package/server/utils/business-hooks.ts +28 -0
  43. package/server/utils/db.ts +18 -0
  44. package/server/utils/email-template.html +105 -0
  45. package/server/utils/email-texts.ts +110 -0
  46. package/server/utils/email.ts +37 -0
  47. package/server/utils/invitation-management.ts +29 -0
  48. package/server/utils/openapi-auth.ts +67 -0
  49. package/server/utils/openapi-contracts.ts +129 -0
  50. package/server/utils/openapi-order.ts +136 -0
  51. package/server/utils/permissions.ts +179 -0
  52. package/server/utils/portal-email.ts +424 -0
  53. package/server/utils/query-builder.ts +270 -0
  54. package/server/utils/route-meta.ts +28 -0
  55. package/shared/core-feature.ts +152 -0
  56. package/shared/feature-registry.ts +62 -0
  57. package/shared/invitation-validation.ts +8 -0
  58. package/shared/organization-avatar.ts +11 -0
  59. package/shared/permissions.ts +26 -0
  60. package/shared/portal-session.ts +17 -0
  61. package/shared/types/feature.ts +130 -0
  62. package/shared/types/index.ts +40 -0
  63. package/shared/types/sections/api.ts +134 -0
  64. package/shared/types/sections/auth.ts +71 -0
  65. package/shared/types/sections/database.ts +86 -0
  66. package/shared/types/sections/queryBuilder.ts +31 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # @nuxt-customer-portal/core
2
+
3
+ ## 0.3.0
4
+
5
+ Declare the Nuxt UI dependency used by the package's public components and types,
6
+ so npm installations do not depend on another package's dependency layout.
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
+ }