@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,101 @@
1
+ <script setup lang="ts">
2
+ import * as z from 'zod'
3
+ import type { FormSubmitEvent } from '@nuxt/ui'
4
+ import { useOrganization } from '@nuxt-customer-portal/core/app/composables/useOrganization'
5
+
6
+ const props = defineProps<{
7
+ organizationId: string
8
+ }>()
9
+
10
+ const emit = defineEmits<{
11
+ success: []
12
+ error: [message: string]
13
+ }>()
14
+
15
+ const open = defineModel<boolean>('open', { default: false })
16
+ const { t } = useI18n()
17
+ const toast = useToast()
18
+ const { inviteMember } = useOrganization()
19
+
20
+ const formSchema = computed(() =>
21
+ z.object({
22
+ email: z.string().email(t('organization.members.validation.emailInvalid')),
23
+ role: z.enum(['member', 'admin'], { error: t('organization.members.validation.roleRequired') })
24
+ })
25
+ )
26
+
27
+ const form = reactive({
28
+ email: '',
29
+ role: 'member' as 'member' | 'admin'
30
+ })
31
+
32
+ const inviting = ref(false)
33
+
34
+ const handleSubmit = async (event: FormSubmitEvent<z.output<typeof formSchema.value>>) => {
35
+ try {
36
+ inviting.value = true
37
+
38
+ const result = await inviteMember(event.data.email, props.organizationId, event.data.role)
39
+
40
+ if (result.error) {
41
+ throw new Error(result.error.message || t('organization.members.errors.sendInvitationFailed'))
42
+ }
43
+
44
+ toast.add({
45
+ title: t('common.success'),
46
+ description: t('organization.members.invitations.sendSuccess'),
47
+ color: 'success'
48
+ })
49
+ open.value = false
50
+ emit('success')
51
+ } catch (err: unknown) {
52
+ const errorMessage = err instanceof Error ? err.message : t('organization.members.errors.sendInvitationFailed')
53
+ toast.add({
54
+ title: t('common.error'),
55
+ description: errorMessage,
56
+ color: 'error'
57
+ })
58
+ emit('error', errorMessage)
59
+ } finally {
60
+ inviting.value = false
61
+ }
62
+ }
63
+ </script>
64
+
65
+ <template>
66
+ <UModal v-model:open="open" :title="t('organization.members.invitations.modalTitle')" :ui="{ footer: 'justify-end' }">
67
+ <template #body>
68
+ <UForm :state="form" :schema="formSchema" class="space-y-4" @submit="handleSubmit">
69
+ <UFormField name="email" :label="t('organization.members.invitations.emailLabel')" required>
70
+ <UInput
71
+ v-model="form.email"
72
+ type="email"
73
+ :placeholder="t('organization.members.invitations.emailPlaceholder')"
74
+ size="lg"
75
+ class="w-full"
76
+ />
77
+ </UFormField>
78
+ <UFormField name="role" :label="t('organization.members.invitations.roleLabel')" required>
79
+ <USelect
80
+ v-model="form.role"
81
+ :items="[
82
+ { label: t('organization.members.invitations.roleMember'), value: 'member' },
83
+ { label: t('organization.members.invitations.roleAdmin'), value: 'admin' }
84
+ ]"
85
+ value-key="value"
86
+ size="lg"
87
+ class="w-full"
88
+ />
89
+ </UFormField>
90
+ <div class="flex flex-col sm:flex-row gap-4 justify-end pt-4">
91
+ <UButton type="button" variant="outline" class="flex-1 sm:flex-none" @click="open = false">
92
+ {{ t('organization.members.invitations.cancel') }}
93
+ </UButton>
94
+ <UButton type="submit" :loading="inviting" color="primary" class="flex-1 sm:flex-none">
95
+ {{ t('organization.members.invitations.sendButton') }}
96
+ </UButton>
97
+ </div>
98
+ </UForm>
99
+ </template>
100
+ </UModal>
101
+ </template>
@@ -0,0 +1,108 @@
1
+ <script setup lang="ts">
2
+ import type { OrganizationMemberWithUser } from '@nuxt-customer-portal/core/shared/types/index'
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ organizationId: string
7
+ members: OrganizationMemberWithUser[]
8
+ loading?: boolean
9
+ canManage?: boolean
10
+ }>(),
11
+ {
12
+ loading: false,
13
+ canManage: false
14
+ }
15
+ )
16
+
17
+ const emit = defineEmits<{ refresh: [] }>()
18
+ const { t } = useI18n()
19
+ const { currentUser } = storeToRefs(useUserStore())
20
+ const { removeMember } = useOrganization()
21
+ const toast = useToast()
22
+ const selectedMember = ref<OrganizationMemberWithUser | null>(null)
23
+ const showRemoveModal = ref(false)
24
+ const removing = ref(false)
25
+
26
+ const requestRemove = (member: OrganizationMemberWithUser) => {
27
+ selectedMember.value = member
28
+ showRemoveModal.value = true
29
+ }
30
+
31
+ const handleRemove = async () => {
32
+ if (!selectedMember.value) {
33
+ return
34
+ }
35
+ try {
36
+ removing.value = true
37
+ const result = await removeMember(selectedMember.value.user.email, props.organizationId)
38
+ if (result.error) {
39
+ throw result.error
40
+ }
41
+ toast.add({ title: t('common.success'), description: t('organization.members.removeSuccess'), color: 'success' })
42
+ emit('refresh')
43
+ } catch (error) {
44
+ const message = error instanceof Error ? error.message : t('organization.members.errors.removeFailed')
45
+ toast.add({ title: t('common.error'), description: message, color: 'error' })
46
+ } finally {
47
+ removing.value = false
48
+ selectedMember.value = null
49
+ }
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <UCard>
55
+ <template #header>
56
+ <h2 class="text-xl font-semibold">{{ t('organization.members.title') }} ({{ members.length }})</h2>
57
+ </template>
58
+
59
+ <div v-if="loading" class="py-8 text-center text-muted">{{ t('organization.members.loading') }}</div>
60
+ <div v-else-if="members.length === 0" class="py-8 text-center text-muted">
61
+ {{ t('organization.members.empty') }}
62
+ </div>
63
+ <div v-else class="divide-y divide-default">
64
+ <div
65
+ v-for="member in members"
66
+ :key="member.id"
67
+ class="flex flex-wrap items-center justify-between gap-3 py-4 first:pt-0 last:pb-0"
68
+ >
69
+ <div class="flex min-w-0 items-center gap-3">
70
+ <UAvatar :src="member.user.image || undefined" :alt="member.user.name || member.user.email" size="md" />
71
+ <div class="min-w-0">
72
+ <p class="truncate font-medium">{{ member.user.name || member.user.email }}</p>
73
+ <p class="truncate text-sm text-muted">{{ member.user.email }}</p>
74
+ </div>
75
+ </div>
76
+ <div class="flex items-center gap-2">
77
+ <UBadge
78
+ :color="member.role === 'owner' ? 'primary' : member.role === 'admin' ? 'info' : 'neutral'"
79
+ variant="soft"
80
+ >
81
+ {{ t(`organization.members.roles.${Array.isArray(member.role) ? member.role[0] : member.role}`) }}
82
+ </UBadge>
83
+ <UButton
84
+ v-if="canManage && member.userId !== currentUser?.id && member.role !== 'owner'"
85
+ icon="i-lucide-user-minus"
86
+ color="error"
87
+ variant="ghost"
88
+ size="sm"
89
+ :aria-label="t('organization.members.remove')"
90
+ @click="requestRemove(member)"
91
+ />
92
+ </div>
93
+ </div>
94
+ </div>
95
+
96
+ <ConfirmationModal
97
+ v-if="showRemoveModal && selectedMember"
98
+ v-model:open="showRemoveModal"
99
+ title="organization.members.confirmRemove.title"
100
+ message="organization.members.confirmRemove.message"
101
+ :message-params="{ name: selectedMember.user.name || selectedMember.user.email }"
102
+ confirm-text="organization.members.confirmRemove.confirm"
103
+ confirm-color="error"
104
+ :loading="removing"
105
+ @confirm="handleRemove"
106
+ />
107
+ </UCard>
108
+ </template>
@@ -0,0 +1,125 @@
1
+ <script setup lang="ts">
2
+ import type { Organization } from '@nuxt-customer-portal/core/shared/types/index'
3
+
4
+ const { t } = useI18n()
5
+
6
+ const userStore = useUserStore()
7
+ const { activeOrganizationId, myOrganizations } = storeToRefs(userStore)
8
+ const { setActiveOrganizationId } = userStore
9
+ const toast = useToast()
10
+
11
+ // Type for organization with role
12
+ type OrganizationWithRole = Organization & { role?: string | null }
13
+
14
+ if (!userStore.isAdmin) {
15
+ throw createError({ statusCode: 403, message: 'Access denied. You must be an admin to view this page.' })
16
+ }
17
+
18
+ // Page metadata
19
+ useSeoMeta({
20
+ title: t('myOrganizations.title'),
21
+ description: t('myOrganizations.description')
22
+ })
23
+
24
+ const isActiveOrganization = (organizationId: string) => {
25
+ if (!activeOrganizationId.value) {
26
+ return false
27
+ }
28
+ return activeOrganizationId.value === organizationId
29
+ }
30
+
31
+ const handleSetActive = async (organizationId: string) => {
32
+ try {
33
+ await setActiveOrganizationId(organizationId)
34
+ toast.add({
35
+ title: t('profile.messages.success'),
36
+ description: t('profile.messages.organizationActivated'),
37
+ color: 'success'
38
+ })
39
+ } catch (error) {
40
+ console.error('Error setting active organization:', error)
41
+ toast.add({
42
+ title: t('profile.messages.error'),
43
+ description: t('profile.messages.failedToActivateOrganization'),
44
+ color: 'error'
45
+ })
46
+ }
47
+ }
48
+ </script>
49
+
50
+ <template>
51
+ <div class="min-h-screen bg-gray-50 dark:bg-gray-900">
52
+ <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
53
+ <!-- Page Header -->
54
+ <div class="mb-8">
55
+ <h1 class="text-3xl font-bold text-gray-900 dark:text-white">
56
+ {{ $t('myOrganizations.title') }}
57
+ </h1>
58
+ <p class="mt-2 hidden text-sm text-gray-600 dark:text-gray-400 sm:block">
59
+ {{ $t('myOrganizations.description') }}
60
+ </p>
61
+ </div>
62
+
63
+ <UCard>
64
+ <template #header>
65
+ <h2 class="text-xl font-semibold text-gray-900 dark:text-white">
66
+ {{ $t('profile.sections.organizations') }}
67
+ </h2>
68
+ </template>
69
+ <div v-if="myOrganizations === null || myOrganizations === undefined">Loading...</div>
70
+ <div v-else-if="myOrganizations.length === 0">No organizations found.</div>
71
+ <div v-else class="space-y-6">
72
+ <NuxtLink
73
+ v-for="organization in myOrganizations"
74
+ :key="organization.id"
75
+ :to="`/admin/organizations/${organization.slug}?from=my-organizations`"
76
+ class="flex items-start justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
77
+ >
78
+ <div class="flex-1">
79
+ <div class="flex items-center justify-between mb-1">
80
+ <div class="flex items-center gap-2">
81
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ organization.name }}</h3>
82
+ <UBadge
83
+ v-if="(organization as OrganizationWithRole).role"
84
+ :color="
85
+ (organization as OrganizationWithRole).role === 'owner'
86
+ ? 'primary'
87
+ : (organization as OrganizationWithRole).role === 'admin'
88
+ ? 'info'
89
+ : 'neutral'
90
+ "
91
+ variant="soft"
92
+ >
93
+ {{
94
+ String((organization as OrganizationWithRole).role)
95
+ .charAt(0)
96
+ .toUpperCase() + String((organization as OrganizationWithRole).role).slice(1)
97
+ }}
98
+ </UBadge>
99
+ </div>
100
+ <div class="flex items-center gap-2" @click.stop>
101
+ <UBadge v-if="isActiveOrganization(organization.id)" color="success" variant="subtle">
102
+ {{ $t('profile.badges.active') }}
103
+ </UBadge>
104
+ <UButton
105
+ v-else
106
+ size="sm"
107
+ variant="outline"
108
+ :loading="userStore.isLoading"
109
+ @click="handleSetActive(organization.id)"
110
+ >
111
+ {{ $t('profile.buttons.setActive') }}
112
+ </UButton>
113
+ </div>
114
+ </div>
115
+ <p class="text-sm text-gray-600 dark:text-gray-400">{{ organization.slug }}</p>
116
+ <p class="text-xs text-gray-500 mt-1">
117
+ {{ $t('common.createdAt') }}: {{ new Date(organization.createdAt).toLocaleDateString() }}
118
+ </p>
119
+ </div>
120
+ </NuxtLink>
121
+ </div>
122
+ </UCard>
123
+ </div>
124
+ </div>
125
+ </template>
@@ -0,0 +1,248 @@
1
+ <script setup lang="ts">
2
+ import * as z from 'zod'
3
+ import type { FormSubmitEvent } from '@nuxt/ui'
4
+
5
+ const { t } = useI18n()
6
+
7
+ const userStore = useUserStore()
8
+ const { currentUser } = storeToRefs(userStore)
9
+ const { setCurrentUser } = userStore
10
+ const toast = useToast()
11
+
12
+ const fileRef = ref<HTMLInputElement>()
13
+
14
+ // Zod schema for profile form validation
15
+ const schema = z
16
+ .object({
17
+ name: z
18
+ .string()
19
+ .trim()
20
+ .min(1, t('profile.validation.nameRequired'))
21
+ .max(255, t('profile.validation.nameMaxLength')),
22
+ image: z
23
+ .union([z.string().url(t('profile.validation.imageInvalidUrl')), z.literal('').transform(() => null), z.null()])
24
+ .optional()
25
+ })
26
+ .refine((data) => data.name !== undefined || data.image !== undefined, {
27
+ message: t('profile.validation.atLeastOneField')
28
+ })
29
+
30
+ type Schema = z.output<typeof schema>
31
+
32
+ // Form state
33
+ const form = reactive<Partial<Schema>>({
34
+ name: '',
35
+ image: ''
36
+ })
37
+
38
+ const isLoading = ref(false)
39
+ const isDirty = ref(false)
40
+ const selectedFile = ref<File | null>(null)
41
+
42
+ // Initialize form with current user data
43
+ watchEffect(() => {
44
+ if (currentUser.value) {
45
+ form.name = currentUser.value.name || ''
46
+ form.image = currentUser.value.image || ''
47
+ isDirty.value = false
48
+ }
49
+ })
50
+
51
+ // Watch for form changes
52
+ watch(
53
+ () => [form.name, form.image],
54
+ () => {
55
+ if (currentUser.value) {
56
+ const nameChanged = form.name !== (currentUser.value.name || '')
57
+ const imageChanged = form.image !== (currentUser.value.image || '')
58
+ isDirty.value = nameChanged || imageChanged
59
+ }
60
+ },
61
+ { deep: true }
62
+ )
63
+
64
+ // Page metadata
65
+ useSeoMeta({
66
+ title: t('profile.title'),
67
+ description: t('profile.description')
68
+ })
69
+
70
+ const handleSubmit = async (event: FormSubmitEvent<Schema>) => {
71
+ if (!currentUser.value) {
72
+ return
73
+ }
74
+
75
+ isLoading.value = true
76
+ try {
77
+ let imageUrl = event.data.image === null || event.data.image === '' ? null : event.data.image
78
+
79
+ // If a file was selected, convert it to base64 data URL
80
+ if (selectedFile.value) {
81
+ const reader = new FileReader()
82
+ imageUrl = await new Promise<string>((resolve, reject) => {
83
+ reader.onload = () => resolve(reader.result as string)
84
+ reader.onerror = reject
85
+ reader.readAsDataURL(selectedFile.value!)
86
+ })
87
+ }
88
+
89
+ const response = await $fetch<{
90
+ success: boolean
91
+ message: string
92
+ user?: {
93
+ id: string
94
+ name: string
95
+ email: string
96
+ image: string | null
97
+ }
98
+ }>('/api/profile', {
99
+ method: 'PATCH',
100
+ body: {
101
+ name: event.data.name?.trim(),
102
+ image: imageUrl
103
+ }
104
+ })
105
+
106
+ if (response.success && response.user) {
107
+ // Update the user store with new data
108
+ setCurrentUser({
109
+ ...currentUser.value,
110
+ name: response.user.name,
111
+ image: response.user.image || undefined
112
+ })
113
+
114
+ toast.add({
115
+ title: t('profile.messages.success'),
116
+ description: t('profile.messages.profileUpdated'),
117
+ color: 'success'
118
+ })
119
+
120
+ isDirty.value = false
121
+ selectedFile.value = null
122
+ // Revoke the blob URL if it was created
123
+ if (form.image && form.image.startsWith('blob:')) {
124
+ URL.revokeObjectURL(form.image)
125
+ }
126
+ // Update form.image with the saved URL
127
+ form.image = response.user.image || ''
128
+ }
129
+ } catch (error: unknown) {
130
+ console.error('Error updating profile:', error)
131
+ const errorMessage =
132
+ (error as { data?: { message?: string }; message?: string })?.data?.message ||
133
+ (error as { message?: string })?.message ||
134
+ t('profile.messages.updateFailed')
135
+ toast.add({
136
+ title: t('profile.messages.error'),
137
+ description: errorMessage,
138
+ color: 'error'
139
+ })
140
+ } finally {
141
+ isLoading.value = false
142
+ }
143
+ }
144
+
145
+ const handleReset = () => {
146
+ if (currentUser.value) {
147
+ // Revoke blob URL if it exists
148
+ if (form.image && form.image.startsWith('blob:')) {
149
+ URL.revokeObjectURL(form.image)
150
+ }
151
+ form.name = currentUser.value.name || ''
152
+ form.image = currentUser.value.image || ''
153
+ isDirty.value = false
154
+ selectedFile.value = null
155
+ if (fileRef.value) {
156
+ fileRef.value.value = ''
157
+ }
158
+ }
159
+ }
160
+
161
+ function onFileChange(e: Event) {
162
+ const input = e.target as HTMLInputElement
163
+
164
+ if (!input.files?.length) {
165
+ return
166
+ }
167
+
168
+ const file = input.files[0]!
169
+
170
+ // Revoke previous blob URL if it exists
171
+ if (form.image && form.image.startsWith('blob:')) {
172
+ URL.revokeObjectURL(form.image)
173
+ }
174
+
175
+ selectedFile.value = file
176
+
177
+ // Create preview URL
178
+ form.image = URL.createObjectURL(file)
179
+ isDirty.value = true
180
+ }
181
+
182
+ function onFileClick() {
183
+ fileRef.value?.click()
184
+ }
185
+
186
+ // Cleanup blob URLs on unmount
187
+ onUnmounted(() => {
188
+ if (form.image && form.image.startsWith('blob:')) {
189
+ URL.revokeObjectURL(form.image)
190
+ }
191
+ })
192
+ </script>
193
+
194
+ <template>
195
+ <AppCard class="mb-8" :title="$t('profile.sections.profileInfo')">
196
+ <UForm :state="form" :schema="schema" class="space-y-6" @submit="handleSubmit">
197
+ <!-- Profile Picture Section -->
198
+ <UFormField
199
+ :label="$t('profile.fields.profilePicture')"
200
+ :description="$t('profile.fields.profilePictureDescription')"
201
+ name="image"
202
+ class="flex max-sm:flex-col justify-between sm:items-center gap-4"
203
+ >
204
+ <div class="flex flex-wrap items-center gap-3">
205
+ <UAvatar
206
+ :src="form.image || undefined"
207
+ :alt="form.name || currentUser?.email || 'User'"
208
+ :text="form.name ? form.name.charAt(0).toUpperCase() : currentUser?.email?.charAt(0).toUpperCase() || 'U'"
209
+ size="xl"
210
+ class="ring-2 ring-gray-200 dark:ring-gray-700"
211
+ />
212
+ <UButton :label="$t('profile.fields.chooseAvatar')" color="primary" variant="outline" @click="onFileClick" />
213
+ <input ref="fileRef" type="file" class="hidden" accept=".jpg, .jpeg, .png, .gif" @change="onFileChange" />
214
+ </div>
215
+ </UFormField>
216
+
217
+ <UFormField
218
+ :label="$t('profile.fields.name')"
219
+ :description="$t('profile.fields.nameDescription')"
220
+ name="name"
221
+ required
222
+ >
223
+ <UInput
224
+ v-model="form.name"
225
+ type="text"
226
+ :placeholder="$t('profile.fields.namePlaceholder')"
227
+ icon="i-lucide-user"
228
+ class="w-full"
229
+ required
230
+ />
231
+ </UFormField>
232
+
233
+ <UFormField :label="$t('profile.fields.email')" :description="$t('profile.fields.emailDescription')" name="email">
234
+ <UInput :model-value="currentUser?.email || ''" type="email" disabled icon="i-lucide-mail" class="w-full" />
235
+ </UFormField>
236
+
237
+ <!-- Form Actions -->
238
+ <div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
239
+ <UButton type="button" variant="ghost" color="neutral" :disabled="!isDirty || isLoading" @click="handleReset">
240
+ {{ $t('common.reset') }}
241
+ </UButton>
242
+ <UButton type="submit" :loading="isLoading" :disabled="!isDirty">
243
+ {{ $t('common.save') }}
244
+ </UButton>
245
+ </div>
246
+ </UForm>
247
+ </AppCard>
248
+ </template>