@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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/app/components/DeleteAccountModal.vue +139 -0
- package/app/components/OrganizationMembers.vue +298 -0
- package/app/components/OrganizationSettings.vue +315 -0
- package/app/components/OrganizationSwitcher.vue +166 -0
- package/app/components/organization/DetailsCard.vue +84 -0
- package/app/components/organization/InvitationsCard.vue +112 -0
- package/app/components/organization/InviteMemberModal.vue +101 -0
- package/app/components/organization/MembersCard.vue +108 -0
- package/app/pages/my-organizations.vue +125 -0
- package/app/pages/settings/index.vue +248 -0
- package/app/pages/settings/organization.vue +171 -0
- package/app/pages/settings/security.vue +195 -0
- package/app/pages/settings.vue +101 -0
- package/app/plugins/account-organizations-feature.ts +3 -0
- package/i18n/locales/en.json +208 -0
- package/i18n/locales/nl.json +208 -0
- package/nuxt.config.ts +11 -0
- package/package.json +51 -0
- package/portal.manifest.mjs +6 -0
- package/server/api/organizations/[id]/index.get.ts +54 -0
- package/server/api/organizations/[id]/invitations/[invitationId]/delete.post.ts +42 -0
- package/server/api/organizations/[id]/invitations/[invitationId]/resend.post.ts +91 -0
- package/server/api/organizations/[id]/invitations/[invitationId].patch.ts +18 -0
- package/server/api/organizations/[id]/invitations.get.ts +49 -0
- package/server/api/organizations/accept-invitation.post.ts +131 -0
- package/server/api/organizations/get-invitation.get.ts +73 -0
- package/server/api/profile.patch.ts +99 -0
- 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>
|