@nuxt-customer-portal/core 0.3.3 → 0.4.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 +35 -0
- package/README.md +4 -0
- package/app/components/PortalTimezoneSelect.vue +28 -0
- package/app/composables/useClientConfiguration.ts +8 -0
- package/app/composables/usePaginatedResource.ts +2 -1
- package/app/composables/usePortalSession.ts +2 -0
- package/app/composables/useUserStore.ts +5 -0
- package/app/utils/auth-client.ts +4 -1
- package/i18n/locales/en.json +22 -0
- package/i18n/locales/nl.json +22 -0
- package/migrations/0006_timezone_preferences.sql +22 -0
- package/migrations/0007_user_names.sql +2 -0
- package/migrations/0008_better_auth_api_keys.sql +27 -0
- package/nuxt.config.ts +16 -5
- package/package.json +8 -3
- package/portal.manifest.mjs +1 -1
- package/server/api/auth/permissions.get.ts +4 -1
- package/server/api/timezones.get.ts +9 -0
- package/server/db/schema/auth-schema.ts +46 -1
- package/server/middleware/auth.ts +10 -0
- package/server/utils/auth.ts +150 -12
- package/server/utils/business-hooks.ts +34 -0
- package/server/utils/client-account-policy.ts +15 -0
- package/server/utils/client-configuration.ts +9 -0
- package/server/utils/email-texts.ts +27 -0
- package/server/utils/email.ts +25 -2
- package/server/utils/invitation-management.ts +4 -0
- package/server/utils/openapi-contracts.ts +8 -0
- package/server/utils/permissions.ts +4 -0
- package/server/utils/portal-email.ts +12 -4
- package/server/utils/public-api-route.ts +14 -0
- package/server/utils/public-invitation-route.ts +3 -0
- package/server/utils/timezone-validation.ts +4 -0
- package/server/utils/timezones.ts +28 -0
- package/shared/appointment-time.ts +43 -0
- package/shared/core-feature.ts +82 -29
- package/shared/countries.ts +18 -0
- package/shared/email-recipient.ts +7 -0
- package/shared/languages.ts +7 -0
- package/shared/permissions.ts +25 -0
- package/shared/portal-session.ts +2 -0
- package/shared/social-auth.ts +5 -0
- package/shared/timezone.ts +21 -0
- package/shared/types/feature.ts +11 -0
- package/shared/types/sections/database.ts +3 -0
package/server/utils/auth.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { isPersonalClient, assertClientInvitationAcceptance } from './client-account-policy'
|
|
1
3
|
import { isPortalDemo } from './demo'
|
|
2
4
|
import { demoMessage, isDemoRequestAllowed } from '../../shared/demo-policy'
|
|
3
5
|
import { betterAuth } from 'better-auth'
|
|
@@ -6,6 +8,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
|
6
8
|
import { sendEmail } from './email'
|
|
7
9
|
import { getInvitationEmailContent, getOTPEmailContent, getDeleteAccountEmailContent } from './email-texts'
|
|
8
10
|
import { admin as adminPlugin, customSession, emailOTP, openAPI, organization } from 'better-auth/plugins'
|
|
11
|
+
import { apiKey } from '@better-auth/api-key'
|
|
9
12
|
import { db } from './db'
|
|
10
13
|
import { and, eq, gt } from 'drizzle-orm'
|
|
11
14
|
import {
|
|
@@ -15,12 +18,23 @@ import {
|
|
|
15
18
|
verification as verificationTable,
|
|
16
19
|
organization as organizationTable,
|
|
17
20
|
member as organizationMemberTable,
|
|
18
|
-
invitation as organizationInvitationTable
|
|
21
|
+
invitation as organizationInvitationTable,
|
|
22
|
+
apiKey as apiKeyTable
|
|
19
23
|
} from '../db/schema/auth-schema'
|
|
20
24
|
import { nanoid } from 'nanoid'
|
|
21
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
ac,
|
|
27
|
+
user,
|
|
28
|
+
admin as adminRole,
|
|
29
|
+
organizationAc,
|
|
30
|
+
organizationOwner,
|
|
31
|
+
organizationAdmin,
|
|
32
|
+
organizationMember
|
|
33
|
+
} from '../../shared/permissions'
|
|
22
34
|
import { canViewOrganizationDirectory } from '../../shared/feature-registry'
|
|
23
35
|
import { isSystemAdminEmail, parseSystemAdminEmails } from './admin-email-allowlist'
|
|
36
|
+
import { runUserIdentityChangedHooks } from './business-hooks'
|
|
37
|
+
import { socialAuthProviderEnabled } from '../../shared/social-auth'
|
|
24
38
|
|
|
25
39
|
/**
|
|
26
40
|
* Generate an ID in the same format as better-auth uses (nanoid)
|
|
@@ -30,13 +44,20 @@ export function generateId(): string {
|
|
|
30
44
|
return nanoid()
|
|
31
45
|
}
|
|
32
46
|
|
|
33
|
-
const envFlag = (value: string | undefined, fallback = true) => (value === undefined ? fallback : value === 'true')
|
|
34
47
|
const portalAuthConfig = useRuntimeConfig().portalAuth
|
|
35
48
|
const registrationMode = ['open', 'invitation-only', 'disabled'].includes(process.env.PORTAL_REGISTRATION_MODE || '')
|
|
36
49
|
? process.env.PORTAL_REGISTRATION_MODE
|
|
37
50
|
: portalAuthConfig.registrationMode
|
|
38
|
-
const githubEnabled =
|
|
39
|
-
|
|
51
|
+
const githubEnabled = socialAuthProviderEnabled(
|
|
52
|
+
process.env.PORTAL_GITHUB_ENABLED,
|
|
53
|
+
process.env.GITHUB_CLIENT_ID,
|
|
54
|
+
process.env.GITHUB_CLIENT_SECRET
|
|
55
|
+
)
|
|
56
|
+
const googleEnabled = socialAuthProviderEnabled(
|
|
57
|
+
process.env.PORTAL_GOOGLE_ENABLED,
|
|
58
|
+
process.env.GOOGLE_CLIENT_ID,
|
|
59
|
+
process.env.GOOGLE_CLIENT_SECRET
|
|
60
|
+
)
|
|
40
61
|
const systemAdminEmails = isPortalDemo() ? new Set<string>() : parseSystemAdminEmails(process.env.ADMIN_EMAILS)
|
|
41
62
|
|
|
42
63
|
export const auth = betterAuth({
|
|
@@ -50,7 +71,8 @@ export const auth = betterAuth({
|
|
|
50
71
|
member: organizationMemberTable,
|
|
51
72
|
verification: verificationTable,
|
|
52
73
|
organization: organizationTable,
|
|
53
|
-
invitation: organizationInvitationTable
|
|
74
|
+
invitation: organizationInvitationTable,
|
|
75
|
+
apikey: apiKeyTable
|
|
54
76
|
},
|
|
55
77
|
usePlural: false
|
|
56
78
|
// Tables are singular (e.g., "user"), so no need for usePlural
|
|
@@ -66,6 +88,95 @@ export const auth = betterAuth({
|
|
|
66
88
|
message: demoMessage(ctx.headers?.get('accept-language') || '')
|
|
67
89
|
})
|
|
68
90
|
}
|
|
91
|
+
if (
|
|
92
|
+
['/api-key/create', '/api-key/list', '/api-key/get', '/api-key/update', '/api-key/delete'].includes(ctx.path)
|
|
93
|
+
) {
|
|
94
|
+
const body = (ctx.body ?? {}) as Record<string, unknown>
|
|
95
|
+
const query = (ctx.query ?? {}) as Record<string, unknown>
|
|
96
|
+
const current = await getSessionFromCtx(ctx)
|
|
97
|
+
const actorId = (body.userId as string | undefined) ?? current?.user.id
|
|
98
|
+
if (actorId) {
|
|
99
|
+
const [actor] = await db
|
|
100
|
+
.select({ role: userTable.role })
|
|
101
|
+
.from(userTable)
|
|
102
|
+
.where(eq(userTable.id, actorId))
|
|
103
|
+
.limit(1)
|
|
104
|
+
if (actor?.role !== 'admin') {
|
|
105
|
+
throw new APIError('FORBIDDEN', { message: 'System administrator access is required to manage API keys' })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
let organizationId = (body.organizationId ?? query.organizationId) as string | undefined
|
|
109
|
+
const keyId = (body.keyId ?? query.id) as string | undefined
|
|
110
|
+
if (!organizationId && !keyId) {
|
|
111
|
+
organizationId = current?.session.activeOrganizationId ?? undefined
|
|
112
|
+
}
|
|
113
|
+
if (!organizationId && keyId) {
|
|
114
|
+
const [key] = await db
|
|
115
|
+
.select({ referenceId: apiKeyTable.referenceId })
|
|
116
|
+
.from(apiKeyTable)
|
|
117
|
+
.where(eq(apiKeyTable.id, keyId))
|
|
118
|
+
.limit(1)
|
|
119
|
+
organizationId = key?.referenceId
|
|
120
|
+
}
|
|
121
|
+
if (organizationId) {
|
|
122
|
+
const [selected] = await db
|
|
123
|
+
.select({ organizationType: organizationTable.organizationType })
|
|
124
|
+
.from(organizationTable)
|
|
125
|
+
.where(eq(organizationTable.id, organizationId))
|
|
126
|
+
.limit(1)
|
|
127
|
+
if (selected?.organizationType !== 'PROVIDER') {
|
|
128
|
+
throw new APIError('FORBIDDEN', {
|
|
129
|
+
message: 'API keys are only available to provider organizations'
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (ctx.path.startsWith('/organization/') && ctx.body) {
|
|
135
|
+
const current = await getSessionFromCtx(ctx)
|
|
136
|
+
const body = ctx.body as Record<string, unknown>
|
|
137
|
+
let target = body.organizationId as string | undefined
|
|
138
|
+
if (typeof body.invitationId === 'string') {
|
|
139
|
+
const [invite] = await db
|
|
140
|
+
.select()
|
|
141
|
+
.from(organizationInvitationTable)
|
|
142
|
+
.where(eq(organizationInvitationTable.id, body.invitationId))
|
|
143
|
+
.limit(1)
|
|
144
|
+
target = invite?.organizationId
|
|
145
|
+
}
|
|
146
|
+
const memberId = body.memberId ?? body.memberIdOrEmail
|
|
147
|
+
if (typeof memberId === 'string') {
|
|
148
|
+
const [selected] = await db
|
|
149
|
+
.select()
|
|
150
|
+
.from(organizationMemberTable)
|
|
151
|
+
.where(eq(organizationMemberTable.id, memberId))
|
|
152
|
+
.limit(1)
|
|
153
|
+
target = selected?.organizationId ?? target
|
|
154
|
+
}
|
|
155
|
+
target ||= current?.session.activeOrganizationId ?? undefined
|
|
156
|
+
if (target && (await isPersonalClient(target))) {
|
|
157
|
+
if (ctx.path === '/organization/accept-invitation') {
|
|
158
|
+
if (!current?.user.emailVerified) {
|
|
159
|
+
throw new APIError('FORBIDDEN', { message: 'Verify your email first' })
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
await assertClientInvitationAcceptance(target, current.user.id)
|
|
163
|
+
} catch {
|
|
164
|
+
throw new APIError('CONFLICT', { message: 'Personal account already exists. Contact your coach.' })
|
|
165
|
+
}
|
|
166
|
+
} else if (
|
|
167
|
+
![
|
|
168
|
+
'/organization/set-active',
|
|
169
|
+
'/organization/reject-invitation',
|
|
170
|
+
'/organization/check-slug',
|
|
171
|
+
'/organization/has-permission'
|
|
172
|
+
].includes(ctx.path)
|
|
173
|
+
) {
|
|
174
|
+
throw new APIError('FORBIDDEN', {
|
|
175
|
+
message: 'Personal account membership and organization settings cannot be changed through this route'
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
69
180
|
if (ctx.path !== '/organization/list-members' && ctx.path !== '/organization/list-invitations') {
|
|
70
181
|
return
|
|
71
182
|
}
|
|
@@ -101,6 +212,11 @@ export const auth = betterAuth({
|
|
|
101
212
|
requireEmailVerification: true
|
|
102
213
|
},
|
|
103
214
|
user: {
|
|
215
|
+
additionalFields: {
|
|
216
|
+
timezone: { type: 'string', required: false, input: false },
|
|
217
|
+
firstName: { type: 'string', required: false, validator: { input: z.string().trim().min(1).max(80) } },
|
|
218
|
+
lastName: { type: 'string', required: false, validator: { input: z.string().trim().min(1).max(80) } }
|
|
219
|
+
},
|
|
104
220
|
deleteUser: {
|
|
105
221
|
enabled: true,
|
|
106
222
|
sendDeleteAccountVerification: async ({ user, url, token: _token }, _request) => {
|
|
@@ -130,19 +246,19 @@ export const auth = betterAuth({
|
|
|
130
246
|
}
|
|
131
247
|
},
|
|
132
248
|
socialProviders: {
|
|
133
|
-
...(githubEnabled
|
|
249
|
+
...(githubEnabled
|
|
134
250
|
? {
|
|
135
251
|
github: {
|
|
136
|
-
clientId: process.env.GITHUB_CLIENT_ID
|
|
137
|
-
clientSecret: process.env.GITHUB_CLIENT_SECRET
|
|
252
|
+
clientId: process.env.GITHUB_CLIENT_ID!,
|
|
253
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET!
|
|
138
254
|
}
|
|
139
255
|
}
|
|
140
256
|
: {}),
|
|
141
|
-
...(googleEnabled
|
|
257
|
+
...(googleEnabled
|
|
142
258
|
? {
|
|
143
259
|
google: {
|
|
144
|
-
clientId: process.env.GOOGLE_CLIENT_ID
|
|
145
|
-
clientSecret: process.env.GOOGLE_CLIENT_SECRET
|
|
260
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
261
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!
|
|
146
262
|
}
|
|
147
263
|
}
|
|
148
264
|
: {})
|
|
@@ -209,6 +325,14 @@ export const auth = betterAuth({
|
|
|
209
325
|
createdAt: new Date()
|
|
210
326
|
})
|
|
211
327
|
}
|
|
328
|
+
},
|
|
329
|
+
update: {
|
|
330
|
+
after: async (updatedUser) => {
|
|
331
|
+
await runUserIdentityChangedHooks(db, updatedUser.id, {
|
|
332
|
+
firstName: typeof updatedUser.firstName === 'string' ? updatedUser.firstName : null,
|
|
333
|
+
lastName: typeof updatedUser.lastName === 'string' ? updatedUser.lastName : null
|
|
334
|
+
})
|
|
335
|
+
}
|
|
212
336
|
}
|
|
213
337
|
},
|
|
214
338
|
session: {
|
|
@@ -238,6 +362,12 @@ export const auth = betterAuth({
|
|
|
238
362
|
}),
|
|
239
363
|
organization({
|
|
240
364
|
allowUserToCreateOrganization: false,
|
|
365
|
+
ac: organizationAc,
|
|
366
|
+
roles: {
|
|
367
|
+
owner: organizationOwner,
|
|
368
|
+
admin: organizationAdmin,
|
|
369
|
+
member: organizationMember
|
|
370
|
+
},
|
|
241
371
|
schema: {
|
|
242
372
|
organization: {
|
|
243
373
|
additionalFields: {
|
|
@@ -290,6 +420,14 @@ export const auth = betterAuth({
|
|
|
290
420
|
}
|
|
291
421
|
}
|
|
292
422
|
}),
|
|
423
|
+
apiKey({
|
|
424
|
+
references: 'organization',
|
|
425
|
+
defaultPrefix: 'portal_',
|
|
426
|
+
requireName: true,
|
|
427
|
+
startingCharactersConfig: { shouldStore: true, charactersLength: 12 },
|
|
428
|
+
keyExpiration: { minExpiresIn: 1 / 24, maxExpiresIn: 365 },
|
|
429
|
+
rateLimit: { enabled: true, timeWindow: 60_000, maxRequests: 120 }
|
|
430
|
+
}),
|
|
293
431
|
emailOTP({
|
|
294
432
|
overrideDefaultEmailVerification: true,
|
|
295
433
|
async sendVerificationOTP({ email, otp, type }) {
|
|
@@ -26,3 +26,37 @@ export const assertTimeEntriesReopenable = async (organizationId: string, entryI
|
|
|
26
26
|
await guard(organizationId, entryIds)
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
+
|
|
30
|
+
type UserDisplayNameChangedHook = (transaction: unknown, userId: string, name: string) => Promise<void>
|
|
31
|
+
const userDisplayNameChangedHooks: UserDisplayNameChangedHook[] = []
|
|
32
|
+
export const registerUserDisplayNameChangedHook = (hook: UserDisplayNameChangedHook) => {
|
|
33
|
+
if (!userDisplayNameChangedHooks.includes(hook)) {
|
|
34
|
+
userDisplayNameChangedHooks.push(hook)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export const runUserDisplayNameChangedHooks = async (transaction: unknown, userId: string, name: string) => {
|
|
38
|
+
for (const hook of userDisplayNameChangedHooks) {
|
|
39
|
+
await hook(transaction, userId, name)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type UserIdentityChangedHook = (
|
|
44
|
+
transaction: unknown,
|
|
45
|
+
userId: string,
|
|
46
|
+
identity: { firstName: string | null; lastName: string | null }
|
|
47
|
+
) => Promise<void>
|
|
48
|
+
const userIdentityChangedHooks: UserIdentityChangedHook[] = []
|
|
49
|
+
export const registerUserIdentityChangedHook = (hook: UserIdentityChangedHook) => {
|
|
50
|
+
if (!userIdentityChangedHooks.includes(hook)) {
|
|
51
|
+
userIdentityChangedHooks.push(hook)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const runUserIdentityChangedHooks = async (
|
|
55
|
+
transaction: unknown,
|
|
56
|
+
userId: string,
|
|
57
|
+
identity: { firstName: string | null; lastName: string | null }
|
|
58
|
+
) => {
|
|
59
|
+
for (const hook of userIdentityChangedHooks) {
|
|
60
|
+
await hook(transaction, userId, identity)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Optional client package policies; core remains usable without Clients. */
|
|
2
|
+
type AccountPolicy = {
|
|
3
|
+
isPersonal: (organizationId: string) => Promise<boolean>
|
|
4
|
+
assertAcceptance: (organizationId: string, userId: string) => Promise<void>
|
|
5
|
+
getIdentity: (organizationId: string) => Promise<{ name: string; firstName?: string; lastName?: string } | null>
|
|
6
|
+
}
|
|
7
|
+
let policy: AccountPolicy | undefined
|
|
8
|
+
export const registerClientAccountPolicy = (value: AccountPolicy) => {
|
|
9
|
+
policy = value
|
|
10
|
+
}
|
|
11
|
+
export const isPersonalClient = async (id: string) => (policy ? policy.isPersonal(id) : false)
|
|
12
|
+
export const assertClientInvitationAcceptance = async (id: string, userId: string) => {
|
|
13
|
+
await policy?.assertAcceptance(id, userId)
|
|
14
|
+
}
|
|
15
|
+
export const getClientAccountIdentity = async (id: string) => (policy ? policy.getIdentity(id) : null)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ClientConfigurationOverride {
|
|
2
|
+
allowedTypes: ('organization' | 'person')[]
|
|
3
|
+
personalSelfRegistration: boolean
|
|
4
|
+
}
|
|
5
|
+
let resolver: (() => Promise<ClientConfigurationOverride>) | undefined
|
|
6
|
+
export const registerClientConfigurationResolver = (value: typeof resolver) => {
|
|
7
|
+
resolver = value
|
|
8
|
+
}
|
|
9
|
+
export const resolveClientConfigurationOverride = () => resolver?.()
|
|
@@ -38,6 +38,33 @@ export function getInvitationEmailContent(params: InvitationEmailParams) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export interface PersonalAccountInvitationEmailParams {
|
|
42
|
+
invitationLink: string
|
|
43
|
+
recipientName?: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getPersonalAccountInvitationEmailContent(params: PersonalAccountInvitationEmailParams) {
|
|
47
|
+
return {
|
|
48
|
+
messageId: 'personal-account-invitation',
|
|
49
|
+
values: {
|
|
50
|
+
...(params.recipientName?.trim() ? { recipient_name: params.recipientName.trim() } : {}),
|
|
51
|
+
action_url: params.invitationLink
|
|
52
|
+
},
|
|
53
|
+
subject: 'Set up your personal portal account',
|
|
54
|
+
params: {
|
|
55
|
+
greeting: 'Hello,',
|
|
56
|
+
body_text: `A personal portal profile has been created for you. Set up your account to access your purchases, appointments, invoices, and shared files.
|
|
57
|
+
<p style="text-align: center;">
|
|
58
|
+
<a href="${params.invitationLink}" class="button">Set up your account</a>
|
|
59
|
+
</p>
|
|
60
|
+
`,
|
|
61
|
+
action_url: params.invitationLink,
|
|
62
|
+
action_text: 'Set up your account',
|
|
63
|
+
footer_text: 'This account setup link expires soon. If you did not expect this message, please ignore it.'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
41
68
|
/**
|
|
42
69
|
* Centralized OTP email content
|
|
43
70
|
* Used for email verification, sign-in, and password reset OTP codes
|
package/server/utils/email.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { coreFeature } from '@nuxt-customer-portal/core/shared/core-feature'
|
|
2
|
+
import { emailRecipientName } from '@nuxt-customer-portal/core/shared/email-recipient'
|
|
3
|
+
import { pool } from './db'
|
|
2
4
|
import { sendPortalEmail } from './portal-email'
|
|
3
5
|
|
|
4
6
|
// Define the structure for the parameters used in the template
|
|
@@ -18,20 +20,41 @@ interface SendEmailArgs {
|
|
|
18
20
|
messageId?: string
|
|
19
21
|
values?: Record<string, string>
|
|
20
22
|
locale?: string
|
|
23
|
+
idempotencyKey?: string
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
// Updated sendEmail function using the template
|
|
24
|
-
export const sendEmail = async ({
|
|
27
|
+
export const sendEmail = async ({
|
|
28
|
+
to,
|
|
29
|
+
subject,
|
|
30
|
+
params,
|
|
31
|
+
messageId,
|
|
32
|
+
values = {},
|
|
33
|
+
locale,
|
|
34
|
+
idempotencyKey
|
|
35
|
+
}: SendEmailArgs) => {
|
|
25
36
|
const definition = coreFeature.emails?.find((item) => item.id === messageId)
|
|
26
37
|
if (!definition) {
|
|
27
38
|
throw new Error(`Unknown core email definition: ${messageId}`)
|
|
28
39
|
}
|
|
40
|
+
const recipient = await pool.query<{ first_name: string | null; name: string | null }>(
|
|
41
|
+
'SELECT first_name,name FROM public."user" WHERE lower(email)=lower($1) LIMIT 1',
|
|
42
|
+
[to]
|
|
43
|
+
)
|
|
29
44
|
return sendPortalEmail({
|
|
30
45
|
moduleId: coreFeature.id,
|
|
31
46
|
definition,
|
|
32
47
|
locale,
|
|
33
|
-
values
|
|
48
|
+
values: {
|
|
49
|
+
recipient_name: emailRecipientName({
|
|
50
|
+
firstName: recipient.rows[0]?.first_name,
|
|
51
|
+
displayName: recipient.rows[0]?.name,
|
|
52
|
+
email: to
|
|
53
|
+
}),
|
|
54
|
+
...values
|
|
55
|
+
},
|
|
34
56
|
to,
|
|
57
|
+
idempotencyKey,
|
|
35
58
|
text: messageId ? undefined : { subject, body: params.body_text, footer: params.footer_text }
|
|
36
59
|
})
|
|
37
60
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPersonalClient } from './client-account-policy'
|
|
1
2
|
import { and, eq, gt } from 'drizzle-orm'
|
|
2
3
|
import { createError } from 'h3'
|
|
3
4
|
import { invitationChangeSchema } from '../../shared/invitation-validation'
|
|
@@ -10,6 +11,9 @@ export const changePendingInvitation = async (organizationId: string, invitation
|
|
|
10
11
|
throw createError({ statusCode: 400, message: 'Invalid invitation change' })
|
|
11
12
|
}
|
|
12
13
|
const change = parsed.data
|
|
14
|
+
if ('role' in change && (await isPersonalClient(organizationId))) {
|
|
15
|
+
throw createError({ statusCode: 403, message: 'Personal invitation roles cannot be changed' })
|
|
16
|
+
}
|
|
13
17
|
const [updated] = await db
|
|
14
18
|
.update(invitation)
|
|
15
19
|
.set(change)
|
|
@@ -6,6 +6,7 @@ type OpenApiOperation = Record<string, unknown>
|
|
|
6
6
|
|
|
7
7
|
export interface PortalOpenApiContracts {
|
|
8
8
|
owner: string
|
|
9
|
+
securitySchemes?: Record<string, Record<string, unknown>>
|
|
9
10
|
query?: Record<string, z.ZodType>
|
|
10
11
|
body?: Record<string, z.ZodType>
|
|
11
12
|
requestBody?: Record<string, Record<string, unknown>>
|
|
@@ -120,6 +121,13 @@ const addContract = (operation: OpenApiOperation): void => {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
export const enrichOpenApiContracts = <T extends OpenApiDocument>(document: T): T => {
|
|
124
|
+
const components = (document.components ?? {}) as Record<string, unknown>
|
|
125
|
+
components.securitySchemes = Object.assign(
|
|
126
|
+
{},
|
|
127
|
+
components.securitySchemes,
|
|
128
|
+
...(contractRegistry[contractRegistryKey] ?? []).map((item) => item.securitySchemes ?? {})
|
|
129
|
+
)
|
|
130
|
+
Object.assign(document, { components })
|
|
123
131
|
for (const pathItem of Object.values(document.paths ?? {})) {
|
|
124
132
|
for (const operation of Object.values(pathItem)) {
|
|
125
133
|
addContract(operation)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPersonalClient } from './client-account-policy'
|
|
1
2
|
import { db } from './db'
|
|
2
3
|
import { member as memberTable, organization as organizationTable } from '../db/schema/auth-schema'
|
|
3
4
|
import { eq, and } from 'drizzle-orm'
|
|
@@ -113,6 +114,9 @@ export const hasPermission = async (
|
|
|
113
114
|
subject: string,
|
|
114
115
|
action: string
|
|
115
116
|
): Promise<boolean> => {
|
|
117
|
+
if (organizationId && ['member', 'invitation'].includes(subject) && (await isPersonalClient(organizationId))) {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
116
120
|
const { permissions } = await getUserPermissions(userId, systemRole, organizationId)
|
|
117
121
|
const subjectPermissions = permissions[subject] || []
|
|
118
122
|
return Array.isArray(subjectPermissions) && subjectPermissions.includes(action)
|
|
@@ -3,6 +3,7 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:
|
|
|
3
3
|
import { eq } from 'drizzle-orm'
|
|
4
4
|
import { createError } from 'h3'
|
|
5
5
|
import { Resend } from 'resend'
|
|
6
|
+
import { marked } from 'marked'
|
|
6
7
|
import type {
|
|
7
8
|
PortalEmailDefinition,
|
|
8
9
|
PortalEmailLocale,
|
|
@@ -47,6 +48,9 @@ const TEMPLATE_PLACEHOLDERS = new Set([
|
|
|
47
48
|
const placeholderPattern = /{{\s*([a-z0-9_]+)\s*}}/gi
|
|
48
49
|
const senderPattern = /^\s*([^<>]+?)\s*<\s*([^<>]+)\s*>\s*$/
|
|
49
50
|
|
|
51
|
+
export const renderPortalEmailMarkdown = (value: string) =>
|
|
52
|
+
marked.parse(value, { async: false, breaks: true, gfm: true }) as string
|
|
53
|
+
|
|
50
54
|
const parseSender = (value = '') => {
|
|
51
55
|
const match = value.match(senderPattern)
|
|
52
56
|
return match ? { fromName: match[1]!.trim(), fromEmail: match[2]!.trim() } : { fromName: '', fromEmail: value.trim() }
|
|
@@ -341,8 +345,10 @@ export const renderPortalEmail = async (input: {
|
|
|
341
345
|
const messageValues = { ...input.values, brand_name: brandName }
|
|
342
346
|
const allowed = new Set([...input.definition.placeholders.map((item) => item.key), 'brand_name'])
|
|
343
347
|
const subject = replacePlaceholders(text.subject, messageValues, allowed)
|
|
344
|
-
const
|
|
345
|
-
const
|
|
348
|
+
const bodySource = replacePlaceholders(text.body, messageValues, allowed)
|
|
349
|
+
const footerSource = replacePlaceholders(text.footer ?? '', messageValues, allowed)
|
|
350
|
+
const body = renderPortalEmailMarkdown(bodySource)
|
|
351
|
+
const footer = renderPortalEmailMarkdown(footerSource)
|
|
346
352
|
const htmlTemplate = input.htmlTemplate ?? settings.htmlTemplate
|
|
347
353
|
validatePortalEmailTemplate(htmlTemplate)
|
|
348
354
|
const inlineAttachments: PortalEmailAttachment[] = []
|
|
@@ -392,6 +398,7 @@ export const sendPortalEmail = async (input: {
|
|
|
392
398
|
fromName?: string
|
|
393
399
|
attachments?: PortalEmailAttachment[]
|
|
394
400
|
idempotencyKey?: string
|
|
401
|
+
subjectPrefix?: string
|
|
395
402
|
}) => {
|
|
396
403
|
if (isPortalDemo()) {
|
|
397
404
|
rejectDemoAction()
|
|
@@ -400,6 +407,7 @@ export const sendPortalEmail = async (input: {
|
|
|
400
407
|
renderPortalEmail({ ...input, inlineBrandAssets: true }),
|
|
401
408
|
providerConfiguration()
|
|
402
409
|
])
|
|
410
|
+
const subject = `${input.subjectPrefix ?? ''}${rendered.subject}`
|
|
403
411
|
const fromEmail = input.fromEmail || provider.fromEmail
|
|
404
412
|
const fromName = input.fromName || provider.fromName
|
|
405
413
|
const from = fromName ? `${fromName.replace(/[<>\r\n]/g, '')} <${fromEmail}>` : fromEmail
|
|
@@ -408,7 +416,7 @@ export const sendPortalEmail = async (input: {
|
|
|
408
416
|
from,
|
|
409
417
|
to: [input.to],
|
|
410
418
|
cc: input.cc?.length ? input.cc : undefined,
|
|
411
|
-
subject
|
|
419
|
+
subject,
|
|
412
420
|
html: rendered.html,
|
|
413
421
|
text: rendered.text,
|
|
414
422
|
attachments: [...rendered.inlineAttachments, ...(input.attachments ?? [])]
|
|
@@ -418,7 +426,7 @@ export const sendPortalEmail = async (input: {
|
|
|
418
426
|
if (error) {
|
|
419
427
|
throw new Error(error.message)
|
|
420
428
|
}
|
|
421
|
-
return { ...data, rendered }
|
|
429
|
+
return { ...data, rendered: { ...rendered, subject } }
|
|
422
430
|
}
|
|
423
431
|
|
|
424
432
|
export const retrievePortalEmail = async (providerMessageId: string) => {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function isPublicStoreRoute(method: string, pathname: string) {
|
|
2
|
+
return (
|
|
3
|
+
(method === 'GET' &&
|
|
4
|
+
(/^\/api\/store\/v1\/products(?:\/[^/]+)?$/.test(pathname) ||
|
|
5
|
+
/^\/api\/store\/(product|media)\/[^/]+$/.test(pathname) ||
|
|
6
|
+
/^\/api\/store\/planning\/[^/]+\/availability$/.test(pathname) ||
|
|
7
|
+
pathname === '/api/store/planning/hold' ||
|
|
8
|
+
/^\/api\/store\/sandbox-checkout\/[^/]+$/.test(pathname))) ||
|
|
9
|
+
(method === 'POST' &&
|
|
10
|
+
(['/api/store/checkout', '/api/store/webhooks/stripe', '/api/store/planning/holds'].includes(pathname) ||
|
|
11
|
+
/^\/api\/store\/sandbox-checkout\/[^/]+$/.test(pathname))) ||
|
|
12
|
+
(method === 'DELETE' && pathname === '/api/store/planning/hold')
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { db } from './db'
|
|
3
|
+
import { organization, organizationSettings, user } from '../db/schema/auth-schema'
|
|
4
|
+
import { DEFAULT_TIMEZONE, resolveTimezones } from '../../shared/timezone'
|
|
5
|
+
|
|
6
|
+
export const getProviderTimezone = async () => {
|
|
7
|
+
const [provider] = await db
|
|
8
|
+
.select({ timezone: organizationSettings.timezone })
|
|
9
|
+
.from(organization)
|
|
10
|
+
.leftJoin(organizationSettings, eq(organization.id, organizationSettings.organizationId))
|
|
11
|
+
.where(eq(organization.organizationType, 'PROVIDER'))
|
|
12
|
+
.limit(1)
|
|
13
|
+
return provider?.timezone || DEFAULT_TIMEZONE
|
|
14
|
+
}
|
|
15
|
+
type ClientTimezoneResolver = (id: string) => Promise<string | null>
|
|
16
|
+
let clientResolver: ClientTimezoneResolver = async () => null
|
|
17
|
+
export const registerClientTimezoneResolver = (resolver: ClientTimezoneResolver) => {
|
|
18
|
+
clientResolver = resolver
|
|
19
|
+
}
|
|
20
|
+
/** Caller must authorize the user and active organization before exposing this projection. */
|
|
21
|
+
export const getResolvedTimezones = async (userId: string, clientOrganizationId?: string | null) => {
|
|
22
|
+
const [providerTimezone, clientTimezone, users] = await Promise.all([
|
|
23
|
+
getProviderTimezone(),
|
|
24
|
+
clientOrganizationId ? clientResolver(clientOrganizationId) : null,
|
|
25
|
+
db.select({ timezone: user.timezone }).from(user).where(eq(user.id, userId)).limit(1)
|
|
26
|
+
])
|
|
27
|
+
return resolveTimezones({ providerTimezone, clientTimezone, userTimezone: users[0]?.timezone ?? null })
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface AppointmentTimeOptions {
|
|
2
|
+
locale?: string | string[]
|
|
3
|
+
timeZone: string
|
|
4
|
+
midnight: string
|
|
5
|
+
dateStyle?: Intl.DateTimeFormatOptions['dateStyle']
|
|
6
|
+
includeDate?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function formatAppointmentTime(value: string | Date, options: AppointmentTimeOptions): string {
|
|
10
|
+
const date = new Date(value)
|
|
11
|
+
const parts = new Intl.DateTimeFormat('en', {
|
|
12
|
+
hour: '2-digit',
|
|
13
|
+
minute: '2-digit',
|
|
14
|
+
hourCycle: 'h23',
|
|
15
|
+
timeZone: options.timeZone
|
|
16
|
+
}).formatToParts(date)
|
|
17
|
+
if (
|
|
18
|
+
parts.find((part) => part.type === 'hour')?.value === '00' &&
|
|
19
|
+
parts.find((part) => part.type === 'minute')?.value === '00'
|
|
20
|
+
) {
|
|
21
|
+
return options.midnight
|
|
22
|
+
}
|
|
23
|
+
return new Intl.DateTimeFormat(options.locale, { timeStyle: 'short', timeZone: options.timeZone }).format(date)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatAppointmentRange(
|
|
27
|
+
start: string | Date,
|
|
28
|
+
end: string | Date,
|
|
29
|
+
options: AppointmentTimeOptions
|
|
30
|
+
): string {
|
|
31
|
+
if (options.includeDate === false) {
|
|
32
|
+
return `${formatAppointmentTime(start, options)} – ${formatAppointmentTime(end, options)}`
|
|
33
|
+
}
|
|
34
|
+
const formatter = new Intl.DateTimeFormat(options.locale, {
|
|
35
|
+
dateStyle: options.dateStyle ?? 'long',
|
|
36
|
+
timeStyle: 'short',
|
|
37
|
+
timeZone: options.timeZone
|
|
38
|
+
})
|
|
39
|
+
const endTime = formatAppointmentTime(end, options)
|
|
40
|
+
return endTime === options.midnight
|
|
41
|
+
? `${formatter.format(new Date(start))} – ${options.midnight}`
|
|
42
|
+
: formatter.formatRange(new Date(start), new Date(end))
|
|
43
|
+
}
|