@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,326 @@
|
|
|
1
|
+
import { betterAuth } from 'better-auth'
|
|
2
|
+
import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api'
|
|
3
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
4
|
+
import { sendEmail } from './email'
|
|
5
|
+
import { getInvitationEmailContent, getOTPEmailContent, getDeleteAccountEmailContent } from './email-texts'
|
|
6
|
+
import { admin as adminPlugin, customSession, emailOTP, openAPI, organization } from 'better-auth/plugins'
|
|
7
|
+
import { db } from './db'
|
|
8
|
+
import { and, eq, gt } from 'drizzle-orm'
|
|
9
|
+
import {
|
|
10
|
+
user as userTable,
|
|
11
|
+
account as accountTable,
|
|
12
|
+
session as sessionTable,
|
|
13
|
+
verification as verificationTable,
|
|
14
|
+
organization as organizationTable,
|
|
15
|
+
member as organizationMemberTable,
|
|
16
|
+
invitation as organizationInvitationTable
|
|
17
|
+
} from '../db/schema/auth-schema'
|
|
18
|
+
import { nanoid } from 'nanoid'
|
|
19
|
+
import { ac, user, admin as adminRole } from '../../shared/permissions'
|
|
20
|
+
import { canViewOrganizationDirectory } from '../../shared/feature-registry'
|
|
21
|
+
import { isSystemAdminEmail, parseSystemAdminEmails } from './admin-email-allowlist'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generate an ID in the same format as better-auth uses (nanoid)
|
|
25
|
+
* This ensures consistency across all ID generation in the application
|
|
26
|
+
*/
|
|
27
|
+
export function generateId(): string {
|
|
28
|
+
return nanoid()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const envFlag = (value: string | undefined, fallback = true) => (value === undefined ? fallback : value === 'true')
|
|
32
|
+
const portalAuthConfig = useRuntimeConfig().portalAuth
|
|
33
|
+
const registrationMode = ['open', 'invitation-only', 'disabled'].includes(process.env.PORTAL_REGISTRATION_MODE || '')
|
|
34
|
+
? process.env.PORTAL_REGISTRATION_MODE
|
|
35
|
+
: portalAuthConfig.registrationMode
|
|
36
|
+
const githubEnabled = envFlag(process.env.PORTAL_GITHUB_ENABLED, portalAuthConfig.githubEnabled)
|
|
37
|
+
const googleEnabled = envFlag(process.env.PORTAL_GOOGLE_ENABLED, portalAuthConfig.googleEnabled)
|
|
38
|
+
const systemAdminEmails = parseSystemAdminEmails(process.env.ADMIN_EMAILS)
|
|
39
|
+
|
|
40
|
+
export const auth = betterAuth({
|
|
41
|
+
baseURL: process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3051',
|
|
42
|
+
database: drizzleAdapter(db, {
|
|
43
|
+
provider: 'pg',
|
|
44
|
+
schema: {
|
|
45
|
+
user: userTable,
|
|
46
|
+
account: accountTable,
|
|
47
|
+
session: sessionTable,
|
|
48
|
+
member: organizationMemberTable,
|
|
49
|
+
verification: verificationTable,
|
|
50
|
+
organization: organizationTable,
|
|
51
|
+
invitation: organizationInvitationTable
|
|
52
|
+
},
|
|
53
|
+
usePlural: false
|
|
54
|
+
// Tables are singular (e.g., "user"), so no need for usePlural
|
|
55
|
+
}),
|
|
56
|
+
hooks: {
|
|
57
|
+
before: createAuthMiddleware(async (ctx) => {
|
|
58
|
+
if (ctx.path !== '/organization/list-members' && ctx.path !== '/organization/list-invitations') {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const session = await getSessionFromCtx(ctx)
|
|
63
|
+
if (!session) {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const organizationId = ctx.query?.organizationId || session.session.activeOrganizationId
|
|
67
|
+
if (!organizationId) {
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const [membership] = await db
|
|
72
|
+
.select({ role: organizationMemberTable.role })
|
|
73
|
+
.from(organizationMemberTable)
|
|
74
|
+
.where(
|
|
75
|
+
and(
|
|
76
|
+
eq(organizationMemberTable.userId, session.user.id),
|
|
77
|
+
eq(organizationMemberTable.organizationId, String(organizationId))
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
.limit(1)
|
|
81
|
+
|
|
82
|
+
if (!canViewOrganizationDirectory(membership?.role)) {
|
|
83
|
+
throw new APIError('FORBIDDEN', { message: 'Organization owner or administrator access required' })
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
},
|
|
87
|
+
emailAndPassword: {
|
|
88
|
+
enabled: true,
|
|
89
|
+
disableSignUp: registrationMode === 'disabled',
|
|
90
|
+
requireEmailVerification: true
|
|
91
|
+
},
|
|
92
|
+
user: {
|
|
93
|
+
deleteUser: {
|
|
94
|
+
enabled: true,
|
|
95
|
+
sendDeleteAccountVerification: async ({ user, url, token: _token }, _request) => {
|
|
96
|
+
// url is already a full URL from better-auth, use it directly
|
|
97
|
+
const emailContent = getDeleteAccountEmailContent({
|
|
98
|
+
userName: user.name || '',
|
|
99
|
+
userEmail: user.email,
|
|
100
|
+
deletionLink: url
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await sendEmail({
|
|
104
|
+
to: user.email,
|
|
105
|
+
...emailContent
|
|
106
|
+
})
|
|
107
|
+
},
|
|
108
|
+
beforeDelete: async (user) => {
|
|
109
|
+
const [record] = await db
|
|
110
|
+
.select({ role: userTable.role })
|
|
111
|
+
.from(userTable)
|
|
112
|
+
.where(eq(userTable.id, user.id))
|
|
113
|
+
.limit(1)
|
|
114
|
+
|
|
115
|
+
if (record?.role === 'admin') {
|
|
116
|
+
throw new APIError('BAD_REQUEST', { message: 'System administrator accounts cannot be deleted' })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
socialProviders: {
|
|
122
|
+
...(githubEnabled && process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
|
|
123
|
+
? {
|
|
124
|
+
github: {
|
|
125
|
+
clientId: process.env.GITHUB_CLIENT_ID,
|
|
126
|
+
clientSecret: process.env.GITHUB_CLIENT_SECRET
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
: {}),
|
|
130
|
+
...(googleEnabled && process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
|
|
131
|
+
? {
|
|
132
|
+
google: {
|
|
133
|
+
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
134
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
: {})
|
|
138
|
+
},
|
|
139
|
+
databaseHooks: {
|
|
140
|
+
user: {
|
|
141
|
+
create: {
|
|
142
|
+
before: async (user) => {
|
|
143
|
+
if (registrationMode === 'invitation-only') {
|
|
144
|
+
const [invitation] = await db
|
|
145
|
+
.select({ id: organizationInvitationTable.id })
|
|
146
|
+
.from(organizationInvitationTable)
|
|
147
|
+
.where(
|
|
148
|
+
and(
|
|
149
|
+
eq(organizationInvitationTable.email, user.email),
|
|
150
|
+
eq(organizationInvitationTable.status, 'pending'),
|
|
151
|
+
gt(organizationInvitationTable.expiresAt, new Date())
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
.limit(1)
|
|
155
|
+
|
|
156
|
+
if (!invitation) {
|
|
157
|
+
throw new APIError('FORBIDDEN', { message: 'Registration requires a valid invitation' })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (isSystemAdminEmail(user.email, systemAdminEmails)) {
|
|
162
|
+
return { data: { ...user, role: 'admin' } }
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
after: async (user) => {
|
|
166
|
+
if (!isSystemAdminEmail(user.email, systemAdminEmails)) {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const [providerOrganization] = await db
|
|
171
|
+
.select({ id: organizationTable.id })
|
|
172
|
+
.from(organizationTable)
|
|
173
|
+
.where(eq(organizationTable.organizationType, 'PROVIDER'))
|
|
174
|
+
.limit(1)
|
|
175
|
+
if (!providerOrganization) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const [membership] = await db
|
|
180
|
+
.select({ id: organizationMemberTable.id })
|
|
181
|
+
.from(organizationMemberTable)
|
|
182
|
+
.where(
|
|
183
|
+
and(
|
|
184
|
+
eq(organizationMemberTable.organizationId, providerOrganization.id),
|
|
185
|
+
eq(organizationMemberTable.userId, user.id)
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
.limit(1)
|
|
189
|
+
if (membership) {
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await db.insert(organizationMemberTable).values({
|
|
194
|
+
id: generateId(),
|
|
195
|
+
organizationId: providerOrganization.id,
|
|
196
|
+
userId: user.id,
|
|
197
|
+
role: 'owner',
|
|
198
|
+
createdAt: new Date()
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
session: {
|
|
204
|
+
create: {
|
|
205
|
+
before: async (session) => {
|
|
206
|
+
const [record] = await db
|
|
207
|
+
.select({ email: userTable.email, role: userTable.role })
|
|
208
|
+
.from(userTable)
|
|
209
|
+
.where(eq(userTable.id, session.userId))
|
|
210
|
+
.limit(1)
|
|
211
|
+
|
|
212
|
+
if (record && record.role !== 'admin' && isSystemAdminEmail(record.email, systemAdminEmails)) {
|
|
213
|
+
await db
|
|
214
|
+
.update(userTable)
|
|
215
|
+
.set({ role: 'admin', updatedAt: new Date() })
|
|
216
|
+
.where(eq(userTable.id, session.userId))
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { data: session }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
plugins: [
|
|
225
|
+
openAPI({
|
|
226
|
+
disableDefaultReference: true
|
|
227
|
+
}),
|
|
228
|
+
organization({
|
|
229
|
+
allowUserToCreateOrganization: false,
|
|
230
|
+
schema: {
|
|
231
|
+
organization: {
|
|
232
|
+
additionalFields: {
|
|
233
|
+
organizationType: { type: 'string', required: true, input: true }
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
member: {
|
|
237
|
+
additionalFields: {
|
|
238
|
+
phone: { type: 'string', required: false, input: true },
|
|
239
|
+
jobTitle: { type: 'string', required: false, input: true }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
sendInvitationEmail: async ({ invitation, organization, inviter }) => {
|
|
244
|
+
const baseURL = process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3000'
|
|
245
|
+
const invitationLink = `${baseURL}/signup?invitationId=${invitation.id}`
|
|
246
|
+
|
|
247
|
+
const emailContent = getInvitationEmailContent({
|
|
248
|
+
inviterName: inviter.user.name || '',
|
|
249
|
+
inviterEmail: inviter.user.email,
|
|
250
|
+
organizationName: organization.name,
|
|
251
|
+
role: invitation.role || 'member',
|
|
252
|
+
invitationLink
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
await sendEmail({
|
|
256
|
+
to: invitation.email,
|
|
257
|
+
...emailContent
|
|
258
|
+
})
|
|
259
|
+
},
|
|
260
|
+
organizationHooks: {
|
|
261
|
+
afterCreateOrganization: async ({ organization: createdOrganization, member, user: creator }) => {
|
|
262
|
+
const [creatorRecord] = await db
|
|
263
|
+
.select({ role: userTable.role })
|
|
264
|
+
.from(userTable)
|
|
265
|
+
.where(eq(userTable.id, creator.id))
|
|
266
|
+
.limit(1)
|
|
267
|
+
|
|
268
|
+
// A system admin manages organizations globally and does not need an
|
|
269
|
+
// artificial membership in every organization they create.
|
|
270
|
+
if (creatorRecord?.role === 'admin' && member) {
|
|
271
|
+
await db.delete(organizationMemberTable).where(eq(organizationMemberTable.id, member.id))
|
|
272
|
+
console.log(`Removed system admin ${creator.email} as member from ${createdOrganization.name}`)
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
afterAcceptInvitation: async ({ invitation: _invitation, member, user, organization }) => {
|
|
276
|
+
// Ensure the user gets the correct role when accepting invitation
|
|
277
|
+
// Better Auth handles this automatically, but we log it for debugging
|
|
278
|
+
console.log(`User ${user.email} accepted invitation to ${organization.name} with role ${member.role}`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}),
|
|
282
|
+
emailOTP({
|
|
283
|
+
overrideDefaultEmailVerification: true,
|
|
284
|
+
async sendVerificationOTP({ email, otp, type }) {
|
|
285
|
+
const config = useRuntimeConfig()
|
|
286
|
+
const emailContent = getOTPEmailContent({
|
|
287
|
+
otp,
|
|
288
|
+
type,
|
|
289
|
+
brandName: config.portalEmail?.brandName
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
await sendEmail({
|
|
293
|
+
to: email,
|
|
294
|
+
...emailContent
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}),
|
|
298
|
+
adminPlugin({
|
|
299
|
+
ac,
|
|
300
|
+
roles: {
|
|
301
|
+
user,
|
|
302
|
+
admin: adminRole
|
|
303
|
+
},
|
|
304
|
+
defaultRole: 'user'
|
|
305
|
+
}),
|
|
306
|
+
customSession(async (sessionData) => {
|
|
307
|
+
// Destructure user and session from the input object
|
|
308
|
+
const { user, session } = sessionData
|
|
309
|
+
// Fetch the account for the user
|
|
310
|
+
const [account] = await db
|
|
311
|
+
.select({ providerId: accountTable.providerId })
|
|
312
|
+
.from(accountTable)
|
|
313
|
+
.where(eq(accountTable.userId, user.id))
|
|
314
|
+
.limit(1)
|
|
315
|
+
|
|
316
|
+
// Return modified session data
|
|
317
|
+
return {
|
|
318
|
+
...session, // Spread the original session part
|
|
319
|
+
user: {
|
|
320
|
+
...user, // Spread the original user part
|
|
321
|
+
providerId: account?.providerId || null // Add providerId to user object
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
]
|
|
326
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type TimeEntryReopenGuard = (organizationId: string, entryIds: string[]) => Promise<void>
|
|
2
|
+
const reopenGuards: TimeEntryReopenGuard[] = []
|
|
3
|
+
|
|
4
|
+
type ClientCreatedHook = (transaction: unknown, clientOrganizationId: string) => Promise<void>
|
|
5
|
+
const clientCreatedHooks: ClientCreatedHook[] = []
|
|
6
|
+
|
|
7
|
+
export const registerClientCreatedHook = (hook: ClientCreatedHook) => {
|
|
8
|
+
if (!clientCreatedHooks.includes(hook)) {
|
|
9
|
+
clientCreatedHooks.push(hook)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const runClientCreatedHooks = async (transaction: unknown, clientOrganizationId: string) => {
|
|
14
|
+
for (const hook of clientCreatedHooks) {
|
|
15
|
+
await hook(transaction, clientOrganizationId)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const registerTimeEntryReopenGuard = (guard: TimeEntryReopenGuard) => {
|
|
20
|
+
if (!reopenGuards.includes(guard)) {
|
|
21
|
+
reopenGuards.push(guard)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export const assertTimeEntriesReopenable = async (organizationId: string, entryIds: string[]) => {
|
|
25
|
+
for (const guard of reopenGuards) {
|
|
26
|
+
await guard(organizationId, entryIds)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
2
|
+
import { Pool } from 'pg'
|
|
3
|
+
|
|
4
|
+
// Declare globals to avoid multiple instances in development
|
|
5
|
+
declare global {
|
|
6
|
+
var portalDatabase: ReturnType<typeof drizzle> | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const pool = new Pool({
|
|
10
|
+
connectionString: process.env.DATABASE_URL
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const db = globalThis.portalDatabase || drizzle(pool)
|
|
14
|
+
|
|
15
|
+
// If in development, assign the instances to the global variables
|
|
16
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
17
|
+
globalThis.portalDatabase = db
|
|
18
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>{{subject}}</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: Arial, Helvetica, sans-serif;
|
|
10
|
+
line-height: 1.6;
|
|
11
|
+
color: #333;
|
|
12
|
+
background-color: #ffffff;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
}
|
|
16
|
+
.container {
|
|
17
|
+
max-width: 600px;
|
|
18
|
+
margin: 20px auto;
|
|
19
|
+
background-color: #ffffff;
|
|
20
|
+
border-radius: 8px;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
23
|
+
border-top: 6px solid {{brand_primary_color}};
|
|
24
|
+
}
|
|
25
|
+
.header {
|
|
26
|
+
text-align: center;
|
|
27
|
+
padding: 28px 30px;
|
|
28
|
+
background-color: #ffffff;
|
|
29
|
+
}
|
|
30
|
+
.header h1 {
|
|
31
|
+
color: {{brand_primary_color}};
|
|
32
|
+
margin: 0;
|
|
33
|
+
font-size: 24px;
|
|
34
|
+
}
|
|
35
|
+
.header p {
|
|
36
|
+
color: {{brand_primary_color}};
|
|
37
|
+
margin: 4px 0 0;
|
|
38
|
+
font-size: 14px;
|
|
39
|
+
}
|
|
40
|
+
.content {
|
|
41
|
+
padding: 30px;
|
|
42
|
+
}
|
|
43
|
+
.content p {
|
|
44
|
+
margin-bottom: 15px;
|
|
45
|
+
}
|
|
46
|
+
.content code {
|
|
47
|
+
font-size: 30px;
|
|
48
|
+
font-weight: bold;
|
|
49
|
+
font-family: monospace;
|
|
50
|
+
}
|
|
51
|
+
.button {
|
|
52
|
+
display: inline-block;
|
|
53
|
+
background-color: {{brand_primary_color}};
|
|
54
|
+
color: #ffffff !important; /* Ensure link text is white */
|
|
55
|
+
padding: 12px 25px;
|
|
56
|
+
text-decoration: none;
|
|
57
|
+
border-radius: 5px;
|
|
58
|
+
font-weight: bold;
|
|
59
|
+
text-transform: uppercase;
|
|
60
|
+
text-align: center;
|
|
61
|
+
margin-top: 10px;
|
|
62
|
+
margin-bottom: 20px;
|
|
63
|
+
}
|
|
64
|
+
.footer {
|
|
65
|
+
text-align: center;
|
|
66
|
+
font-size: 0.9em;
|
|
67
|
+
color: #777;
|
|
68
|
+
padding: 20px 30px;
|
|
69
|
+
border-top: 1px solid #eeeeee;
|
|
70
|
+
background-color: #ffffff;
|
|
71
|
+
}
|
|
72
|
+
a {
|
|
73
|
+
color: #0ea5e9;
|
|
74
|
+
}
|
|
75
|
+
@media only screen and (max-width: 640px) {
|
|
76
|
+
.container {
|
|
77
|
+
margin: 0;
|
|
78
|
+
border-radius: 0;
|
|
79
|
+
}
|
|
80
|
+
.header,
|
|
81
|
+
.content,
|
|
82
|
+
.footer {
|
|
83
|
+
padding-left: 20px;
|
|
84
|
+
padding-right: 20px;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
<div class="container">
|
|
91
|
+
<div class="header">
|
|
92
|
+
{{brand_logo_light}}
|
|
93
|
+
<h1>{{brand_name}}</h1>
|
|
94
|
+
<p>{{brand_tagline}}</p>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="content">
|
|
97
|
+
<div>{{body}}</div>
|
|
98
|
+
<p>{{footer}}</p>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="footer">
|
|
101
|
+
<p>© {{current_year}} {{brand_name}}. All rights reserved.</p>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</body>
|
|
105
|
+
</html>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized invitation email content
|
|
3
|
+
* Used across all invitation email sending functions
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface InvitationEmailParams {
|
|
7
|
+
inviterName: string
|
|
8
|
+
inviterEmail: string
|
|
9
|
+
organizationName: string
|
|
10
|
+
role: string
|
|
11
|
+
invitationLink: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getInvitationEmailContent(params: InvitationEmailParams) {
|
|
15
|
+
const { inviterName, inviterEmail, organizationName, role, invitationLink } = params
|
|
16
|
+
const inviterDisplay = inviterName || inviterEmail
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
messageId: 'invitation',
|
|
20
|
+
values: {
|
|
21
|
+
inviter_name: inviterDisplay,
|
|
22
|
+
organization_name: organizationName,
|
|
23
|
+
role: role || 'member',
|
|
24
|
+
action_url: invitationLink
|
|
25
|
+
},
|
|
26
|
+
subject: `You've been invited to join ${organizationName}`,
|
|
27
|
+
params: {
|
|
28
|
+
greeting: 'Hello,',
|
|
29
|
+
body_text: `${inviterDisplay} has invited you to join <strong>${organizationName}</strong> as a ${role || 'member'}. Click the button below to accept the invitation and create your account.
|
|
30
|
+
<p style="text-align: center;">
|
|
31
|
+
<a href="${invitationLink}" class="button">Accept Invitation</a>
|
|
32
|
+
</p>
|
|
33
|
+
`,
|
|
34
|
+
action_url: invitationLink,
|
|
35
|
+
action_text: 'Accept Invitation',
|
|
36
|
+
footer_text: 'This invitation will expire soon. If you did not expect this invitation, please ignore this email.'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Centralized OTP email content
|
|
43
|
+
* Used for email verification, sign-in, and password reset OTP codes
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type OTPEmailType = 'email-verification' | 'sign-in' | 'forget-password' | 'change-email'
|
|
47
|
+
|
|
48
|
+
export interface OTPEmailParams {
|
|
49
|
+
otp: string
|
|
50
|
+
type: OTPEmailType
|
|
51
|
+
brandName?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getOTPEmailContent(params: OTPEmailParams) {
|
|
55
|
+
const { otp, type, brandName = 'Nuxt Customer Portal' } = params
|
|
56
|
+
|
|
57
|
+
const subjects = {
|
|
58
|
+
'email-verification': `Verify your ${brandName} email address`,
|
|
59
|
+
'sign-in': `Your ${brandName} sign-in code`,
|
|
60
|
+
'forget-password': `Reset your ${brandName} password`,
|
|
61
|
+
'change-email': `Confirm your new ${brandName} email address`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
messageId: type,
|
|
66
|
+
values: { otp },
|
|
67
|
+
subject: subjects[type],
|
|
68
|
+
params: {
|
|
69
|
+
greeting: 'Hello,',
|
|
70
|
+
body_text: `Your verification code is: <code>${otp}</code>`,
|
|
71
|
+
action_url: '#', // Not used for OTP
|
|
72
|
+
action_text: 'Verification Code',
|
|
73
|
+
footer_text: 'This code will expire soon. If you did not request this, please ignore this email.'
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Centralized account deletion email content
|
|
80
|
+
* Used for account deletion verification
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
export interface DeleteAccountEmailParams {
|
|
84
|
+
userName: string
|
|
85
|
+
userEmail: string
|
|
86
|
+
deletionLink: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getDeleteAccountEmailContent(params: DeleteAccountEmailParams) {
|
|
90
|
+
const { userName, userEmail, deletionLink } = params
|
|
91
|
+
const userDisplay = userName || userEmail
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
messageId: 'account-deletion',
|
|
95
|
+
values: { user_name: userDisplay, action_url: deletionLink },
|
|
96
|
+
subject: 'Confirm Account Deletion',
|
|
97
|
+
params: {
|
|
98
|
+
greeting: `Hello ${userDisplay},`,
|
|
99
|
+
body_text: `You have requested to delete your account. This action cannot be undone. All your data will be permanently deleted.
|
|
100
|
+
<p style="text-align: center;">
|
|
101
|
+
<a href="${deletionLink}" class="button">Delete Account</a>
|
|
102
|
+
</p>
|
|
103
|
+
`,
|
|
104
|
+
action_url: deletionLink,
|
|
105
|
+
action_text: 'Delete Account',
|
|
106
|
+
footer_text:
|
|
107
|
+
'If you did not request to delete your account, please ignore this email and your account will remain active.'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { coreFeature } from '@nuxt-customer-portal/core/shared/core-feature'
|
|
2
|
+
import { sendPortalEmail } from './portal-email'
|
|
3
|
+
|
|
4
|
+
// Define the structure for the parameters used in the template
|
|
5
|
+
interface EmailParams {
|
|
6
|
+
greeting?: string
|
|
7
|
+
body_text: string
|
|
8
|
+
action_url: string
|
|
9
|
+
action_text: string
|
|
10
|
+
footer_text: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Define the arguments for the sendEmail function
|
|
14
|
+
interface SendEmailArgs {
|
|
15
|
+
to: string
|
|
16
|
+
subject: string
|
|
17
|
+
params: EmailParams
|
|
18
|
+
messageId?: string
|
|
19
|
+
values?: Record<string, string>
|
|
20
|
+
locale?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Updated sendEmail function using the template
|
|
24
|
+
export const sendEmail = async ({ to, subject, params, messageId, values = {}, locale }: SendEmailArgs) => {
|
|
25
|
+
const definition = coreFeature.emails?.find((item) => item.id === messageId)
|
|
26
|
+
if (!definition) {
|
|
27
|
+
throw new Error(`Unknown core email definition: ${messageId}`)
|
|
28
|
+
}
|
|
29
|
+
return sendPortalEmail({
|
|
30
|
+
moduleId: coreFeature.id,
|
|
31
|
+
definition,
|
|
32
|
+
locale,
|
|
33
|
+
values,
|
|
34
|
+
to,
|
|
35
|
+
text: messageId ? undefined : { subject, body: params.body_text, footer: params.footer_text }
|
|
36
|
+
})
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { and, eq, gt } from 'drizzle-orm'
|
|
2
|
+
import { createError } from 'h3'
|
|
3
|
+
import { invitationChangeSchema } from '../../shared/invitation-validation'
|
|
4
|
+
import { db } from './db'
|
|
5
|
+
import { invitation } from '../db/schema/auth-schema'
|
|
6
|
+
|
|
7
|
+
export const changePendingInvitation = async (organizationId: string, invitationId: string, body: unknown) => {
|
|
8
|
+
const parsed = invitationChangeSchema.safeParse(body)
|
|
9
|
+
if (!parsed.success) {
|
|
10
|
+
throw createError({ statusCode: 400, message: 'Invalid invitation change' })
|
|
11
|
+
}
|
|
12
|
+
const change = parsed.data
|
|
13
|
+
const [updated] = await db
|
|
14
|
+
.update(invitation)
|
|
15
|
+
.set(change)
|
|
16
|
+
.where(
|
|
17
|
+
and(
|
|
18
|
+
eq(invitation.id, invitationId),
|
|
19
|
+
eq(invitation.organizationId, organizationId),
|
|
20
|
+
eq(invitation.status, 'pending'),
|
|
21
|
+
'role' in change ? gt(invitation.expiresAt, new Date()) : undefined
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
.returning({ id: invitation.id })
|
|
25
|
+
if (!updated) {
|
|
26
|
+
throw createError({ statusCode: 409, message: 'Invitation is no longer pending or available' })
|
|
27
|
+
}
|
|
28
|
+
return { success: true }
|
|
29
|
+
}
|