@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
@@ -0,0 +1,67 @@
1
+ import type { OpenApiDocument } from './openapi-order'
2
+
3
+ type OpenApiObject = Record<string, unknown>
4
+
5
+ const AUTH_TAG = 'Authentication'
6
+ const AUTH_PATH_PREFIX = '/api/auth'
7
+
8
+ const mergeObjects = (left: unknown, right: unknown): OpenApiObject => ({
9
+ ...(left as OpenApiObject | undefined),
10
+ ...(right as OpenApiObject | undefined)
11
+ })
12
+
13
+ const prefixAuthPath = (path: string): string => `${AUTH_PATH_PREFIX}${path.startsWith('/') ? path : `/${path}`}`
14
+
15
+ const tagAuthenticationOperations = (pathItem: OpenApiObject): OpenApiObject =>
16
+ Object.fromEntries(
17
+ Object.entries(pathItem).map(([method, value]) => {
18
+ if (!value || typeof value !== 'object' || method === 'parameters') {
19
+ return [method, value]
20
+ }
21
+ return [
22
+ method,
23
+ {
24
+ ...(value as OpenApiObject),
25
+ tags: [AUTH_TAG]
26
+ }
27
+ ]
28
+ })
29
+ )
30
+
31
+ export const mergeBetterAuthOpenApi = (document: OpenApiDocument, authDocument: OpenApiDocument): OpenApiDocument => {
32
+ const components = mergeObjects(document.components, authDocument.components)
33
+ components.schemas = mergeObjects(
34
+ (document.components as OpenApiObject | undefined)?.schemas,
35
+ (authDocument.components as OpenApiObject | undefined)?.schemas
36
+ )
37
+ components.securitySchemes = mergeObjects(
38
+ (document.components as OpenApiObject | undefined)?.securitySchemes,
39
+ (authDocument.components as OpenApiObject | undefined)?.securitySchemes
40
+ )
41
+
42
+ const authPaths = Object.fromEntries(
43
+ Object.entries(authDocument.paths ?? {}).map(([path, pathItem]) => [
44
+ prefixAuthPath(path),
45
+ tagAuthenticationOperations(pathItem)
46
+ ])
47
+ )
48
+
49
+ const tags = Array.isArray(document.tags) ? document.tags : []
50
+
51
+ return {
52
+ ...document,
53
+ components,
54
+ tags: [
55
+ {
56
+ name: AUTH_TAG,
57
+ description:
58
+ 'Authentication, session, account, administration, email verification, and organization membership endpoints provided by Better Auth.'
59
+ },
60
+ ...tags.filter((tag) => (tag as OpenApiObject)?.name !== AUTH_TAG)
61
+ ],
62
+ paths: {
63
+ ...document.paths,
64
+ ...authPaths
65
+ } as OpenApiDocument['paths']
66
+ }
67
+ }
@@ -0,0 +1,129 @@
1
+ import { z } from 'zod'
2
+ import type { OpenApiDocument } from './openapi-order'
3
+
4
+ type JsonSchema = Record<string, unknown>
5
+ type OpenApiOperation = Record<string, unknown>
6
+
7
+ export interface PortalOpenApiContracts {
8
+ owner: string
9
+ query?: Record<string, z.ZodType>
10
+ body?: Record<string, z.ZodType>
11
+ requestBody?: Record<string, Record<string, unknown>>
12
+ }
13
+
14
+ const contractRegistryKey = Symbol.for('nuxt-customer-portal.openapi-contracts')
15
+ const contractRegistry = globalThis as typeof globalThis & {
16
+ [contractRegistryKey]?: PortalOpenApiContracts[]
17
+ }
18
+
19
+ export const registerPortalOpenApiContracts = (contracts: PortalOpenApiContracts): void => {
20
+ const registry = (contractRegistry[contractRegistryKey] ??= [])
21
+ const index = registry.findIndex((item) => item.owner === contracts.owner)
22
+ if (index === -1) {
23
+ registry.push(contracts)
24
+ } else {
25
+ registry[index] = contracts
26
+ }
27
+ }
28
+
29
+ const id = z.string().min(1).max(128)
30
+ const userQuerySchema = z.object({ search: z.string().trim().max(200).optional() })
31
+ const genericUserQuerySchema = z.object({
32
+ take: z.coerce.number().int().min(1).optional(),
33
+ skip: z.coerce.number().int().min(0).optional(),
34
+ sortField: z.string().optional(),
35
+ sortDirection: z.enum(['asc', 'desc']).optional(),
36
+ filters: z.string().describe('JSON-encoded array of field, operator, and value filter objects.').optional()
37
+ })
38
+
39
+ const querySchemas: Record<string, z.ZodType> = {
40
+ generalAdminUsersGet: userQuerySchema,
41
+ generalAdminUsersQueryGet: genericUserQuerySchema,
42
+ generalOrganizationsGetInvitationGet: z.object({ id })
43
+ }
44
+
45
+ const bodySchemas: Record<string, z.ZodType> = {
46
+ generalAdminOrganizationsPost: z.object({
47
+ name: z.string().min(1),
48
+ slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
49
+ }),
50
+ generalAdminOrganizationsByIdPatch: z.object({
51
+ name: z.string().min(1),
52
+ slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
53
+ officialCompanyName: z.string().min(1).max(200),
54
+ logo: z.string().max(2_800_000)
55
+ }),
56
+ generalAdminOrganizationsByIdInvitationsPost: z.object({
57
+ email: z.string().email(),
58
+ role: z.enum(['member', 'admin', 'owner'])
59
+ }),
60
+ generalAdminUsersByIdRolePatch: z.object({ role: z.enum(['user', 'admin']) }),
61
+ generalOrganizationsAcceptInvitationPost: z.object({ invitationId: id }),
62
+ generalProfilePatch: z.object({
63
+ name: z.string().min(1).max(255).optional(),
64
+ image: z.string().url().nullable().optional()
65
+ })
66
+ }
67
+
68
+ const parameterDescriptions: Record<string, string> = {
69
+ search: 'Free-text search term.',
70
+ page: 'One-based page number.',
71
+ pageSize: 'Number of records per page.',
72
+ sortBy: 'Field used to sort the results.',
73
+ sortDir: 'Sort direction.',
74
+ refresh: 'Set to 1 to refresh external provider status.',
75
+ locale: 'Locale used to render localized invoice content.',
76
+ week: 'Any date in the requested ISO week (YYYY-MM-DD).',
77
+ section: 'Administration section whose supporting data should be loaded.',
78
+ id: 'Invitation identifier.'
79
+ }
80
+
81
+ const jsonSchema = (schema: z.ZodType): JsonSchema => {
82
+ const result = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonSchema
83
+ delete result.$schema
84
+ return result
85
+ }
86
+
87
+ const queryParameters = (schema: z.ZodType): Array<Record<string, unknown>> => {
88
+ const converted = jsonSchema(schema)
89
+ const properties = (converted.properties ?? {}) as Record<string, JsonSchema>
90
+ const required = new Set((converted.required ?? []) as string[])
91
+ return Object.entries(properties).map(([name, property]) => ({
92
+ name,
93
+ in: 'query',
94
+ required: required.has(name),
95
+ description: property.description ?? parameterDescriptions[name],
96
+ schema: property
97
+ }))
98
+ }
99
+
100
+ const addContract = (operation: OpenApiOperation): void => {
101
+ const operationId = String(operation.operationId ?? '')
102
+ const registered = contractRegistry[contractRegistryKey] ?? []
103
+ const querySchema =
104
+ querySchemas[operationId] ?? registered.find((item) => item.query?.[operationId])?.query?.[operationId]
105
+ if (querySchema) {
106
+ operation.parameters = [...((operation.parameters ?? []) as unknown[]), ...queryParameters(querySchema)]
107
+ }
108
+ const bodySchema =
109
+ bodySchemas[operationId] ?? registered.find((item) => item.body?.[operationId])?.body?.[operationId]
110
+ if (bodySchema) {
111
+ operation.requestBody = {
112
+ required: true,
113
+ content: { 'application/json': { schema: jsonSchema(bodySchema) } }
114
+ }
115
+ }
116
+ const requestBody = registered.find((item) => item.requestBody?.[operationId])?.requestBody?.[operationId]
117
+ if (requestBody) {
118
+ operation.requestBody = requestBody
119
+ }
120
+ }
121
+
122
+ export const enrichOpenApiContracts = <T extends OpenApiDocument>(document: T): T => {
123
+ for (const pathItem of Object.values(document.paths ?? {})) {
124
+ for (const operation of Object.values(pathItem)) {
125
+ addContract(operation)
126
+ }
127
+ }
128
+ return document
129
+ }
@@ -0,0 +1,136 @@
1
+ type OpenApiOperation = Record<string, unknown>
2
+ type OpenApiPathItem = Record<string, OpenApiOperation>
3
+
4
+ export interface OpenApiDocument {
5
+ paths?: Record<string, OpenApiPathItem>
6
+ [key: string]: unknown
7
+ }
8
+
9
+ const METHOD_ORDER = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']
10
+
11
+ const timesheetsSectionRank = (path: string): number => {
12
+ if (path === '/api/timesheets/bootstrap') {
13
+ return 0
14
+ }
15
+ if (path.includes('/api/timesheets/timer')) {
16
+ return 10
17
+ }
18
+ if (path.includes('/api/timesheets/entries')) {
19
+ return 20
20
+ }
21
+ if (path.includes('/api/timesheets/weeks')) {
22
+ return 30
23
+ }
24
+ if (path.includes('/api/timesheets/admin/bootstrap')) {
25
+ return 40
26
+ }
27
+ if (path.includes('/api/timesheets/admin/approvals')) {
28
+ return 100
29
+ }
30
+ if (path.includes('/api/timesheets/admin/clients') || path.includes('/api/timesheets/admin/organizations')) {
31
+ return 200
32
+ }
33
+ if (path.includes('/api/timesheets/admin/projects')) {
34
+ return 300
35
+ }
36
+ if (path.includes('/api/timesheets/admin/activities')) {
37
+ return 400
38
+ }
39
+ if (path.includes('/api/timesheets/admin/tariffs')) {
40
+ return 600
41
+ }
42
+ if (path.includes('/api/timesheets/admin/settings') || path.includes('/api/timesheets/admin/email-domain')) {
43
+ return 700
44
+ }
45
+ if (path.includes('/api/timesheets/admin/report')) {
46
+ return 800
47
+ }
48
+ return 900
49
+ }
50
+
51
+ const invoiceActionRank = (path: string): number => {
52
+ if (path.endsWith('/attachments')) {
53
+ return 10
54
+ }
55
+ if (path.includes('/attachments/')) {
56
+ return 20
57
+ }
58
+ if (path.endsWith('/pdf')) {
59
+ return 30
60
+ }
61
+ if (path.endsWith('/issue')) {
62
+ return 40
63
+ }
64
+ if (path.endsWith('/email-preview')) {
65
+ return 50
66
+ }
67
+ if (path.endsWith('/email')) {
68
+ return 60
69
+ }
70
+ if (path.endsWith('/email-status')) {
71
+ return 70
72
+ }
73
+ if (path.endsWith('/reminder-preview')) {
74
+ return 80
75
+ }
76
+ if (path.endsWith('/reminder')) {
77
+ return 90
78
+ }
79
+ if (path.endsWith('/payments')) {
80
+ return 100
81
+ }
82
+ return 0
83
+ }
84
+
85
+ const pathRank = (path: string): number => {
86
+ if (path.startsWith('/api/auth')) {
87
+ return 500
88
+ }
89
+ if (path.startsWith('/api/timesheets')) {
90
+ return 2000 + timesheetsSectionRank(path) * 100
91
+ }
92
+ if (path.startsWith('/api/invoices')) {
93
+ return 3000 + invoiceActionRank(path)
94
+ }
95
+ if (path.startsWith('/api/invoice-timesheets')) {
96
+ return 3100
97
+ }
98
+ if (path.startsWith('/api/service-requests/admin')) {
99
+ return 1100
100
+ }
101
+ if (path.startsWith('/api/service-requests')) {
102
+ return 1000
103
+ }
104
+ if (path.startsWith('/api/')) {
105
+ return 0
106
+ }
107
+ return 3000
108
+ }
109
+
110
+ const pathDepth = (path: string): number => path.split('/').filter(Boolean).length
111
+
112
+ const comparePaths = ([left]: [string, OpenApiPathItem], [right]: [string, OpenApiPathItem]): number =>
113
+ pathRank(left) - pathRank(right) || pathDepth(left) - pathDepth(right) || left.localeCompare(right)
114
+
115
+ const orderMethods = (pathItem: OpenApiPathItem): OpenApiPathItem =>
116
+ Object.fromEntries(
117
+ Object.entries(pathItem).sort(([left], [right]) => {
118
+ const leftRank = METHOD_ORDER.indexOf(left)
119
+ const rightRank = METHOD_ORDER.indexOf(right)
120
+ return (leftRank === -1 ? METHOD_ORDER.length : leftRank) - (rightRank === -1 ? METHOD_ORDER.length : rightRank)
121
+ })
122
+ )
123
+
124
+ export const orderOpenApiDocument = <T extends OpenApiDocument>(document: T): T => {
125
+ if (!document.paths) {
126
+ return document
127
+ }
128
+ return {
129
+ ...document,
130
+ paths: Object.fromEntries(
131
+ Object.entries(document.paths)
132
+ .sort(comparePaths)
133
+ .map(([path, item]) => [path, orderMethods(item)])
134
+ )
135
+ }
136
+ }
@@ -0,0 +1,179 @@
1
+ import { db } from './db'
2
+ import { member as memberTable, organization as organizationTable } from '../db/schema/auth-schema'
3
+ import { eq, and } from 'drizzle-orm'
4
+ import { user as userRole } from '../../shared/permissions'
5
+ import type { MemberRole, Organization } from '@nuxt-customer-portal/core/shared/types/index'
6
+
7
+ // Type for role statements structure
8
+ type RoleStatements = Record<string, readonly string[]>
9
+
10
+ // Helper to extract statements from a role object
11
+ const getRoleStatements = (role: unknown): RoleStatements => {
12
+ // Roles created with ac.newRole() may have a statements property or be the statements object itself
13
+ if (role && typeof role === 'object' && 'statements' in role) {
14
+ return (role as { statements: RoleStatements }).statements
15
+ }
16
+ return role as RoleStatements
17
+ }
18
+
19
+ /**
20
+ * Get organization role permissions mapping
21
+ * Organization owners get full permissions including organization management
22
+ * Organization admins get most permissions except organization deletion
23
+ * Members get basic service-request permissions (same as user role)
24
+ */
25
+ export const getOrganizationRolePermissions = (orgRole: MemberRole | null | undefined): RoleStatements => {
26
+ if (!orgRole) {
27
+ // No organization role, return user role permissions
28
+ return getRoleStatements(userRole)
29
+ }
30
+
31
+ // Get base user role permissions
32
+ const userStatements = getRoleStatements(userRole)
33
+ const permissions: RoleStatements = { ...userStatements }
34
+
35
+ if (orgRole === 'owner') {
36
+ // Owners get full organization management permissions
37
+ permissions['organization'] = ['read', 'update', 'delete']
38
+ permissions['member'] = ['read', 'list', 'create', 'update', 'delete', 'update-name']
39
+ permissions['invitation'] = ['list', 'create', 'cancel', 'resend']
40
+ } else if (orgRole === 'admin') {
41
+ // Admins can manage members and invitations but not delete organization
42
+ permissions['organization'] = ['read', 'update'] // No 'delete' for admins
43
+ permissions['member'] = ['read', 'list', 'create', 'update', 'delete', 'update-name']
44
+ permissions['invitation'] = ['list', 'create', 'cancel', 'resend']
45
+ } else if (orgRole === 'member') {
46
+ // Members can see the organization they belong to, but the member and
47
+ // invitation directories are administrative data.
48
+ permissions['organization'] = ['read']
49
+ }
50
+
51
+ return permissions
52
+ }
53
+
54
+ /**
55
+ * Get the effective permissions for a user based on their system role and organization role
56
+ */
57
+ export const getUserPermissions = async (
58
+ userId: string,
59
+ _systemRole: string | null | undefined,
60
+ organizationId: string | null | undefined
61
+ ): Promise<{ permissions: RoleStatements; orgRole: MemberRole | null; organization: Organization | null }> => {
62
+ let roleDefinition: unknown = userRole
63
+ let orgRole: MemberRole | null = null
64
+ let organization: Organization | null = null
65
+
66
+ if (organizationId) {
67
+ try {
68
+ // Get the user's role in the organization
69
+ const [member] = await db
70
+ .select({ role: memberTable.role })
71
+ .from(memberTable)
72
+ .where(and(eq(memberTable.userId, userId), eq(memberTable.organizationId, organizationId)))
73
+ .limit(1)
74
+
75
+ if (member) {
76
+ orgRole = member.role as MemberRole
77
+ roleDefinition = getOrganizationRolePermissions(orgRole)
78
+ }
79
+
80
+ // Get organization details
81
+ const [org] = await db.select().from(organizationTable).where(eq(organizationTable.id, organizationId)).limit(1)
82
+
83
+ if (org) {
84
+ organization = org as Organization
85
+ }
86
+ } catch (error) {
87
+ console.error('Error fetching organization role or details:', error)
88
+ // Fall back to default role if there's an error
89
+ }
90
+ }
91
+
92
+ const roleStatements = getRoleStatements(roleDefinition)
93
+ return {
94
+ permissions: roleStatements,
95
+ orgRole,
96
+ organization
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Check if a user has a specific permission
102
+ * @param userId The user ID to check
103
+ * @param systemRole The user's system role (from user.role)
104
+ * @param organizationId The organization ID to check permissions for (optional)
105
+ * @param subject The permission subject (e.g., 'organization', 'member', 'invitation')
106
+ * @param action The permission action (e.g., 'read', 'update', 'delete')
107
+ * @returns true if the user has the permission, false otherwise
108
+ */
109
+ export const hasPermission = async (
110
+ userId: string,
111
+ systemRole: string | null | undefined,
112
+ organizationId: string | null | undefined,
113
+ subject: string,
114
+ action: string
115
+ ): Promise<boolean> => {
116
+ const { permissions } = await getUserPermissions(userId, systemRole, organizationId)
117
+ const subjectPermissions = permissions[subject] || []
118
+ return Array.isArray(subjectPermissions) && subjectPermissions.includes(action)
119
+ }
120
+
121
+ /**
122
+ * Check if a user has permission for an organization operation
123
+ * This is a convenience function that extracts user info from session and checks permission
124
+ * @param session The Better Auth session object
125
+ * @param organizationId The organization ID to check permissions for
126
+ * @param subject The permission subject
127
+ * @param action The permission action
128
+ * @returns true if the user has the permission, false otherwise
129
+ */
130
+ export const checkOrganizationPermission = async (
131
+ session: { user: { id: string; role?: string } },
132
+ organizationId: string,
133
+ subject: string,
134
+ action: string
135
+ ): Promise<boolean> => {
136
+ if (!session?.user) {
137
+ return false
138
+ }
139
+
140
+ return await hasPermission(session.user.id, session.user.role, organizationId, subject, action)
141
+ }
142
+
143
+ /**
144
+ * Get user's organization role for a specific organization
145
+ * @param userId The user ID
146
+ * @param organizationId The organization ID
147
+ * @returns The organization role or null if not a member
148
+ */
149
+ export const getUserOrganizationRole = async (userId: string, organizationId: string): Promise<MemberRole | null> => {
150
+ try {
151
+ const [member] = await db
152
+ .select({ role: memberTable.role })
153
+ .from(memberTable)
154
+ .where(and(eq(memberTable.userId, userId), eq(memberTable.organizationId, organizationId)))
155
+ .limit(1)
156
+
157
+ return member ? (member.role as MemberRole) : null
158
+ } catch (error) {
159
+ console.error('Error fetching organization role:', error)
160
+ return null
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Check if a user is a member of an organization
166
+ * System admins are considered to have access to all organizations
167
+ * @param userId The user ID
168
+ * @param organizationId The organization ID
169
+ * @param systemRole The user's system role (optional, for admin check)
170
+ * @returns true if the user is a member or is a system admin
171
+ */
172
+ export const isOrganizationMember = async (
173
+ userId: string,
174
+ organizationId: string,
175
+ _systemRole?: string | null
176
+ ): Promise<boolean> => {
177
+ const role = await getUserOrganizationRole(userId, organizationId)
178
+ return role !== null
179
+ }