@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,315 @@
1
+ <script setup lang="ts">
2
+ import * as z from 'zod'
3
+ import type { FormSubmitEvent } from '@nuxt/ui'
4
+ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
5
+ import type { ApiError, Organization } from '@nuxt-customer-portal/core/shared/types/index'
6
+
7
+ const emit = defineEmits<{
8
+ updated: []
9
+ canceled: []
10
+ }>()
11
+
12
+ const loading = ref(true)
13
+ const updating = ref(false)
14
+ const error = ref('')
15
+ const memberRole = ref<string | null>(null)
16
+ const originalSlug = ref('')
17
+ const slugStatus = ref<'idle' | 'checking' | 'available' | 'unavailable' | 'error'>('idle')
18
+ const slugMessage = ref('')
19
+ const fileRef = ref<HTMLInputElement>()
20
+ const selectedFile = ref<File | null>(null)
21
+ const formState = reactive({
22
+ id: '',
23
+ name: '',
24
+ slug: '',
25
+ logo: ''
26
+ })
27
+
28
+ const { t } = useI18n()
29
+
30
+ const normalizeSlug = (value: string) =>
31
+ value
32
+ .toLowerCase()
33
+ .trim()
34
+ .replace(/[^a-z0-9-]+/g, '-')
35
+ .replace(/^-+|-+$/g, '')
36
+
37
+ const formSchema = computed(() =>
38
+ z.object({
39
+ name: z.string().min(1, t('organization.settings.validation.nameRequired')),
40
+ slug: z
41
+ .string()
42
+ .min(1, t('organization.settings.validation.slugRequired'))
43
+ .transform((value) => normalizeSlug(value))
44
+ .refine((value) => /^[a-z0-9-]+$/.test(value), {
45
+ message: t('organization.settings.validation.slugInvalid')
46
+ }),
47
+ logo: z.string().url(t('organization.settings.validation.logoInvalid')).optional().or(z.literal(''))
48
+ })
49
+ )
50
+
51
+ const userStore = useUserStore()
52
+
53
+ const loadOrganization = async () => {
54
+ try {
55
+ loading.value = true
56
+ const { data: member } = await authClient.organization.getActiveMember()
57
+ const { data: roleData } = await authClient.organization.getActiveMemberRole()
58
+ memberRole.value = roleData?.role ?? null
59
+ if (member?.organizationId) {
60
+ // Fetch organization details using organizationId
61
+ const organization = await $fetch<Organization>(`/api/organizations/${member.organizationId}`)
62
+ if (organization) {
63
+ formState.id = organization.id
64
+ formState.name = organization.name
65
+ formState.slug = organization.slug
66
+ formState.logo = organization.logo || ''
67
+ originalSlug.value = organization.slug
68
+ slugStatus.value = 'idle'
69
+ slugMessage.value = ''
70
+ selectedFile.value = null
71
+ }
72
+ }
73
+ } catch (err) {
74
+ const apiError = err as ApiError
75
+ error.value = apiError.message || t('organization.settings.errors.loadFailed')
76
+ } finally {
77
+ loading.value = false
78
+ }
79
+ }
80
+ await loadOrganization()
81
+
82
+ const isOwner = computed(() => memberRole.value === 'owner')
83
+ const slugChanged = computed(() => normalizeSlug(formState.slug) !== normalizeSlug(originalSlug.value))
84
+
85
+ const checkSlugAvailability = async (slug: string, { updateStatus = true } = {}) => {
86
+ if (!slug.trim() || !slugChanged.value) {
87
+ if (updateStatus) {
88
+ slugStatus.value = 'idle'
89
+ slugMessage.value = ''
90
+ }
91
+ return true
92
+ }
93
+
94
+ if (updateStatus) {
95
+ slugStatus.value = 'checking'
96
+ slugMessage.value = ''
97
+ }
98
+
99
+ try {
100
+ const { data } = await authClient.organization.checkSlug({ slug })
101
+ const available = data?.status || false
102
+ if (updateStatus) {
103
+ slugStatus.value = available ? 'available' : 'unavailable'
104
+ slugMessage.value = available
105
+ ? t('organization.settings.slugAvailable')
106
+ : t('organization.settings.slugUnavailable')
107
+ }
108
+ return available
109
+ } catch {
110
+ if (updateStatus) {
111
+ slugStatus.value = 'error'
112
+ slugMessage.value = t('organization.settings.slugError')
113
+ }
114
+ return false
115
+ }
116
+ }
117
+
118
+ const debouncedSlugCheck = useDebounceFn((slug: string) => {
119
+ void checkSlugAvailability(slug)
120
+ }, 400)
121
+
122
+ watch(
123
+ () => formState.slug,
124
+ (newSlug) => {
125
+ const normalized = normalizeSlug(newSlug)
126
+ if (normalized !== newSlug) {
127
+ formState.slug = normalized
128
+ return
129
+ }
130
+ if (!slugChanged.value) {
131
+ slugStatus.value = 'idle'
132
+ slugMessage.value = ''
133
+ return
134
+ }
135
+
136
+ debouncedSlugCheck(newSlug)
137
+ }
138
+ )
139
+
140
+ const updateOrganization = async (event: FormSubmitEvent<z.output<typeof formSchema.value>>) => {
141
+ try {
142
+ if (!isOwner.value) {
143
+ error.value = t('organization.settings.ownerOnly')
144
+ return
145
+ }
146
+
147
+ updating.value = true
148
+ error.value = ''
149
+
150
+ if (slugChanged.value) {
151
+ const available = await checkSlugAvailability(event.data.slug, { updateStatus: false })
152
+ if (!available) {
153
+ error.value = t('organization.settings.errors.slugTaken')
154
+ return
155
+ }
156
+ }
157
+
158
+ let logoUrl = event.data.logo ?? ''
159
+ if (selectedFile.value) {
160
+ const reader = new FileReader()
161
+ logoUrl = await new Promise<string>((resolve, reject) => {
162
+ reader.onload = () => resolve(reader.result as string)
163
+ reader.onerror = reject
164
+ reader.readAsDataURL(selectedFile.value!)
165
+ })
166
+ }
167
+
168
+ await authClient.organization.update({
169
+ organizationId: formState.id,
170
+ data: {
171
+ name: event.data.name,
172
+ slug: event.data.slug,
173
+ logo: logoUrl
174
+ }
175
+ })
176
+ // Show success message
177
+ error.value = ''
178
+ originalSlug.value = event.data.slug
179
+ slugStatus.value = 'idle'
180
+ slugMessage.value = ''
181
+ selectedFile.value = null
182
+ await userStore.refreshOrganizations()
183
+ emit('updated')
184
+ } catch (err) {
185
+ const apiError = err as ApiError
186
+ error.value = apiError.message || t('organization.settings.errors.updateFailed')
187
+ } finally {
188
+ updating.value = false
189
+ }
190
+ }
191
+
192
+ function onLogoFileChange(e: Event) {
193
+ const input = e.target as HTMLInputElement
194
+
195
+ if (!input.files?.length) {
196
+ return
197
+ }
198
+
199
+ const file = input.files[0]!
200
+
201
+ if (formState.logo && formState.logo.startsWith('blob:')) {
202
+ URL.revokeObjectURL(formState.logo)
203
+ }
204
+
205
+ selectedFile.value = file
206
+ formState.logo = URL.createObjectURL(file)
207
+ }
208
+
209
+ function onLogoFileClick() {
210
+ fileRef.value?.click()
211
+ }
212
+
213
+ onUnmounted(() => {
214
+ if (formState.logo && formState.logo.startsWith('blob:')) {
215
+ URL.revokeObjectURL(formState.logo)
216
+ }
217
+ })
218
+ </script>
219
+
220
+ <template>
221
+ <div class="space-y-4">
222
+ <div v-if="loading" class="text-center py-8">
223
+ <UIcon name="i-lucide-loader-2" class="w-8 h-8 animate-spin mx-auto" />
224
+ <p class="text-gray-600 dark:text-gray-400 mt-2">{{ t('common.loading') }}</p>
225
+ </div>
226
+
227
+ <UAlert v-else-if="error" color="error" :title="error" variant="outline" />
228
+
229
+ <UForm v-else class="space-y-4" :state="formState" :schema="formSchema" @submit="updateOrganization">
230
+ <UAlert v-if="!isOwner" color="warning" :title="t('organization.settings.ownerOnly')" variant="outline" />
231
+ <UFormField name="name" :label="t('organization.settings.nameLabel')" required>
232
+ <UInput
233
+ v-model="formState.name"
234
+ type="text"
235
+ :placeholder="t('organization.settings.namePlaceholder')"
236
+ required
237
+ :disabled="!isOwner"
238
+ class="w-full"
239
+ />
240
+ </UFormField>
241
+
242
+ <UFormField name="slug" :label="t('organization.settings.slugLabel')" required>
243
+ <UInput
244
+ v-model="formState.slug"
245
+ type="text"
246
+ :placeholder="t('organization.settings.slugPlaceholder')"
247
+ required
248
+ :disabled="!isOwner"
249
+ class="w-full"
250
+ />
251
+ <template #hint>
252
+ <div class="flex flex-col gap-1">
253
+ <span>{{ t('organization.settings.slugHint') }}</span>
254
+ <span v-if="slugStatus === 'checking'" class="text-sm text-gray-500">{{
255
+ t('organization.settings.slugChecking')
256
+ }}</span>
257
+ <span v-else-if="slugStatus === 'available'" class="text-sm text-green-600">{{ slugMessage }}</span>
258
+ <span v-else-if="slugStatus === 'unavailable'" class="text-sm text-red-600">{{ slugMessage }}</span>
259
+ <span v-else-if="slugStatus === 'error'" class="text-sm text-red-600">{{ slugMessage }}</span>
260
+ </div>
261
+ </template>
262
+ </UFormField>
263
+
264
+ <UFormField name="logo" :label="t('organization.settings.logoLabel')">
265
+ <div class="flex flex-col gap-3">
266
+ <div class="flex flex-wrap items-center gap-3">
267
+ <div
268
+ class="flex items-center justify-center w-28 h-16 rounded-md ring-2 ring-gray-200 dark:ring-gray-700 bg-gray-50 dark:bg-gray-900 overflow-hidden"
269
+ >
270
+ <img
271
+ v-if="formState.logo"
272
+ :src="formState.logo"
273
+ :alt="formState.name || 'Organization'"
274
+ class="w-full h-full object-contain"
275
+ />
276
+ <span v-else class="text-sm font-semibold text-gray-500">
277
+ {{ formState.name ? formState.name.charAt(0).toUpperCase() : 'O' }}
278
+ </span>
279
+ </div>
280
+ <UButton
281
+ :label="t('organization.settings.logoChoose')"
282
+ color="primary"
283
+ variant="outline"
284
+ :disabled="!isOwner"
285
+ @click="onLogoFileClick"
286
+ />
287
+ <input
288
+ ref="fileRef"
289
+ type="file"
290
+ class="hidden"
291
+ accept=".jpg, .jpeg, .png, .gif"
292
+ @change="onLogoFileChange"
293
+ />
294
+ </div>
295
+ <p class="text-sm text-gray-600 dark:text-gray-400">
296
+ {{ t('organization.settings.logoHint') }}
297
+ </p>
298
+ </div>
299
+ </UFormField>
300
+ <!-- Form Actions -->
301
+ <div class="flex items-center justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
302
+ <UButton type="button" variant="ghost" color="neutral" @click="emit('canceled')">
303
+ {{ $t('common.cancel') }}
304
+ </UButton>
305
+ <UButton
306
+ type="submit"
307
+ :loading="updating"
308
+ :disabled="updating || !isOwner || (slugChanged && slugStatus !== 'available')"
309
+ >
310
+ {{ $t('common.save') }}
311
+ </UButton>
312
+ </div>
313
+ </UForm>
314
+ </div>
315
+ </template>
@@ -0,0 +1,166 @@
1
+ <script setup lang="ts">
2
+ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
3
+ import type { SelectItem } from '@nuxt/ui'
4
+
5
+ interface Props {
6
+ /** Whether to show in compact/header mode without card wrapper */
7
+ compact?: boolean
8
+ /** Whether to show the create button */
9
+ showCreateButton?: boolean
10
+ }
11
+
12
+ withDefaults(defineProps<Props>(), {
13
+ compact: false,
14
+ showCreateButton: true
15
+ })
16
+
17
+ const emit = defineEmits<{
18
+ switched: []
19
+ }>()
20
+
21
+ const toast = useToast()
22
+ const { t } = useI18n()
23
+ const userStore = useUserStore()
24
+ const { activeOrganizationId } = storeToRefs(userStore)
25
+ const selectedOrg = ref('')
26
+ const error = ref('')
27
+ const organizations = authClient.useListOrganizations()
28
+
29
+ // Sync selectedOrg with activeOrganizationId from store
30
+ watch(
31
+ activeOrganizationId,
32
+ (newOrgId) => {
33
+ if (newOrgId && newOrgId !== selectedOrg.value) {
34
+ selectedOrg.value = newOrgId
35
+ }
36
+ },
37
+ { immediate: true }
38
+ )
39
+
40
+ // Also watch for when organizations load and set initial value
41
+ watch(
42
+ () => organizations.value.data,
43
+ (orgs) => {
44
+ if (orgs && activeOrganizationId.value && !selectedOrg.value) {
45
+ selectedOrg.value = activeOrganizationId.value
46
+ }
47
+ },
48
+ { immediate: true }
49
+ )
50
+
51
+ // Check if user has any organizations
52
+ const hasOrgs = computed(() => {
53
+ return organizations.value.data && organizations.value.data.length > 0
54
+ })
55
+
56
+ const selectItems = computed<SelectItem[]>(() => {
57
+ if (!organizations.value.data) {
58
+ return []
59
+ }
60
+ return organizations.value.data.map((org) => ({
61
+ label: org.name,
62
+ value: org.id
63
+ }))
64
+ })
65
+
66
+ const switchOrganization = async (value: unknown) => {
67
+ if (!value || typeof value !== 'string') {
68
+ return
69
+ }
70
+
71
+ try {
72
+ // Update the store's activeOrganizationId (this also calls authClient.organization.setActive)
73
+ await userStore.setActiveOrganizationId(value)
74
+ emit('switched')
75
+ navigateTo('/dashboard')
76
+ } catch (err: unknown) {
77
+ const errorMessage = err instanceof Error ? err.message : 'Failed to switch organization'
78
+ error.value = errorMessage
79
+ toast.add({
80
+ title: t('common.error'),
81
+ description: errorMessage,
82
+ color: 'error'
83
+ })
84
+ }
85
+ }
86
+
87
+ const createNewOrg = () => {
88
+ // Navigate to organization creation page or show modal
89
+ navigateTo('/organizations/create')
90
+ }
91
+ </script>
92
+
93
+ <template>
94
+ <div :class="compact ? '' : 'organization-switcher-wrapper'">
95
+ <!-- Full version (for modal or standalone use) -->
96
+ <div v-if="!compact">
97
+ <div v-if="organizations.isPending || organizations.isRefetching" class="space-y-2">
98
+ <USkeleton class="h-10 w-full" />
99
+ <USkeleton v-if="showCreateButton" class="h-10 w-full" />
100
+ </div>
101
+ <div v-else-if="organizations.error" class="space-y-4">
102
+ <UAlert
103
+ color="error"
104
+ :title="t('common.error')"
105
+ :description="organizations.error?.message || 'Failed to load organizations'"
106
+ icon="i-lucide-alert-circle"
107
+ variant="outline"
108
+ />
109
+ </div>
110
+ <div v-else class="flex flex-col gap-4">
111
+ <UFormField label="Select Organization" name="organization">
112
+ <USelect
113
+ id="org-select"
114
+ v-model="selectedOrg"
115
+ :items="selectItems"
116
+ placeholder="Select Organization"
117
+ value-key="value"
118
+ class="w-full"
119
+ @update:model-value="switchOrganization"
120
+ />
121
+ </UFormField>
122
+ <UButton
123
+ v-if="showCreateButton"
124
+ color="primary"
125
+ variant="solid"
126
+ block
127
+ icon="i-lucide-plus"
128
+ @click="createNewOrg"
129
+ >
130
+ Create New Organization
131
+ </UButton>
132
+ </div>
133
+ </div>
134
+
135
+ <!-- Compact version for header -->
136
+ <div v-else>
137
+ <div v-if="organizations.isPending || organizations.isRefetching">
138
+ <USkeleton class="h-9 w-48" />
139
+ </div>
140
+ <div v-else-if="organizations.error" class="hidden">
141
+ <!-- Silently fail in compact mode -->
142
+ </div>
143
+ <div v-else-if="hasOrgs" class="flex items-center gap-2">
144
+ <USelect
145
+ id="org-select-header"
146
+ v-model="selectedOrg"
147
+ :items="selectItems"
148
+ placeholder="Organization"
149
+ value-key="value"
150
+ size="sm"
151
+ class="min-w-[160px]"
152
+ @update:model-value="switchOrganization"
153
+ />
154
+ <UButton
155
+ v-if="showCreateButton"
156
+ color="primary"
157
+ variant="ghost"
158
+ size="sm"
159
+ icon="i-lucide-plus"
160
+ :ui="{ base: 'p-1.5' }"
161
+ @click="createNewOrg"
162
+ />
163
+ </div>
164
+ </div>
165
+ </div>
166
+ </template>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ import type { Organization } from '@nuxt-customer-portal/core/shared/types/index'
3
+
4
+ const props = withDefaults(
5
+ defineProps<{
6
+ canEdit?: boolean
7
+ editing?: boolean
8
+ organization: Organization
9
+ role?: string | null
10
+ }>(),
11
+ {
12
+ canEdit: false,
13
+ editing: false,
14
+ role: null
15
+ }
16
+ )
17
+
18
+ const emit = defineEmits(['edit'])
19
+ const { t } = useI18n()
20
+ const officialCompanyName = computed(() => {
21
+ if (!props.organization.metadata) {
22
+ return props.organization.name
23
+ }
24
+ try {
25
+ const metadata = JSON.parse(props.organization.metadata) as Record<string, unknown>
26
+ return typeof metadata.officialCompanyName === 'string' ? metadata.officialCompanyName : props.organization.name
27
+ } catch {
28
+ return props.organization.name
29
+ }
30
+ })
31
+ </script>
32
+
33
+ <template>
34
+ <UCard>
35
+ <template #header>
36
+ <div class="flex items-center justify-between gap-4">
37
+ <h2 class="text-xl font-semibold">{{ t('organization.details.title') }}</h2>
38
+ <UButton
39
+ v-if="canEdit && !editing"
40
+ icon="i-lucide-pencil"
41
+ color="neutral"
42
+ variant="ghost"
43
+ size="xs"
44
+ :aria-label="t('organization.settings.editButton')"
45
+ @click="emit('edit')"
46
+ />
47
+ </div>
48
+ </template>
49
+ <slot v-if="editing" name="edit" />
50
+ <div v-else class="space-y-2">
51
+ <img
52
+ v-if="organization.logo"
53
+ :src="organization.logo"
54
+ :alt="officialCompanyName"
55
+ class="mb-4 max-h-20 max-w-56 object-contain object-left"
56
+ />
57
+ <div>
58
+ <span class="text-sm text-muted">{{ t('organization.details.name') }}</span>
59
+ <div class="flex items-center gap-2">
60
+ <p class="font-semibold">{{ organization.name }}</p>
61
+ <UBadge
62
+ v-if="role"
63
+ :color="role === 'owner' ? 'primary' : role === 'admin' ? 'info' : 'neutral'"
64
+ variant="soft"
65
+ >
66
+ {{ role.charAt(0).toUpperCase() + role.slice(1) }}
67
+ </UBadge>
68
+ </div>
69
+ </div>
70
+ <div>
71
+ <span class="text-sm text-muted">{{ t('organization.details.slug') }}</span>
72
+ <p class="font-mono text-sm">{{ organization.slug }}</p>
73
+ </div>
74
+ <div>
75
+ <span class="text-sm text-muted">{{ t('organization.settings.officialCompanyNameLabel') }}</span>
76
+ <p>{{ officialCompanyName }}</p>
77
+ </div>
78
+ <div>
79
+ <span class="text-sm text-muted">{{ t('organization.details.created') }}</span>
80
+ <p>{{ new Date(organization.createdAt).toLocaleDateString() }}</p>
81
+ </div>
82
+ </div>
83
+ </UCard>
84
+ </template>
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ import type {
3
+ ApiError,
4
+ Invitation,
5
+ OrganizationInvitationsResponse
6
+ } from '@nuxt-customer-portal/core/shared/types/index'
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ organizationId: string
11
+ invitations: OrganizationInvitationsResponse
12
+ loading?: boolean
13
+ canManage?: boolean
14
+ }>(),
15
+ {
16
+ loading: false,
17
+ canManage: false
18
+ }
19
+ )
20
+
21
+ const emit = defineEmits<{ refresh: [] }>()
22
+ const { t, locale } = useI18n()
23
+ const toast = useToast()
24
+ const showInviteModal = ref(false)
25
+ const busyInvitationId = ref<string | null>(null)
26
+
27
+ const resend = async (invitation: Invitation) => {
28
+ try {
29
+ busyInvitationId.value = invitation.id
30
+ await $fetch(`/api/organizations/${props.organizationId}/invitations/${invitation.id}/resend`, { method: 'POST' })
31
+ toast.add({
32
+ title: t('common.success'),
33
+ description: t('organization.members.invitations.resendSuccess'),
34
+ color: 'success'
35
+ })
36
+ emit('refresh')
37
+ } catch (error) {
38
+ const apiError = error as ApiError
39
+ toast.add({
40
+ title: t('common.error'),
41
+ description: apiError.message || t('organization.members.errors.resendFailed'),
42
+ color: 'error'
43
+ })
44
+ } finally {
45
+ busyInvitationId.value = null
46
+ }
47
+ }
48
+ </script>
49
+
50
+ <template>
51
+ <UCard>
52
+ <template #header>
53
+ <div class="flex flex-wrap items-center justify-between gap-3">
54
+ <h2 class="text-xl font-semibold">
55
+ {{ t('organization.members.invitations.title') }} ({{ invitations.length }})
56
+ </h2>
57
+ <UButton v-if="canManage" icon="i-lucide-user-plus" variant="outline" @click="showInviteModal = true">
58
+ {{ t('organization.members.invitations.inviteButton') }}
59
+ </UButton>
60
+ </div>
61
+ </template>
62
+
63
+ <div v-if="loading" class="py-8 text-center text-muted">{{ t('organization.members.loading') }}</div>
64
+ <div v-else-if="invitations.length === 0" class="py-8 text-center text-muted">
65
+ {{ t('organization.members.invitations.empty') }}
66
+ </div>
67
+ <div v-else class="divide-y divide-default">
68
+ <div
69
+ v-for="invitation in invitations"
70
+ :key="invitation.id"
71
+ class="flex flex-wrap items-center justify-between gap-3 py-4 first:pt-0 last:pb-0"
72
+ >
73
+ <div>
74
+ <p class="font-medium">{{ invitation.email }}</p>
75
+ <p class="text-sm text-muted">
76
+ {{ t('organization.members.invitations.expires') }}:
77
+ {{ new Date(invitation.expiresAt).toLocaleDateString(locale) }}
78
+ </p>
79
+ </div>
80
+ <div class="flex items-center gap-2">
81
+ <UBadge color="warning" variant="soft">{{ invitation.role || 'member' }}</UBadge>
82
+ <template v-if="canManage">
83
+ <UButton
84
+ icon="i-lucide-send"
85
+ variant="ghost"
86
+ size="sm"
87
+ :loading="busyInvitationId === invitation.id"
88
+ :aria-label="t('organization.members.invitations.resend')"
89
+ @click="resend(invitation)"
90
+ />
91
+ <InvitationActions
92
+ v-if="invitation.status === 'pending'"
93
+ :endpoint="`/api/organizations/${organizationId}/invitations/${invitation.id}`"
94
+ :email="invitation.email"
95
+ :role="invitation.role ?? null"
96
+ can-edit
97
+ can-revoke
98
+ @refresh="emit('refresh')"
99
+ />
100
+ </template>
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <OrganizationInviteMemberModal
106
+ v-if="showInviteModal"
107
+ v-model:open="showInviteModal"
108
+ :organization-id="organizationId"
109
+ @success="emit('refresh')"
110
+ />
111
+ </UCard>
112
+ </template>