@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
|
@@ -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
|
+
}
|