@nuxt-customer-portal/organizations 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 (31) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +3 -0
  4. package/app/components/DeleteAccountModal.vue +139 -0
  5. package/app/components/OrganizationMembers.vue +298 -0
  6. package/app/components/OrganizationSettings.vue +315 -0
  7. package/app/components/OrganizationSwitcher.vue +166 -0
  8. package/app/components/organization/DetailsCard.vue +84 -0
  9. package/app/components/organization/InvitationsCard.vue +112 -0
  10. package/app/components/organization/InviteMemberModal.vue +101 -0
  11. package/app/components/organization/MembersCard.vue +108 -0
  12. package/app/pages/my-organizations.vue +125 -0
  13. package/app/pages/settings/index.vue +248 -0
  14. package/app/pages/settings/organization.vue +171 -0
  15. package/app/pages/settings/security.vue +195 -0
  16. package/app/pages/settings.vue +101 -0
  17. package/app/plugins/account-organizations-feature.ts +3 -0
  18. package/i18n/locales/en.json +208 -0
  19. package/i18n/locales/nl.json +208 -0
  20. package/nuxt.config.ts +11 -0
  21. package/package.json +51 -0
  22. package/portal.manifest.mjs +6 -0
  23. package/server/api/organizations/[id]/index.get.ts +54 -0
  24. package/server/api/organizations/[id]/invitations/[invitationId]/delete.post.ts +42 -0
  25. package/server/api/organizations/[id]/invitations/[invitationId]/resend.post.ts +91 -0
  26. package/server/api/organizations/[id]/invitations/[invitationId].patch.ts +18 -0
  27. package/server/api/organizations/[id]/invitations.get.ts +49 -0
  28. package/server/api/organizations/accept-invitation.post.ts +131 -0
  29. package/server/api/organizations/get-invitation.get.ts +73 -0
  30. package/server/api/profile.patch.ts +99 -0
  31. package/shared/feature.ts +24 -0
