@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,198 @@
|
|
|
1
|
+
// Core authentication and organization schema owned by portal core.
|
|
2
|
+
|
|
3
|
+
import { sql } from 'drizzle-orm'
|
|
4
|
+
import { pgTable, text, timestamp, boolean, check, index, uniqueIndex, jsonb } from 'drizzle-orm/pg-core'
|
|
5
|
+
|
|
6
|
+
export const user = pgTable(
|
|
7
|
+
'user',
|
|
8
|
+
{
|
|
9
|
+
id: text('id').primaryKey(),
|
|
10
|
+
name: text('name').notNull(),
|
|
11
|
+
email: text('email').notNull().unique(),
|
|
12
|
+
emailVerified: boolean('email_verified').default(false).notNull(),
|
|
13
|
+
image: text('image'),
|
|
14
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
15
|
+
updatedAt: timestamp('updated_at')
|
|
16
|
+
.defaultNow()
|
|
17
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
18
|
+
.notNull(),
|
|
19
|
+
role: text('role').default('user').notNull(),
|
|
20
|
+
banned: boolean('banned').default(false),
|
|
21
|
+
banReason: text('ban_reason'),
|
|
22
|
+
banExpires: timestamp('ban_expires')
|
|
23
|
+
},
|
|
24
|
+
(table) => [index('user_name_idx').on(table.name), uniqueIndex('user_email_idx').on(table.email)]
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
export const session = pgTable(
|
|
28
|
+
'session',
|
|
29
|
+
{
|
|
30
|
+
id: text('id').primaryKey(),
|
|
31
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
32
|
+
token: text('token').notNull().unique(),
|
|
33
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
34
|
+
updatedAt: timestamp('updated_at')
|
|
35
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
36
|
+
.notNull(),
|
|
37
|
+
ipAddress: text('ip_address'),
|
|
38
|
+
userAgent: text('user_agent'),
|
|
39
|
+
userId: text('user_id')
|
|
40
|
+
.notNull()
|
|
41
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
42
|
+
activeOrganizationId: text('active_organization_id'),
|
|
43
|
+
impersonatedBy: text('impersonated_by')
|
|
44
|
+
},
|
|
45
|
+
(table) => [
|
|
46
|
+
index('session_user_id_idx').on(table.userId),
|
|
47
|
+
index('session_active_organization_id_idx').on(table.activeOrganizationId)
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
export const account = pgTable(
|
|
52
|
+
'account',
|
|
53
|
+
{
|
|
54
|
+
id: text('id').primaryKey(),
|
|
55
|
+
accountId: text('account_id').notNull(),
|
|
56
|
+
providerId: text('provider_id').notNull(),
|
|
57
|
+
userId: text('user_id')
|
|
58
|
+
.notNull()
|
|
59
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
60
|
+
accessToken: text('access_token'),
|
|
61
|
+
refreshToken: text('refresh_token'),
|
|
62
|
+
idToken: text('id_token'),
|
|
63
|
+
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
|
64
|
+
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
|
65
|
+
scope: text('scope'),
|
|
66
|
+
password: text('password'),
|
|
67
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
68
|
+
updatedAt: timestamp('updated_at')
|
|
69
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
70
|
+
.notNull()
|
|
71
|
+
},
|
|
72
|
+
(table) => [
|
|
73
|
+
index('account_user_id_idx').on(table.userId),
|
|
74
|
+
index('account_account_id_idx').on(table.accountId),
|
|
75
|
+
index('account_provider_id_idx').on(table.providerId)
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
export const verification = pgTable(
|
|
80
|
+
'verification',
|
|
81
|
+
{
|
|
82
|
+
id: text('id').primaryKey(),
|
|
83
|
+
identifier: text('identifier').notNull(),
|
|
84
|
+
value: text('value').notNull(),
|
|
85
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
86
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
87
|
+
updatedAt: timestamp('updated_at')
|
|
88
|
+
.defaultNow()
|
|
89
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
90
|
+
.notNull()
|
|
91
|
+
},
|
|
92
|
+
(table) => [
|
|
93
|
+
index('verification_identifier_idx').on(table.identifier),
|
|
94
|
+
index('verification_value_idx').on(table.value)
|
|
95
|
+
]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
export const organization = pgTable(
|
|
99
|
+
'organization',
|
|
100
|
+
{
|
|
101
|
+
id: text('id').primaryKey(),
|
|
102
|
+
name: text('name').notNull(),
|
|
103
|
+
slug: text('slug').notNull().unique(),
|
|
104
|
+
logo: text('logo'),
|
|
105
|
+
createdAt: timestamp('created_at').notNull(),
|
|
106
|
+
metadata: text('metadata'),
|
|
107
|
+
organizationType: text('organization_type').notNull()
|
|
108
|
+
},
|
|
109
|
+
(table) => [
|
|
110
|
+
index('organization_slug_idx').on(table.slug),
|
|
111
|
+
check('organization_type_check', sql`${table.organizationType} IN ('PROVIDER', 'CLIENT')`),
|
|
112
|
+
uniqueIndex('organization_single_provider_uidx')
|
|
113
|
+
.on(table.organizationType)
|
|
114
|
+
.where(sql`${table.organizationType} = 'PROVIDER'`)
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
export const member = pgTable(
|
|
119
|
+
'member',
|
|
120
|
+
{
|
|
121
|
+
id: text('id').primaryKey(),
|
|
122
|
+
organizationId: text('organization_id')
|
|
123
|
+
.notNull()
|
|
124
|
+
.references(() => organization.id, { onDelete: 'cascade' }),
|
|
125
|
+
userId: text('user_id')
|
|
126
|
+
.notNull()
|
|
127
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
128
|
+
role: text('role').default('member').notNull(),
|
|
129
|
+
phone: text('phone'),
|
|
130
|
+
jobTitle: text('job_title'),
|
|
131
|
+
createdAt: timestamp('created_at').notNull()
|
|
132
|
+
},
|
|
133
|
+
(table) => [
|
|
134
|
+
index('member_organization_id_idx').on(table.organizationId),
|
|
135
|
+
index('member_user_id_idx').on(table.userId)
|
|
136
|
+
]
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
export const portalEmailSettings = pgTable('portal_email_settings', {
|
|
140
|
+
id: boolean('id').primaryKey().default(true),
|
|
141
|
+
provider: text('provider').default('RESEND').notNull(),
|
|
142
|
+
encryptedApiKey: text('encrypted_api_key'),
|
|
143
|
+
keyFingerprint: text('key_fingerprint'),
|
|
144
|
+
keyLastFour: text('key_last_four'),
|
|
145
|
+
fromName: text('from_name'),
|
|
146
|
+
fromEmail: text('from_email'),
|
|
147
|
+
defaultLocale: text('default_locale').default('en').notNull(),
|
|
148
|
+
htmlTemplate: text('html_template'),
|
|
149
|
+
textOverrides: jsonb('text_overrides').$type<Record<string, unknown>>().default({}).notNull(),
|
|
150
|
+
configuredById: text('configured_by_id').references(() => user.id, { onDelete: 'set null' }),
|
|
151
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
152
|
+
updatedAt: timestamp('updated_at')
|
|
153
|
+
.defaultNow()
|
|
154
|
+
.$onUpdate(() => /* @__PURE__ */ new Date())
|
|
155
|
+
.notNull()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Retained for one compatibility release. Runtime email delivery no longer reads this table.
|
|
159
|
+
export const organizationEmailCredential = pgTable('organization_email_credential', {
|
|
160
|
+
organizationId: text('organization_id')
|
|
161
|
+
.primaryKey()
|
|
162
|
+
.references(() => organization.id, { onDelete: 'cascade' }),
|
|
163
|
+
provider: text('provider').default('RESEND').notNull(),
|
|
164
|
+
apiKey: text('api_key'),
|
|
165
|
+
keyFingerprint: text('key_fingerprint'),
|
|
166
|
+
keyLastFour: text('key_last_four'),
|
|
167
|
+
configuredById: text('configured_by_id').references(() => user.id, { onDelete: 'set null' }),
|
|
168
|
+
removedById: text('removed_by_id').references(() => user.id, { onDelete: 'set null' }),
|
|
169
|
+
removedAt: timestamp('removed_at'),
|
|
170
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
171
|
+
updatedAt: timestamp('updated_at')
|
|
172
|
+
.defaultNow()
|
|
173
|
+
.$onUpdate(() => new Date())
|
|
174
|
+
.notNull()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
export const invitation = pgTable(
|
|
178
|
+
'invitation',
|
|
179
|
+
{
|
|
180
|
+
id: text('id').primaryKey(),
|
|
181
|
+
organizationId: text('organization_id')
|
|
182
|
+
.notNull()
|
|
183
|
+
.references(() => organization.id, { onDelete: 'cascade' }),
|
|
184
|
+
email: text('email').notNull(),
|
|
185
|
+
role: text('role'),
|
|
186
|
+
status: text('status').default('pending').notNull(),
|
|
187
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
188
|
+
inviterId: text('inviter_id')
|
|
189
|
+
.notNull()
|
|
190
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
191
|
+
createdAt: timestamp('created_at').defaultNow().notNull()
|
|
192
|
+
},
|
|
193
|
+
(table) => [
|
|
194
|
+
index('invitation_organization_id_idx').on(table.organizationId),
|
|
195
|
+
index('invitation_inviter_id_idx').on(table.inviterId),
|
|
196
|
+
index('invitation_email_idx').on(table.email)
|
|
197
|
+
]
|
|
198
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './auth-schema'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createError, defineEventHandler, setResponseHeader } from 'h3'
|
|
2
|
+
import { auth } from '../utils/auth'
|
|
3
|
+
|
|
4
|
+
const API_DOCS_PATH = '/api-docs'
|
|
5
|
+
|
|
6
|
+
const isApiDocsRequest = (path: string): boolean => path === API_DOCS_PATH || path.startsWith(`${API_DOCS_PATH}/`)
|
|
7
|
+
|
|
8
|
+
export default defineEventHandler(async (event) => {
|
|
9
|
+
if (!isApiDocsRequest(event.path)) {
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
setResponseHeader(event, 'Cache-Control', 'private, no-store')
|
|
14
|
+
|
|
15
|
+
const session = await auth.api.getSession({
|
|
16
|
+
headers: event.headers
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
if (!session?.user) {
|
|
20
|
+
throw createError({
|
|
21
|
+
statusCode: 401,
|
|
22
|
+
statusMessage: 'Unauthorized',
|
|
23
|
+
message: 'Authentication is required to access the API documentation'
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineEventHandler, type H3Event, createError } from 'h3'
|
|
2
|
+
import { auth } from '../utils/auth'
|
|
3
|
+
|
|
4
|
+
async function isAuthenticated(event: H3Event): Promise<boolean> {
|
|
5
|
+
const session = await auth.api.getSession({
|
|
6
|
+
headers: event.headers
|
|
7
|
+
})
|
|
8
|
+
if (!session) {
|
|
9
|
+
return false
|
|
10
|
+
}
|
|
11
|
+
return !!session.user
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default defineEventHandler(async (event) => {
|
|
15
|
+
const url = event.node.req.url
|
|
16
|
+
|
|
17
|
+
if (!url || !url.startsWith('/api/')) {
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
const unprotectedPaths = [
|
|
21
|
+
'/api/auth/',
|
|
22
|
+
'/api/_nuxt_icon',
|
|
23
|
+
'/api/health',
|
|
24
|
+
'/api/portal/bootstrap',
|
|
25
|
+
'/api/portal/public'
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
const isUnprotected = unprotectedPaths.some((path) => url.startsWith(path))
|
|
29
|
+
|
|
30
|
+
if (isUnprotected) {
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Allow specific GET requests for single questionnaires
|
|
35
|
+
if (event.method === 'GET' && url.match(/^\/api\/questionnaires\/[^/]+$/)) {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Allow POST (index), PUT (specific response), and GET (specific response) to questionnaire-responses for anonymous users
|
|
40
|
+
const isQuestionnaireResponseIndex = url === '/api/questionnaire-responses'
|
|
41
|
+
const isSpecificQuestionnaireResponse = url.startsWith('/api/questionnaire-responses/')
|
|
42
|
+
|
|
43
|
+
if (
|
|
44
|
+
(isQuestionnaireResponseIndex && event.method === 'POST') ||
|
|
45
|
+
(isSpecificQuestionnaireResponse && (event.method === 'PUT' || event.method === 'GET'))
|
|
46
|
+
) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const authenticated = await isAuthenticated(event)
|
|
51
|
+
if (authenticated) {
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.warn(`Unauthorized access attempt to: ${url}`)
|
|
56
|
+
throw createError({
|
|
57
|
+
statusCode: 401,
|
|
58
|
+
statusMessage: 'Unauthorized',
|
|
59
|
+
message: 'Authentication required'
|
|
60
|
+
})
|
|
61
|
+
})
|
package/server/portal.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { H3Event } from 'h3'
|
|
2
|
+
import { and, asc, eq, ilike, inArray, ne } from 'drizzle-orm'
|
|
3
|
+
import { nanoid } from 'nanoid'
|
|
4
|
+
import type {
|
|
5
|
+
PortalFeaturePolicy,
|
|
6
|
+
PortalOrganizationRole,
|
|
7
|
+
PortalOrganizationType
|
|
8
|
+
} from '@nuxt-customer-portal/core/shared/types/feature'
|
|
9
|
+
import { isPortalActionAllowed } from '@nuxt-customer-portal/core/shared/feature-registry'
|
|
10
|
+
import { getActiveOrganizationId, type PortalSession } from '@nuxt-customer-portal/core/shared/portal-session'
|
|
11
|
+
import { member, organization, user } from './db/schema/auth-schema'
|
|
12
|
+
import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
|
|
13
|
+
import { db } from '@nuxt-customer-portal/core/server/utils/db'
|
|
14
|
+
import { getUserOrganizationRole } from '@nuxt-customer-portal/core/server/utils/permissions'
|
|
15
|
+
|
|
16
|
+
export { db }
|
|
17
|
+
export type { PortalSession } from '@nuxt-customer-portal/core/shared/portal-session'
|
|
18
|
+
|
|
19
|
+
export const getSession = async (event: H3Event): Promise<PortalSession | null> => {
|
|
20
|
+
const session = await auth.api.getSession({ headers: event.headers })
|
|
21
|
+
return session as PortalSession | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const requireSession = async (event: H3Event): Promise<PortalSession> => {
|
|
25
|
+
const session = await getSession(event)
|
|
26
|
+
if (!session?.user) {
|
|
27
|
+
throw createError({ statusCode: 401, message: 'Unauthorized' })
|
|
28
|
+
}
|
|
29
|
+
return session
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const requireActiveOrganization = (session: PortalSession): string => {
|
|
33
|
+
// Better Auth has returned both shapes across versions/configurations.
|
|
34
|
+
// Keep the layer adapter compatible with either so feature APIs do not
|
|
35
|
+
// lose their tenant context during a layer or auth upgrade.
|
|
36
|
+
const organizationId = getActiveOrganizationId(session)
|
|
37
|
+
if (!organizationId) {
|
|
38
|
+
throw createError({ statusCode: 400, message: 'No active organization' })
|
|
39
|
+
}
|
|
40
|
+
return organizationId
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const getPortalOrganization = async (organizationId: string) => {
|
|
44
|
+
const [selected] = await db.select().from(organization).where(eq(organization.id, organizationId)).limit(1)
|
|
45
|
+
return selected ?? null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const requireOrganizationContext = async (session: PortalSession) => {
|
|
49
|
+
const organizationId = requireActiveOrganization(session)
|
|
50
|
+
const [selected, role] = await Promise.all([
|
|
51
|
+
getPortalOrganization(organizationId),
|
|
52
|
+
getUserOrganizationRole(session.user.id, organizationId) as Promise<PortalOrganizationRole | null>
|
|
53
|
+
])
|
|
54
|
+
if (!selected || !role) {
|
|
55
|
+
throw createError({ statusCode: 403, message: 'Organization membership required' })
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
organizationId,
|
|
59
|
+
organizationType: selected.organizationType as PortalOrganizationType,
|
|
60
|
+
organization: selected,
|
|
61
|
+
role
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const authorize = async <Action extends string>(
|
|
66
|
+
session: PortalSession,
|
|
67
|
+
organizationId: string,
|
|
68
|
+
policy: PortalFeaturePolicy<Action>,
|
|
69
|
+
action: Action
|
|
70
|
+
): Promise<void> => {
|
|
71
|
+
const [role, selected] = await Promise.all([
|
|
72
|
+
getUserOrganizationRole(session.user.id, organizationId) as Promise<PortalOrganizationRole | null>,
|
|
73
|
+
getPortalOrganization(organizationId)
|
|
74
|
+
])
|
|
75
|
+
if (!selected || !isPortalActionAllowed(policy, role, action, selected.organizationType as PortalOrganizationType)) {
|
|
76
|
+
throw createError({ statusCode: 403, message: 'Access denied' })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const hasFeatureAccess = async <Action extends string>(
|
|
81
|
+
session: PortalSession,
|
|
82
|
+
organizationId: string,
|
|
83
|
+
policy: PortalFeaturePolicy<Action>,
|
|
84
|
+
action: Action
|
|
85
|
+
): Promise<boolean> => {
|
|
86
|
+
const [role, selected] = await Promise.all([
|
|
87
|
+
getUserOrganizationRole(session.user.id, organizationId) as Promise<PortalOrganizationRole | null>,
|
|
88
|
+
getPortalOrganization(organizationId)
|
|
89
|
+
])
|
|
90
|
+
return Boolean(
|
|
91
|
+
selected && isPortalActionAllowed(policy, role, action, selected.organizationType as PortalOrganizationType)
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const requireFeatureAccess = async <Action extends string>(
|
|
96
|
+
event: H3Event,
|
|
97
|
+
policy: PortalFeaturePolicy<Action>,
|
|
98
|
+
action: Action
|
|
99
|
+
) => {
|
|
100
|
+
const session = await requireSession(event)
|
|
101
|
+
const organizationId = requireActiveOrganization(session)
|
|
102
|
+
const [selected, role] = await Promise.all([
|
|
103
|
+
getPortalOrganization(organizationId),
|
|
104
|
+
getUserOrganizationRole(session.user.id, organizationId) as Promise<PortalOrganizationRole | null>
|
|
105
|
+
])
|
|
106
|
+
if (!selected || !isPortalActionAllowed(policy, role, action, selected.organizationType as PortalOrganizationType)) {
|
|
107
|
+
throw createError({ statusCode: 403, message: 'Access denied' })
|
|
108
|
+
}
|
|
109
|
+
return { session, organizationId, organizationType: selected.organizationType as PortalOrganizationType, role }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const requireActiveOrganizationRole = async (event: H3Event) => {
|
|
113
|
+
const session = await requireSession(event)
|
|
114
|
+
return { session, ...(await requireOrganizationContext(session)) }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Stable portal-core projections used by feature layers. Keeping these here
|
|
119
|
+
* prevents features from importing host authentication tables at runtime.
|
|
120
|
+
*/
|
|
121
|
+
export const listPortalOrganizationMembers = async (organizationId: string) =>
|
|
122
|
+
db
|
|
123
|
+
.select({
|
|
124
|
+
id: user.id,
|
|
125
|
+
name: user.name,
|
|
126
|
+
email: user.email,
|
|
127
|
+
image: user.image,
|
|
128
|
+
organizationRole: member.role,
|
|
129
|
+
phone: member.phone,
|
|
130
|
+
jobTitle: member.jobTitle
|
|
131
|
+
})
|
|
132
|
+
.from(member)
|
|
133
|
+
.innerJoin(user, eq(user.id, member.userId))
|
|
134
|
+
.where(eq(member.organizationId, organizationId))
|
|
135
|
+
.orderBy(asc(user.name), asc(user.email))
|
|
136
|
+
|
|
137
|
+
export const searchPortalOrganizations = async (workspaceOrganizationId: string, search?: string) =>
|
|
138
|
+
db
|
|
139
|
+
.select({
|
|
140
|
+
id: organization.id,
|
|
141
|
+
name: organization.name,
|
|
142
|
+
slug: organization.slug,
|
|
143
|
+
logo: organization.logo,
|
|
144
|
+
metadata: organization.metadata,
|
|
145
|
+
organizationType: organization.organizationType
|
|
146
|
+
})
|
|
147
|
+
.from(organization)
|
|
148
|
+
.where(
|
|
149
|
+
and(ne(organization.id, workspaceOrganizationId), search ? ilike(organization.name, `%${search}%`) : undefined)
|
|
150
|
+
)
|
|
151
|
+
.orderBy(asc(organization.name))
|
|
152
|
+
.limit(50)
|
|
153
|
+
|
|
154
|
+
export const listPortalOrganizationsForUser = async (userId: string) => {
|
|
155
|
+
const selection = {
|
|
156
|
+
id: organization.id,
|
|
157
|
+
name: organization.name,
|
|
158
|
+
slug: organization.slug,
|
|
159
|
+
logo: organization.logo,
|
|
160
|
+
organizationType: organization.organizationType
|
|
161
|
+
}
|
|
162
|
+
return db
|
|
163
|
+
.select(selection)
|
|
164
|
+
.from(member)
|
|
165
|
+
.innerJoin(organization, eq(organization.id, member.organizationId))
|
|
166
|
+
.where(eq(member.userId, userId))
|
|
167
|
+
.orderBy(asc(organization.name))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const getPortalOrganizationsByIds = async (organizationIds: string[]) => {
|
|
171
|
+
if (!organizationIds.length) {
|
|
172
|
+
return []
|
|
173
|
+
}
|
|
174
|
+
return db
|
|
175
|
+
.select({
|
|
176
|
+
id: organization.id,
|
|
177
|
+
name: organization.name,
|
|
178
|
+
slug: organization.slug,
|
|
179
|
+
logo: organization.logo,
|
|
180
|
+
metadata: organization.metadata,
|
|
181
|
+
organizationType: organization.organizationType
|
|
182
|
+
})
|
|
183
|
+
.from(organization)
|
|
184
|
+
.where(inArray(organization.id, organizationIds))
|
|
185
|
+
.orderBy(asc(organization.name))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const createPortalOrganizationRecord = async (input: {
|
|
189
|
+
name: string
|
|
190
|
+
slug: string
|
|
191
|
+
logo?: string | null
|
|
192
|
+
organizationType?: PortalOrganizationType
|
|
193
|
+
}) => {
|
|
194
|
+
const [created] = await db
|
|
195
|
+
.insert(organization)
|
|
196
|
+
.values({
|
|
197
|
+
id: nanoid(),
|
|
198
|
+
name: input.name,
|
|
199
|
+
slug: input.slug,
|
|
200
|
+
logo: input.logo ?? null,
|
|
201
|
+
organizationType: input.organizationType ?? 'CLIENT',
|
|
202
|
+
createdAt: new Date()
|
|
203
|
+
})
|
|
204
|
+
.returning({
|
|
205
|
+
id: organization.id,
|
|
206
|
+
name: organization.name,
|
|
207
|
+
slug: organization.slug,
|
|
208
|
+
logo: organization.logo,
|
|
209
|
+
organizationType: organization.organizationType
|
|
210
|
+
})
|
|
211
|
+
return created
|
|
212
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { fetchWithEvent, getRequestURL } from 'h3'
|
|
2
|
+
import { auth } from '../../utils/auth'
|
|
3
|
+
import { mergeBetterAuthOpenApi } from '../../utils/openapi-auth'
|
|
4
|
+
import { enrichOpenApiContracts } from '../../utils/openapi-contracts'
|
|
5
|
+
import { orderOpenApiDocument, type OpenApiDocument } from '../../utils/openapi-order'
|
|
6
|
+
|
|
7
|
+
defineRouteMeta({
|
|
8
|
+
openAPI: {
|
|
9
|
+
tags: ['Internal'],
|
|
10
|
+
summary: 'Get the ordered OpenAPI document',
|
|
11
|
+
description:
|
|
12
|
+
'Returns the generated OpenAPI document in a stable, domain-oriented order for the documentation interfaces.',
|
|
13
|
+
'x-scalar-ignore': true
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
export default defineEventHandler(async (event) => {
|
|
18
|
+
const response = await fetchWithEvent(event, new URL('/api-docs/openapi.raw.json', getRequestURL(event)))
|
|
19
|
+
const document = (await response.json()) as OpenApiDocument
|
|
20
|
+
const authDocument = (await auth.api.generateOpenAPISchema()) as unknown as OpenApiDocument
|
|
21
|
+
return orderOpenApiDocument(enrichOpenApiContracts(mergeBetterAuthOpenApi(document, authDocument)))
|
|
22
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function parseSystemAdminEmails(value: string | undefined) {
|
|
2
|
+
return new Set(
|
|
3
|
+
(value || '')
|
|
4
|
+
.split(',')
|
|
5
|
+
.map((email) => email.trim().toLowerCase())
|
|
6
|
+
.filter(Boolean)
|
|
7
|
+
)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isSystemAdminEmail(email: string | null | undefined, allowlist: ReadonlySet<string>) {
|
|
11
|
+
return Boolean(email && allowlist.has(email.trim().toLowerCase()))
|
|
12
|
+
}
|