@nuxt-customer-portal/core 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/app/composables/useAutoPagination.ts +30 -0
  5. package/app/composables/useOrganization.ts +117 -0
  6. package/app/composables/usePaginatedResource.ts +167 -0
  7. package/app/composables/usePortalFeatures.ts +74 -0
  8. package/app/composables/usePortalSession.ts +20 -0
  9. package/app/composables/useUserStore.ts +460 -0
  10. package/app/middleware/auth.global.ts +32 -0
  11. package/app/plugins/auth.ts +28 -0
  12. package/app/plugins/portal-core-feature.ts +3 -0
  13. package/app/types/index.d.ts +75 -0
  14. package/app/utils/auth-client.ts +31 -0
  15. package/app/utils/permission-utils.ts +21 -0
  16. package/app/utils/random.ts +9 -0
  17. package/i18n/locales/en.json +81 -0
  18. package/i18n/locales/nl.json +81 -0
  19. package/migrations/0000_baseline.sql +119 -0
  20. package/migrations/0001_organization_types.sql +12 -0
  21. package/migrations/0002_invitation_created_at.sql +1 -0
  22. package/migrations/0003_system_admin_role.sql +5 -0
  23. package/migrations/0004_provider_organization_type.sql +16 -0
  24. package/migrations/0005_portal_email_settings.sql +15 -0
  25. package/migrations/meta/0000_snapshot.json +880 -0
  26. package/migrations/meta/_journal.json +13 -0
  27. package/nuxt.config.ts +55 -0
  28. package/package.json +66 -0
  29. package/portal.manifest.mjs +8 -0
  30. package/server/api/auth/[...].ts +18 -0
  31. package/server/api/auth/permissions.get.ts +64 -0
  32. package/server/api/notifications.ts +294 -0
  33. package/server/db/core-schema.ts +1 -0
  34. package/server/db/schema/auth-schema.ts +198 -0
  35. package/server/db/schema/index.ts +1 -0
  36. package/server/middleware/api-docs-auth.ts +26 -0
  37. package/server/middleware/auth.ts +61 -0
  38. package/server/portal.ts +212 -0
  39. package/server/routes/api-docs/openapi.json.get.ts +22 -0
  40. package/server/utils/admin-email-allowlist.ts +12 -0
  41. package/server/utils/auth.ts +326 -0
  42. package/server/utils/business-hooks.ts +28 -0
  43. package/server/utils/db.ts +18 -0
  44. package/server/utils/email-template.html +105 -0
  45. package/server/utils/email-texts.ts +110 -0
  46. package/server/utils/email.ts +37 -0
  47. package/server/utils/invitation-management.ts +29 -0
  48. package/server/utils/openapi-auth.ts +67 -0
  49. package/server/utils/openapi-contracts.ts +129 -0
  50. package/server/utils/openapi-order.ts +136 -0
  51. package/server/utils/permissions.ts +179 -0
  52. package/server/utils/portal-email.ts +424 -0
  53. package/server/utils/query-builder.ts +270 -0
  54. package/server/utils/route-meta.ts +28 -0
  55. package/shared/core-feature.ts +152 -0
  56. package/shared/feature-registry.ts +62 -0
  57. package/shared/invitation-validation.ts +8 -0
  58. package/shared/organization-avatar.ts +11 -0
  59. package/shared/permissions.ts +26 -0
  60. package/shared/portal-session.ts +17 -0
  61. package/shared/types/feature.ts +130 -0
  62. package/shared/types/index.ts +40 -0
  63. package/shared/types/sections/api.ts +134 -0
  64. package/shared/types/sections/auth.ts +71 -0
  65. package/shared/types/sections/database.ts +86 -0
  66. package/shared/types/sections/queryBuilder.ts +31 -0
@@ -0,0 +1,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>&copy; {{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
+ }