@@ -0,0 +1,208 @@
1
+ {
2
+ "settings": {
3
+ "title": "Instellingen",
4
+ "profile": "Profiel",
5
+ "organization": "Organisatie",
6
+ "security": "Beveiliging"
7
+ },
8
+ "security": {
9
+ "password": {
10
+ "title": "Wachtwoord",
11
+ "description": "Bevestig uw huidige wachtwoord voordat u een nieuw wachtwoord instelt.",
12
+ "currentPassword": "Huidig wachtwoord",
13
+ "currentPasswordPlaceholder": "Voer uw huidige wachtwoord in",
14
+ "newPassword": "Nieuw wachtwoord",
15
+ "newPasswordPlaceholder": "Voer uw nieuwe wachtwoord in",
16
+ "updateButton": "Wachtwoord bijwerken",
17
+ "validation": {
18
+ "currentPasswordRequired": "Huidig wachtwoord is verplicht",
19
+ "newPasswordRequired": "Nieuw wachtwoord is verplicht",
20
+ "passwordsMustBeDifferent": "Nieuw wachtwoord moet verschillen van het huidige wachtwoord",
21
+ "passwordsMustBeDifferentShort": "Wachtwoorden moeten verschillend zijn"
22
+ },
23
+ "success": "Wachtwoord succesvol gewijzigd",
24
+ "error": "Wachtwoord wijzigen mislukt"
25
+ },
26
+ "account": {
27
+ "title": "Account beëindigen",
28
+ "description": "Wilt u onze service niet meer gebruiken? U kunt hier uw account verwijderen. Deze actie kan niet ongedaan worden gemaakt. Alle informatie met betrekking tot dit account wordt permanent verwijderd.",
29
+ "deleteButton": "Account verwijderen",
30
+ "deleteRequested": "Accountverwijdering aangevraagd",
31
+ "deleteRequestedDescription": "We hebben u een verificatie-e-mail gestuurd. Controleer uw e-mail en klik op de link om het accountverwijderingsproces te voltooien.",
32
+ "delete": {
33
+ "confirmTitle": "Accountverwijdering bevestigen",
34
+ "confirmMessage": "Weet u zeker dat u uw account wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
35
+ "passwordRequired": "Voer uw wachtwoord in om accountverwijdering te bevestigen",
36
+ "passwordPlaceholder": "Voer uw wachtwoord in",
37
+ "deleteButton": "Account verwijderen",
38
+ "cancelButton": "Annuleren",
39
+ "error": "Account verwijderen mislukt",
40
+ "emailVerificationSent": "Verificatie-e-mail verzonden. Controleer uw e-mail om de accountverwijdering te voltooien."
41
+ }
42
+ }
43
+ },
44
+ "myOrganizations": {
45
+ "title": "Mijn Organisaties",
46
+ "description": "Beheer je organisaties en wissel tussen hen"
47
+ },
48
+ "organization": {
49
+ "details": {
50
+ "title": "Organisatiegegevens",
51
+ "name": "Naam",
52
+ "slug": "Slug",
53
+ "created": "Aangemaakt"
54
+ },
55
+ "emailProvider": {
56
+ "title": "E-mailprovider",
57
+ "description": "Configureer het Resend-account van deze organisatie voor facturen, herinneringen en andere transactionele e-mail.",
58
+ "configured": "Resend is geconfigureerd",
59
+ "notConfigured": "Resend is niet geconfigureerd",
60
+ "required": "Transactionele e-mail is geblokkeerd totdat een organisatie-eigenaar een sleutel configureert.",
61
+ "masked": "Actieve API-sleutel eindigend op {suffix}",
62
+ "verifiedDomains": "Geverifieerde verzenddomeinen",
63
+ "keyLabel": "Resend API-sleutel",
64
+ "replaceLabel": "Nieuwe Resend API-sleutel",
65
+ "configure": "Configureren en valideren",
66
+ "replace": "Valideren en vervangen",
67
+ "checkAgain": "Domeinen opnieuw controleren",
68
+ "remove": "Sleutel verwijderen",
69
+ "saved": "Resend API-sleutel opgeslagen",
70
+ "documentation": "Handleiding voor domeinverificatie in Resend",
71
+ "invalid": "De Resend API-sleutel kon niet worden gevalideerd",
72
+ "invalidDescription": "Controleer of de sleutel actief is en bij het juiste Resend-account hoort.",
73
+ "permissionRequired": "Deze Resend-sleutel kan alleen verzenden",
74
+ "permissionRequiredDescription": "Maak in Resend een API-sleutel met Full access aan. Domeintoegang is nodig om te controleren of deze organisatie het ingestelde afzenderadres mag gebruiken.",
75
+ "checkFailed": "Resend-domeinen konden niet worden gecontroleerd",
76
+ "removed": "Resend API-sleutel verwijderd",
77
+ "statusFailed": "Status van de e-mailprovider kon niet worden geladen",
78
+ "statusFailedDescription": "Controleer de server en databasemigratie voordat je een sleutel configureert.",
79
+ "removeTitle": "Resend API-sleutel verwijderen?",
80
+ "removeDescription": "Facturen en herinneringen kunnen niet worden verzonden totdat een nieuwe organisatiesleutel is geconfigureerd."
81
+ },
82
+ "settings": {
83
+ "editButton": "Organisatie bewerken",
84
+ "updateSuccess": "Organisatie succesvol bijgewerkt",
85
+ "nameLabel": "Organisatienaam",
86
+ "namePlaceholder": "Organisatienaam",
87
+ "officialCompanyNameLabel": "Officiële bedrijfsnaam",
88
+ "officialCompanyNamePlaceholder": "Bedrijfsnaam zoals geregistreerd",
89
+ "officialCompanyNameHint": "De officiële naam waaronder de organisatie is geregistreerd.",
90
+ "slugLabel": "Organisatie Slug",
91
+ "slugPlaceholder": "organisatie-slug",
92
+ "slugHint": "URL-vriendelijke identifier voor je organisatie",
93
+ "slugChecking": "Beschikbaarheid controleren...",
94
+ "slugAvailable": "Slug is beschikbaar",
95
+ "slugUnavailable": "Slug is al in gebruik",
96
+ "slugError": "Slug beschikbaarheid controleren mislukt",
97
+ "logoLabel": "Organisatielogo",
98
+ "logoChoose": "Logo kiezen",
99
+ "logoRemove": "Logo verwijderen",
100
+ "avatarLogoLabel": "Avatarlogo",
101
+ "avatarLogoHint": "Een compact logo voor organisatielijsten. Gebruik bij voorkeur een vierkante afbeelding (PNG, JPG, GIF of WebP, maximaal 2 MB).",
102
+ "invoiceLogoHint": "Upload het organisatielogo als PNG, JPG, GIF of WebP.",
103
+ "logoHint": "Upload een PNG, JPG of GIF. Aanbevolen formaat: 280×160.",
104
+ "ownerOnly": "Alleen organisatie-eigenaren kunnen naam of slug wijzigen.",
105
+ "errors": {
106
+ "loadFailed": "Organisatie laden mislukt",
107
+ "updateFailed": "Organisatie bijwerken mislukt",
108
+ "slugTaken": "Organisatie slug is al in gebruik"
109
+ },
110
+ "validation": {
111
+ "nameRequired": "Organisatienaam is verplicht",
112
+ "officialCompanyNameRequired": "Officiële bedrijfsnaam is verplicht",
113
+ "slugRequired": "Organisatie slug is verplicht",
114
+ "slugInvalid": "Slug mag alleen kleine letters, cijfers en streepjes bevatten",
115
+ "logoInvalid": "Logo moet een geldige afbeelding zijn",
116
+ "logoTooLarge": "Logo moet kleiner zijn dan 2 MB"
117
+ }
118
+ },
119
+ "members": {
120
+ "loading": "Organisatiegegevens laden...",
121
+ "title": "Leden",
122
+ "empty": "Nog geen leden",
123
+ "role": "Rol",
124
+ "remove": "Lid verwijderen",
125
+ "removeSuccess": "Lid succesvol verwijderd",
126
+ "roles": {
127
+ "owner": "Eigenaar",
128
+ "admin": "Beheerder",
129
+ "member": "Lid"
130
+ },
131
+ "confirmRemove": {
132
+ "title": "Lid verwijderen?",
133
+ "message": "Weet je zeker dat je {name} uit deze organisatie wilt verwijderen?",
134
+ "confirm": "Verwijderen"
135
+ },
136
+ "invitations": {
137
+ "title": "Uitnodigingen",
138
+ "empty": "Geen openstaande uitnodigingen",
139
+ "expires": "Verloopt",
140
+ "resend": "Opnieuw Verzenden",
141
+ "delete": "Verwijderen",
142
+ "inviteButton": "Lid Uitnodigen",
143
+ "modalTitle": "Lid Uitnodigen",
144
+ "emailLabel": "E-mailadres",
145
+ "emailPlaceholder": "Voer het e-mailadres van het lid in",
146
+ "roleLabel": "Rol",
147
+ "roleMember": "Lid",
148
+ "roleAdmin": "Beheerder",
149
+ "sendButton": "Uitnodiging Verzenden",
150
+ "sendSuccess": "Uitnodiging succesvol verzonden",
151
+ "cancel": "Annuleren",
152
+ "resendSuccess": "Uitnodiging succesvol opnieuw verzonden",
153
+ "deleteSuccess": "Uitnodiging succesvol verwijderd"
154
+ },
155
+ "errors": {
156
+ "sendInvitationFailed": "Uitnodiging verzenden mislukt",
157
+ "resendFailed": "Uitnodiging opnieuw verzenden mislukt",
158
+ "cancelFailed": "Uitnodiging annuleren mislukt",
159
+ "removeFailed": "Lid verwijderen mislukt"
160
+ },
161
+ "confirm": {
162
+ "cancelInvitation": "Weet je zeker dat je deze uitnodiging wilt annuleren?"
163
+ },
164
+ "validation": {
165
+ "emailInvalid": "Ongeldig e-mailadres",
166
+ "roleRequired": "Rol is verplicht"
167
+ }
168
+ }
169
+ },
170
+ "profile": {
171
+ "badges": {
172
+ "active": "Actief"
173
+ },
174
+ "buttons": {
175
+ "setActive": "Activeren"
176
+ },
177
+ "description": "Beheer je persoonlijke informatie en profielinstellingen",
178
+ "fields": {
179
+ "email": "E-mail",
180
+ "emailDescription": "Je e-mailadres kan niet worden gewijzigd",
181
+ "name": "Naam",
182
+ "nameDescription": "Je weergavenaam",
183
+ "namePlaceholder": "Voer je naam in",
184
+ "profilePicture": "Profielfoto",
185
+ "profilePictureDescription": "JPG, GIF of PNG. Max. 1MB.",
186
+ "chooseAvatar": "Kiezen"
187
+ },
188
+ "messages": {
189
+ "error": "Fout",
190
+ "failedToActivateOrganization": "Organisatie activeren mislukt",
191
+ "organizationActivated": "Organisatie succesvol geactiveerd",
192
+ "profileUpdated": "Je profiel is succesvol bijgewerkt",
193
+ "success": "Succes",
194
+ "updateFailed": "Profiel bijwerken mislukt"
195
+ },
196
+ "sections": {
197
+ "organizations": "Mijn organisaties",
198
+ "profileInfo": "Profielinformatie"
199
+ },
200
+ "title": "Profielinstellingen",
201
+ "validation": {
202
+ "atLeastOneField": "Ten minste één veld (naam of afbeelding) moet worden ingevuld",
203
+ "imageInvalidUrl": "Afbeelding moet een geldige URL zijn",
204
+ "nameMaxLength": "Naam moet minder dan 255 tekens zijn",
205
+ "nameRequired": "Naam is verplicht"
206
+ }
207
+ }
208
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ export default defineNuxtConfig({
2
+ $meta: { name: 'nuxt-customer-portal-organizations' },
3
+ compatibilityDate: '2025-10-24',
4
+ modules: ['@nuxtjs/i18n'],
5
+ i18n: {
6
+ locales: [
7
+ { code: 'en', file: 'en.json' },
8
+ { code: 'nl', file: 'nl.json' }
9
+ ]
10
+ }
11
+ })
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@nuxt-customer-portal/organizations",
3
+ "version": "0.3.0",
4
+ "license": "MIT",
5
+ "homepage": "https://nuxt-customer-portal.com",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ludulicious/nuxt-customer-portal.git"
9
+ },
10
+ "type": "module",
11
+ "main": "./nuxt.config.ts",
12
+ "files": [
13
+ "LICENSE",
14
+ "CHANGELOG.md",
15
+ "app",
16
+ "i18n",
17
+ "server",
18
+ "shared",
19
+ "nuxt.config.ts",
20
+ "portal.manifest.mjs",
21
+ "README.md"
22
+ ],
23
+ "exports": {
24
+ ".": "./nuxt.config.ts",
25
+ "./feature": "./shared/feature.ts",
26
+ "./portal-manifest": "./portal.manifest.mjs",
27
+ "./app/*": "./app/*.ts",
28
+ "./server/*": "./server/*.ts",
29
+ "./shared/*": "./shared/*.ts"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "dependencies": {
35
+ "@nuxtjs/i18n": "^10.6.0",
36
+ "@types/pg": "8.15.5",
37
+ "drizzle-orm": "^0.45.2",
38
+ "h3": "^1.15.4",
39
+ "pg": "^8.22.0",
40
+ "zod": "^4.4.3",
41
+ "@nuxt/ui": "^4.10.0",
42
+ "@nuxt-customer-portal/core": "^0.3.0",
43
+ "@nuxt-customer-portal/ui": "^0.3.0"
44
+ },
45
+ "peerDependencies": {
46
+ "nuxt": "^4.5.1"
47
+ },
48
+ "devDependencies": {
49
+ "nuxt": "^4.5.1"
50
+ }
51
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ id: 'organizations',
3
+ version: '0.3.0',
4
+ source: '@nuxt-customer-portal/organizations',
5
+ dependsOn: ['core', 'ui']
6
+ }
@@ -0,0 +1,54 @@
1
+ import { defineEventHandler, createError, getRouterParam } from 'h3'
2
+ import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
3
+ import { db } from '@nuxt-customer-portal/core/server/utils/db'
4
+ import { organization as organizationTable } from '@nuxt-customer-portal/core/server/db/schema/auth-schema'
5
+ import { eq } from 'drizzle-orm'
6
+ import { checkOrganizationPermission } from '@nuxt-customer-portal/core/server/utils/permissions'
7
+ import type { Organization } from '@nuxt-customer-portal/core/shared/types/index'
8
+
9
+ defineRouteMeta({
10
+ openAPI: {
11
+ tags: ['General'],
12
+ operationId: 'generalOrganizationsByIdGet',
13
+ summary: 'Get an organization',
14
+ description:
15
+ 'Get an organization. Uses the current authenticated session and enforces the relevant portal permissions.'
16
+ }
17
+ })
18
+
19
+ export default defineEventHandler(async (event): Promise<Organization> => {
20
+ const session = await auth.api.getSession({ headers: event.headers })
21
+ if (!session?.user) {
22
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
23
+ }
24
+
25
+ const organizationId = getRouterParam(event, 'id')
26
+ if (!organizationId) {
27
+ throw createError({ statusCode: 400, message: 'Organization ID is required' })
28
+ }
29
+
30
+ // Check if user has permission to read this organization
31
+ const hasPermission = await checkOrganizationPermission(
32
+ session as { user: { id: string; role?: string } },
33
+ organizationId,
34
+ 'organization',
35
+ 'read'
36
+ )
37
+
38
+ if (!hasPermission) {
39
+ throw createError({ statusCode: 403, message: 'Access denied' })
40
+ }
41
+
42
+ // Get organization details
43
+ const [organization] = await db
44
+ .select()
45
+ .from(organizationTable)
46
+ .where(eq(organizationTable.id, organizationId))
47
+ .limit(1)
48
+
49
+ if (!organization) {
50
+ throw createError({ statusCode: 404, message: 'Organization not found' })
51
+ }
52
+
53
+ return organization as Organization
54
+ })
@@ -0,0 +1,42 @@
1
+ import { defineEventHandler, createError, getRouterParam } from 'h3'
2
+ import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
3
+ import { checkOrganizationPermission } from '@nuxt-customer-portal/core/server/utils/permissions'
4
+ import { changePendingInvitation } from '@nuxt-customer-portal/core/server/utils/invitation-management'
5
+
6
+ defineRouteMeta({
7
+ openAPI: {
8
+ tags: ['General'],
9
+ operationId: 'generalOrganizationsByIdInvitationsByInvitationIdDeletePost',
10
+ summary: 'Cancel an organization invitation',
11
+ description:
12
+ 'Cancel an organization invitation. Uses the current authenticated session and enforces the relevant portal permissions.'
13
+ }
14
+ })
15
+
16
+ export default defineEventHandler(async (event) => {
17
+ const session = await auth.api.getSession({ headers: event.headers })
18
+ if (!session?.user) {
19
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
20
+ }
21
+
22
+ const organizationId = getRouterParam(event, 'id')
23
+ const invitationId = getRouterParam(event, 'invitationId')
24
+
25
+ if (!organizationId || !invitationId) {
26
+ throw createError({ statusCode: 400, message: 'Organization ID and Invitation ID are required' })
27
+ }
28
+
29
+ // Check if user has permission to cancel invitations
30
+ const hasPermission = await checkOrganizationPermission(
31
+ session as { user: { id: string; role?: string } },
32
+ organizationId,
33
+ 'invitation',
34
+ 'cancel'
35
+ )
36
+
37
+ if (!hasPermission) {
38
+ throw createError({ statusCode: 403, message: 'Access denied' })
39
+ }
40
+
41
+ return changePendingInvitation(organizationId, invitationId, { status: 'canceled' })
42
+ })
@@ -0,0 +1,91 @@
1
+ import { defineEventHandler, createError, getRouterParam } from 'h3'
2
+ import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
3
+ import { db } from '@nuxt-customer-portal/core/server/utils/db'
4
+ import {
5
+ invitation as invitationTable,
6
+ organization as organizationTable
7
+ } from '@nuxt-customer-portal/core/server/db/schema/auth-schema'
8
+ import { eq } from 'drizzle-orm'
9
+ import { checkOrganizationPermission } from '@nuxt-customer-portal/core/server/utils/permissions'
10
+ import { sendEmail } from '@nuxt-customer-portal/core/server/utils/email'
11
+ import { getInvitationEmailContent } from '@nuxt-customer-portal/core/server/utils/email-texts'
12
+ import type { SessionUser } from '@nuxt-customer-portal/core/shared/types/index'
13
+
14
+ defineRouteMeta({
15
+ openAPI: {
16
+ tags: ['General'],
17
+ operationId: 'generalOrganizationsByIdInvitationsByInvitationIdResendPost',
18
+ summary: 'Resend an organization invitation',
19
+ description:
20
+ 'Resend an organization invitation. Uses the current authenticated session and enforces the relevant portal permissions.'
21
+ }
22
+ })
23
+
24
+ export default defineEventHandler(async (event) => {
25
+ const session = await auth.api.getSession({ headers: event.headers })
26
+ if (!session?.user) {
27
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
28
+ }
29
+
30
+ const user = session.user as SessionUser
31
+ const organizationId = getRouterParam(event, 'id')
32
+ const invitationId = getRouterParam(event, 'invitationId')
33
+
34
+ if (!organizationId || !invitationId) {
35
+ throw createError({ statusCode: 400, message: 'Organization ID and Invitation ID are required' })
36
+ }
37
+
38
+ // Check if user has permission to create invitations (resending is creating a new invitation)
39
+ const hasPermission = await checkOrganizationPermission(
40
+ session as { user: { id: string; role?: string } },
41
+ organizationId,
42
+ 'invitation',
43
+ 'create'
44
+ )
45
+
46
+ if (!hasPermission) {
47
+ throw createError({ statusCode: 403, message: 'Access denied' })
48
+ }
49
+
50
+ // Get invitation details
51
+ const [invitation] = await db.select().from(invitationTable).where(eq(invitationTable.id, invitationId)).limit(1)
52
+
53
+ if (!invitation || invitation.organizationId !== organizationId) {
54
+ throw createError({ statusCode: 404, message: 'Invitation not found' })
55
+ }
56
+
57
+ // Check if invitation is still pending
58
+ if (invitation.status !== 'pending') {
59
+ throw createError({ statusCode: 400, message: 'Can only resend pending invitations' })
60
+ }
61
+
62
+ // Get organization details for email
63
+ const [organization] = await db
64
+ .select()
65
+ .from(organizationTable)
66
+ .where(eq(organizationTable.id, organizationId))
67
+ .limit(1)
68
+
69
+ if (!organization) {
70
+ throw createError({ statusCode: 404, message: 'Organization not found' })
71
+ }
72
+
73
+ // Resend invitation email
74
+ const baseURL = process.env.BETTER_AUTH_URL || process.env.PUBLIC_URL || 'http://localhost:3000'
75
+ const invitationLink = `${baseURL}/signup?invitationId=${invitationId}`
76
+
77
+ const emailContent = getInvitationEmailContent({
78
+ inviterName: user.name || '',
79
+ inviterEmail: user.email,
80
+ organizationName: organization.name,
81
+ role: invitation.role || 'member',
82
+ invitationLink
83
+ })
84
+
85
+ await sendEmail({
86
+ to: invitation.email,
87
+ ...emailContent
88
+ })
89
+
90
+ return { success: true, message: 'Invitation resent successfully' }
91
+ })
@@ -0,0 +1,18 @@
1
+ import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
2
+ import { checkOrganizationPermission } from '@nuxt-customer-portal/core/server/utils/permissions'
3
+ import { changePendingInvitation } from '@nuxt-customer-portal/core/server/utils/invitation-management'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const session = await auth.api.getSession({ headers: event.headers })
7
+ if (!session?.user) {
8
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
9
+ }
10
+ const organizationId = getRouterParam(event, 'id')!
11
+ if (
12
+ !(await checkOrganizationPermission(session, organizationId, 'invitation', 'create')) ||
13
+ !(await checkOrganizationPermission(session, organizationId, 'invitation', 'cancel'))
14
+ ) {
15
+ throw createError({ statusCode: 403, message: 'Access denied' })
16
+ }
17
+ return changePendingInvitation(organizationId, getRouterParam(event, 'invitationId')!, await readBody(event))
18
+ })
@@ -0,0 +1,49 @@
1
+ import { defineEventHandler, createError, getRouterParam } from 'h3'
2
+ import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
3
+ import { db } from '@nuxt-customer-portal/core/server/utils/db'
4
+ import { invitation as invitationTable } from '@nuxt-customer-portal/core/server/db/schema/auth-schema'
5
+ import { eq, and } from 'drizzle-orm'
6
+ import { checkOrganizationPermission } from '@nuxt-customer-portal/core/server/utils/permissions'
7
+ import type { OrganizationInvitationsResponse } from '@nuxt-customer-portal/core/shared/types/index'
8
+
9
+ defineRouteMeta({
10
+ openAPI: {
11
+ tags: ['General'],
12
+ operationId: 'generalOrganizationsByIdInvitationsGet',
13
+ summary: 'List organization invitations',
14
+ description:
15
+ 'List organization invitations. Uses the current authenticated session and enforces the relevant portal permissions.'
16
+ }
17
+ })
18
+
19
+ export default defineEventHandler(async (event): Promise<OrganizationInvitationsResponse> => {
20
+ const session = await auth.api.getSession({ headers: event.headers })
21
+ if (!session?.user) {
22
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
23
+ }
24
+
25
+ const organizationId = getRouterParam(event, 'id')
26
+ if (!organizationId) {
27
+ throw createError({ statusCode: 400, message: 'Organization ID is required' })
28
+ }
29
+
30
+ // Check if user has permission to list members (which includes viewing invitations)
31
+ const hasPermission = await checkOrganizationPermission(
32
+ session as { user: { id: string; role?: string } },
33
+ organizationId,
34
+ 'member',
35
+ 'list'
36
+ )
37
+
38
+ if (!hasPermission) {
39
+ throw createError({ statusCode: 403, message: 'Access denied' })
40
+ }
41
+
42
+ // Get all invitations for this organization
43
+ const invitations = await db
44
+ .select()
45
+ .from(invitationTable)
46
+ .where(and(eq(invitationTable.organizationId, organizationId), eq(invitationTable.status, 'pending')))
47
+
48
+ return invitations as OrganizationInvitationsResponse
49
+ })
@@ -0,0 +1,131 @@
1
+ import { defineEventHandler, createError, readBody } from 'h3'
2
+ import { z } from 'zod'
3
+ import { auth, generateId } from '@nuxt-customer-portal/core/server/utils/auth'
4
+ import { db } from '@nuxt-customer-portal/core/server/utils/db'
5
+ import {
6
+ invitation as invitationTable,
7
+ member as memberTable,
8
+ organization as organizationTable
9
+ } from '@nuxt-customer-portal/core/server/db/schema/auth-schema'
10
+ import { eq, and } from 'drizzle-orm'
11
+ import type { SessionUser } from '@nuxt-customer-portal/core/shared/types/index'
12
+
13
+ defineRouteMeta({
14
+ openAPI: {
15
+ tags: ['General'],
16
+ operationId: 'generalOrganizationsAcceptInvitationPost',
17
+ summary: 'Accept an organization invitation',
18
+ description:
19
+ 'Accept an organization invitation. Uses the current authenticated session and enforces the relevant portal permissions.'
20
+ }
21
+ })
22
+
23
+ /**
24
+ * Custom endpoint to accept invitations, bypassing Better Auth's inviter membership check.
25
+ * This is necessary because admins who create organizations and invite owners
26
+ * are removed as members, but their invitations should still be valid.
27
+ */
28
+ export default defineEventHandler(async (event) => {
29
+ const session = await auth.api.getSession({ headers: event.headers })
30
+ if (!session?.user) {
31
+ throw createError({ statusCode: 401, message: 'Unauthorized' })
32
+ }
33
+
34
+ const user = session.user as SessionUser
35
+ const parsed = z.object({ invitationId: z.string().min(1) }).safeParse(await readBody(event))
36
+ if (!parsed.success) {
37
+ throw createError({ statusCode: 400, message: 'Invitation ID is required' })
38
+ }
39
+ const { invitationId } = parsed.data
40
+
41
+ try {
42
+ return await db.transaction(async (tx) => {
43
+ // Fetch the invitation
44
+ const [invitation] = await tx
45
+ .select()
46
+ .from(invitationTable)
47
+ .where(eq(invitationTable.id, invitationId))
48
+ .limit(1)
49
+ .for('update')
50
+
51
+ if (!invitation) {
52
+ throw createError({ statusCode: 404, message: 'Invitation not found' })
53
+ }
54
+
55
+ // Validate that the invitation is still pending
56
+ if (invitation.status !== 'pending') {
57
+ throw createError({ statusCode: 400, message: 'Invitation is no longer valid' })
58
+ }
59
+
60
+ // Validate that the invitation hasn't expired
61
+ const now = new Date()
62
+ const expiresAt = new Date(invitation.expiresAt)
63
+ if (expiresAt < now) {
64
+ throw createError({ statusCode: 400, message: 'Invitation has expired' })
65
+ }
66
+
67
+ // Validate that the user's email matches the invitation email
68
+ if (user.email?.toLowerCase() !== invitation.email?.toLowerCase()) {
69
+ throw createError({
70
+ statusCode: 400,
71
+ message: 'Email mismatch: This invitation was sent to a different email address'
72
+ })
73
+ }
74
+
75
+ // Check if user is already a member
76
+ const [existingMember] = await tx
77
+ .select()
78
+ .from(memberTable)
79
+ .where(and(eq(memberTable.organizationId, invitation.organizationId), eq(memberTable.userId, user.id)))
80
+ .limit(1)
81
+
82
+ if (existingMember) {
83
+ throw createError({ statusCode: 400, message: 'You are already a member of this organization' })
84
+ }
85
+
86
+ // Get organization details
87
+ const [organization] = await tx
88
+ .select()
89
+ .from(organizationTable)
90
+ .where(eq(organizationTable.id, invitation.organizationId))
91
+ .limit(1)
92
+
93
+ if (!organization) {
94
+ throw createError({ statusCode: 404, message: 'Organization not found' })
95
+ }
96
+
97
+ // Create the member record
98
+ const memberId = generateId()
99
+ await tx.insert(memberTable).values({
100
+ id: memberId,
101
+ organizationId: invitation.organizationId,
102
+ userId: user.id,
103
+ role: invitation.role || 'member',
104
+ createdAt: new Date()
105
+ })
106
+
107
+ // Update invitation status to accepted
108
+ await tx.update(invitationTable).set({ status: 'accepted' }).where(eq(invitationTable.id, invitationId))
109
+
110
+ return {
111
+ success: true,
112
+ member: {
113
+ id: memberId,
114
+ organizationId: invitation.organizationId,
115
+ userId: user.id,
116
+ role: invitation.role || 'member'
117
+ },
118
+ organization: {
119
+ id: organization.id,
120
+ name: organization.name
121
+ }
122
+ }
123
+ })
124
+ } catch (err: unknown) {
125
+ if (err instanceof Error && 'statusCode' in err) {
126
+ throw err
127
+ }
128
+ const errorMessage = err instanceof Error ? err.message : 'Failed to accept invitation'
129
+ throw createError({ statusCode: 500, message: errorMessage })
130
+ }
131
+ })