@nuxt-customer-portal/organizations 0.3.3 → 0.4.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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # @nuxt-customer-portal/organizations
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 59c8921: Support organization and personal clients with optional verified self-registration, single-person access safeguards, and shared provider, client, and user timezone preferences. Preserve existing B2B defaults, module references, invoice history, and Timesheets timezone behavior.
8
+
9
+ Collect and store first and last names during signup and personal onboarding. Route ordinary personal registration and verified users without memberships or pending invitations through onboarding.
10
+
11
+ Allow users to edit first name, last name, and an independent display name in profile settings.
12
+
13
+ Synchronize display-name edits with the linked private client name and current billing name in the same transaction, preserving organizations and historical invoices.
14
+
15
+ Let private clients edit their own billing address in profile settings, independently of the active organization, with server-side ownership checks.
16
+
17
+ Private clients use their own invoice email without contact persons; hide contact management and reject private-client contact creation or selection server-side.
18
+
19
+ Add a B2C-only Invite private client action to the Users page, supporting new and existing private clients with atomic record creation, request retry protection, and pending invitation recovery when email delivery fails.
20
+
21
+ ### Patch Changes
22
+
23
+ - 59c8921: Allow SaaS administrators to configure allowed client types and personal self-registration in Portal Settings. Apply saved preferences to server authorization, onboarding, client forms and navigation, preserving configuration defaults for existing portals. Prevent disabling client types that still have records, including during concurrent creation.
24
+ - Updated dependencies [59c8921]
25
+ - Updated dependencies [59c8921]
26
+ - Updated dependencies [59c8921]
27
+ - Updated dependencies [59c8921]
28
+ - Updated dependencies [59c8921]
29
+ - Updated dependencies [59c8921]
30
+ - Updated dependencies [59c8921]
31
+ - Updated dependencies [59c8921]
32
+ - Updated dependencies [59c8921]
33
+ - @nuxt-customer-portal/core@0.4.0
34
+ - @nuxt-customer-portal/ui@0.4.0
35
+
3
36
  ## 0.3.3
4
37
 
5
38
  ### Patch Changes
@@ -91,7 +91,12 @@ const handleCancel = () => {
91
91
  </script>
92
92
 
93
93
  <template>
94
- <UModal v-model:open="open" :title="t('security.account.delete.confirmTitle')" :ui="{ footer: 'justify-end' }">
94
+ <UModal
95
+ v-if="open"
96
+ v-model:open="open"
97
+ :title="t('security.account.delete.confirmTitle')"
98
+ :ui="{ footer: 'justify-end' }"
99
+ >
95
100
  <template #body>
96
101
  <div class="space-y-4">
97
102
  <p class="text-sm text-gray-600 dark:text-gray-400">
@@ -193,7 +193,7 @@ onMounted(() => {
193
193
  </div>
194
194
  </div>
195
195
 
196
- <UModal v-model="showInviteModal" @close="showInviteModal = false">
196
+ <UModal v-if="showInviteModal" v-model:open="showInviteModal">
197
197
  <UCard>
198
198
  <template #header>
199
199
  <h3 class="text-lg font-semibold">Invite Member</h3>
@@ -304,6 +304,8 @@ onUnmounted(() => {
304
304
  </UButton>
305
305
  <UButton
306
306
  type="submit"
307
+ icon="i-lucide-save"
308
+ class="ml-auto flex min-w-28 justify-center"
307
309
  :loading="updating"
308
310
  :disabled="updating || !isOwner || (slugChanged && slugStatus !== 'available')"
309
311
  >
@@ -18,6 +18,7 @@ const emit = defineEmits<{
18
18
  switched: []
19
19
  }>()
20
20
 
21
+ const runtimeConfig = useRuntimeConfig()
21
22
  const toast = useToast()
22
23
  const { t } = useI18n()
23
24
  const userStore = useUserStore()
@@ -25,6 +26,18 @@ const { activeOrganizationId } = storeToRefs(userStore)
25
26
  const selectedOrg = ref('')
26
27
  const error = ref('')
27
28
  const organizations = authClient.useListOrganizations()
29
+ const clientAccounts = ref<{ organizationId: string; clientType: string }[]>([])
30
+ watch(
31
+ () => organizations.value.data?.map((org) => org.id).join(','),
32
+ async () => {
33
+ if (runtimeConfig.public.clients && organizations.value.data?.length) {
34
+ clientAccounts.value = await $fetch('/api/client-account')
35
+ } else {
36
+ clientAccounts.value = []
37
+ }
38
+ },
39
+ { immediate: true }
40
+ )
28
41
 
29
42
  // Sync selectedOrg with activeOrganizationId from store
30
43
  watch(
@@ -58,7 +71,7 @@ const selectItems = computed<SelectItem[]>(() => {
58
71
  return []
59
72
  }
60
73
  return organizations.value.data.map((org) => ({
61
- label: org.name,
74
+ label: `${org.name}${clientAccounts.value.some((account) => account.organizationId === org.id && account.clientType === 'person') ? ` · ${t('timezones.personalAccount')}` : ''}`,
62
75
  value: org.id
63
76
  }))
64
77
  })
@@ -108,12 +121,12 @@ const createNewOrg = () => {
108
121
  />
109
122
  </div>
110
123
  <div v-else class="flex flex-col gap-4">
111
- <UFormField label="Select Organization" name="organization">
124
+ <UFormField :label="t('timezones.selectAccount')" name="organization">
112
125
  <USelect
113
126
  id="org-select"
114
127
  v-model="selectedOrg"
115
128
  :items="selectItems"
116
- placeholder="Select Organization"
129
+ :placeholder="t('timezones.selectAccount')"
117
130
  value-key="value"
118
131
  class="w-full"
119
132
  @update:model-value="switchOrganization"
@@ -145,7 +158,7 @@ const createNewOrg = () => {
145
158
  id="org-select-header"
146
159
  v-model="selectedOrg"
147
160
  :items="selectItems"
148
- placeholder="Organization"
161
+ :placeholder="t('timezones.selectAccount')"
149
162
  value-key="value"
150
163
  size="sm"
151
164
  class="min-w-[160px]"
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import { isValidTimezone } from '@nuxt-customer-portal/core/shared/timezone'
4
+
5
+ const props = defineProps<{ provider?: boolean }>()
6
+ const { t } = useI18n()
7
+ const api = useTimezonePreferences()
8
+ const { data, refresh } = await useAsyncData(`timezone-preferences-${props.provider}`, () => api.get())
9
+ const state = reactive({
10
+ timezone: (props.provider ? data.value?.providerTimezone : data.value?.userTimezone) ?? (null as string | null)
11
+ })
12
+ const schema = computed(() =>
13
+ z.object({
14
+ timezone: z
15
+ .string()
16
+ .nullable()
17
+ .refine((value) => (value === null ? !props.provider : isValidTimezone(value)), t('timezones.invalid'))
18
+ })
19
+ )
20
+ const busy = ref(false)
21
+ const error = ref('')
22
+ const toast = useToast()
23
+ const save = async () => {
24
+ busy.value = true
25
+ error.value = ''
26
+ try {
27
+ if (props.provider) {
28
+ await api.saveProvider(state.timezone!)
29
+ } else {
30
+ await api.saveUser(state.timezone)
31
+ }
32
+ await refresh()
33
+ toast.add({ title: t('timezones.saved'), color: 'success' })
34
+ } catch {
35
+ error.value = t('timezones.failed')
36
+ } finally {
37
+ busy.value = false
38
+ }
39
+ }
40
+ const suggest = () => {
41
+ state.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
42
+ }
43
+ </script>
44
+
45
+ <template>
46
+ <UCard class="mb-6">
47
+ <template #header
48
+ ><h2 class="font-semibold">{{ t(provider ? 'timezones.provider' : 'timezones.preference') }}</h2></template
49
+ >
50
+ <UForm :state="state" :schema="schema" novalidate class="space-y-4" @submit="save">
51
+ <UFormField name="timezone" :label="t('timezones.label')" :description="t('timezones.savedPreference')">
52
+ <PortalTimezoneSelect
53
+ v-model="state.timezone"
54
+ :allow-inherit="!provider"
55
+ :inherited-timezone="data?.schedulingTimezone"
56
+ />
57
+ </UFormField>
58
+ <p v-if="data" class="text-sm text-muted">
59
+ {{ t('timezones.current', { timezone: provider ? data.providerTimezone : data.displayTimezone }) }}
60
+ </p>
61
+ <UAlert v-if="error" color="error" :title="error" />
62
+ <div class="flex justify-end gap-2">
63
+ <UButton v-if="!provider" type="button" variant="outline" @click="suggest">{{
64
+ t('timezones.useDevice')
65
+ }}</UButton>
66
+ <UButton type="submit" icon="i-lucide-save" class="ml-auto flex min-w-28 justify-center" :loading="busy">{{
67
+ t('timezones.save')
68
+ }}</UButton>
69
+ </div>
70
+ </UForm>
71
+ </UCard>
72
+ </template>
@@ -63,7 +63,12 @@ const handleSubmit = async (event: FormSubmitEvent<z.output<typeof formSchema.va
63
63
  </script>
64
64
 
65
65
  <template>
66
- <UModal v-model:open="open" :title="t('organization.members.invitations.modalTitle')" :ui="{ footer: 'justify-end' }">
66
+ <UModal
67
+ v-if="open"
68
+ v-model:open="open"
69
+ :title="t('organization.members.invitations.modalTitle')"
70
+ :ui="{ footer: 'justify-end' }"
71
+ >
67
72
  <template #body>
68
73
  <UForm :state="form" :schema="formSchema" class="space-y-4" @submit="handleSubmit">
69
74
  <UFormField name="email" :label="t('organization.members.invitations.emailLabel')" required>
@@ -0,0 +1,12 @@
1
+ export const useTimezonePreferences = () => ({
2
+ get: () =>
3
+ $fetch<{
4
+ providerTimezone: string
5
+ clientTimezone: string | null
6
+ userTimezone: string | null
7
+ schedulingTimezone: string
8
+ displayTimezone: string
9
+ }>('/api/timezones'),
10
+ saveUser: (timezone: string | null) => $fetch('/api/profile', { method: 'PATCH', body: { timezone } }),
11
+ saveProvider: (timezone: string) => $fetch('/api/provider-timezone', { method: 'PATCH', body: { timezone } })
12
+ })
@@ -8,30 +8,35 @@ const userStore = useUserStore()
8
8
  const { currentUser } = storeToRefs(userStore)
9
9
  const { setCurrentUser } = userStore
10
10
  const toast = useToast()
11
+ const clientConfiguration = useClientConfiguration()
11
12
 
12
13
  const fileRef = ref<HTMLInputElement>()
13
14
 
14
15
  // 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
- })
16
+ const schema = z.object({
17
+ name: z.string().trim().min(1, t('profile.validation.nameRequired')).max(255, t('profile.validation.nameMaxLength')),
18
+ firstName: z
19
+ .string()
20
+ .trim()
21
+ .min(1, t('profile.validation.firstNameRequired'))
22
+ .max(80, t('profile.validation.namePartMaxLength')),
23
+ lastName: z
24
+ .string()
25
+ .trim()
26
+ .min(1, t('profile.validation.lastNameRequired'))
27
+ .max(80, t('profile.validation.namePartMaxLength')),
28
+ image: z
29
+ .union([z.string().url(t('profile.validation.imageInvalidUrl')), z.literal('').transform(() => null), z.null()])
30
+ .optional()
31
+ })
29
32
 
30
33
  type Schema = z.output<typeof schema>
31
34
 
32
35
  // Form state
33
36
  const form = reactive<Partial<Schema>>({
34
37
  name: '',
38
+ firstName: '',
39
+ lastName: '',
35
40
  image: ''
36
41
  })
37
42
 
@@ -43,6 +48,8 @@ const selectedFile = ref<File | null>(null)
43
48
  watchEffect(() => {
44
49
  if (currentUser.value) {
45
50
  form.name = currentUser.value.name || ''
51
+ form.firstName = currentUser.value.firstName || ''
52
+ form.lastName = currentUser.value.lastName || ''
46
53
  form.image = currentUser.value.image || ''
47
54
  isDirty.value = false
48
55
  }
@@ -50,10 +57,13 @@ watchEffect(() => {
50
57
 
51
58
  // Watch for form changes
52
59
  watch(
53
- () => [form.name, form.image],
60
+ () => [form.name, form.firstName, form.lastName, form.image],
54
61
  () => {
55
62
  if (currentUser.value) {
56
- const nameChanged = form.name !== (currentUser.value.name || '')
63
+ const nameChanged =
64
+ form.name !== (currentUser.value.name || '') ||
65
+ form.firstName !== (currentUser.value.firstName || '') ||
66
+ form.lastName !== (currentUser.value.lastName || '')
57
67
  const imageChanged = form.image !== (currentUser.value.image || '')
58
68
  isDirty.value = nameChanged || imageChanged
59
69
  }
@@ -92,13 +102,17 @@ const handleSubmit = async (event: FormSubmitEvent<Schema>) => {
92
102
  user?: {
93
103
  id: string
94
104
  name: string
105
+ firstName: string
106
+ lastName: string
95
107
  email: string
96
108
  image: string | null
97
109
  }
98
110
  }>('/api/profile', {
99
111
  method: 'PATCH',
100
112
  body: {
101
- name: event.data.name?.trim(),
113
+ name: event.data.name,
114
+ firstName: event.data.firstName,
115
+ lastName: event.data.lastName,
102
116
  image: imageUrl
103
117
  }
104
118
  })
@@ -108,9 +122,12 @@ const handleSubmit = async (event: FormSubmitEvent<Schema>) => {
108
122
  setCurrentUser({
109
123
  ...currentUser.value,
110
124
  name: response.user.name,
125
+ firstName: response.user.firstName,
126
+ lastName: response.user.lastName,
111
127
  image: response.user.image || undefined
112
128
  })
113
129
 
130
+ await userStore.refreshOrganizations()
114
131
  toast.add({
115
132
  title: t('profile.messages.success'),
116
133
  description: t('profile.messages.profileUpdated'),
@@ -149,6 +166,8 @@ const handleReset = () => {
149
166
  URL.revokeObjectURL(form.image)
150
167
  }
151
168
  form.name = currentUser.value.name || ''
169
+ form.firstName = currentUser.value.firstName || ''
170
+ form.lastName = currentUser.value.lastName || ''
152
171
  form.image = currentUser.value.image || ''
153
172
  isDirty.value = false
154
173
  selectedFile.value = null
@@ -192,8 +211,17 @@ onUnmounted(() => {
192
211
  </script>
193
212
 
194
213
  <template>
214
+ <UButton
215
+ v-if="clientConfiguration.personalSelfRegistration"
216
+ to="/personal-onboarding"
217
+ class="mb-4"
218
+ variant="outline"
219
+ >{{ t('timezones.openPersonalAccount') }}</UButton
220
+ >
221
+ <TimezonePreferences />
222
+ <component :is="resolveComponent('ClientsPersonalAddress')" v-if="useRuntimeConfig().public.clients" />
195
223
  <AppCard class="mb-8" :title="$t('profile.sections.profileInfo')">
196
- <UForm :state="form" :schema="schema" class="space-y-6" @submit="handleSubmit">
224
+ <UForm novalidate :state="form" :schema="schema" class="space-y-6" @submit="handleSubmit">
197
225
  <!-- Profile Picture Section -->
198
226
  <UFormField
199
227
  :label="$t('profile.fields.profilePicture')"
@@ -215,19 +243,18 @@ onUnmounted(() => {
215
243
  </UFormField>
216
244
 
217
245
  <UFormField
218
- :label="$t('profile.fields.name')"
219
- :description="$t('profile.fields.nameDescription')"
246
+ :label="$t('profile.fields.displayName')"
247
+ :description="$t('profile.fields.displayNameDescription')"
220
248
  name="name"
221
249
  required
222
250
  >
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
- />
251
+ <UInput v-model="form.name" autocomplete="nickname" icon="i-lucide-user" class="w-full" />
252
+ </UFormField>
253
+ <UFormField :label="$t('profile.fields.firstName')" name="firstName" required>
254
+ <UInput v-model="form.firstName" autocomplete="given-name" icon="i-lucide-user" class="w-full" />
255
+ </UFormField>
256
+ <UFormField :label="$t('profile.fields.lastName')" name="lastName" required>
257
+ <UInput v-model="form.lastName" autocomplete="family-name" icon="i-lucide-user" class="w-full" />
231
258
  </UFormField>
232
259
 
233
260
  <UFormField :label="$t('profile.fields.email')" :description="$t('profile.fields.emailDescription')" name="email">
@@ -239,7 +266,13 @@ onUnmounted(() => {
239
266
  <UButton type="button" variant="ghost" color="neutral" :disabled="!isDirty || isLoading" @click="handleReset">
240
267
  {{ $t('common.reset') }}
241
268
  </UButton>
242
- <UButton type="submit" :loading="isLoading" :disabled="!isDirty">
269
+ <UButton
270
+ type="submit"
271
+ icon="i-lucide-save"
272
+ class="ml-auto flex min-w-28 justify-center"
273
+ :loading="isLoading"
274
+ :disabled="!isDirty"
275
+ >
243
276
  {{ $t('common.save') }}
244
277
  </UButton>
245
278
  </div>
@@ -9,6 +9,7 @@ import type {
9
9
  import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
10
10
  import { canViewOrganizationDirectory as canViewDirectory } from '@nuxt-customer-portal/core/shared/feature-registry'
11
11
 
12
+ const runtimeConfig = useRuntimeConfig()
12
13
  const userStore = useUserStore()
13
14
  const { activeOrganizationId } = storeToRefs(userStore)
14
15
  const { hasPermission } = userStore
@@ -26,7 +27,10 @@ const members = ref<OrganizationMemberWithUser[]>([])
26
27
  const invitations = ref<OrganizationInvitationsResponse>([])
27
28
  const showEditModal = ref(false)
28
29
  const userOrganizationRole = ref<MemberRole | null>(null)
29
- const canViewOrganizationDirectory = computed(() => canViewDirectory(userOrganizationRole.value))
30
+ const personalAccount = ref(false)
31
+ const canViewOrganizationDirectory = computed(
32
+ () => !personalAccount.value && canViewDirectory(userOrganizationRole.value)
33
+ )
30
34
 
31
35
  // Load organization details
32
36
  const loadOrganization = async () => {
@@ -49,6 +53,13 @@ const loadOrganization = async () => {
49
53
  throw roleError
50
54
  }
51
55
  userOrganizationRole.value = (roleData?.role as MemberRole | undefined) ?? null
56
+ personalAccount.value = false
57
+ if (runtimeConfig.public.clients) {
58
+ const accounts = await $fetch<{ organizationId: string; clientType: string }[]>('/api/client-account')
59
+ personalAccount.value = accounts.some(
60
+ (account) => account.organizationId === activeOrganizationId.value && account.clientType === 'person'
61
+ )
62
+ }
52
63
  organization.value = await $fetch<Organization>(`/api/organizations/${activeOrganizationId.value}`)
53
64
  if (organization.value && canViewOrganizationDirectory.value) {
54
65
  await loadMembers()
@@ -138,7 +149,13 @@ watch(
138
149
 
139
150
  <!-- Organization Details -->
140
151
  <div v-else-if="organization && hasPermission('organization', 'read')" class="space-y-6">
152
+ <TimezonePreferences
153
+ v-if="organization.organizationType === 'PROVIDER' && ['owner', 'admin'].includes(userOrganizationRole || '')"
154
+ provider
155
+ />
156
+ <component :is="resolveComponent('ClientsPersonalProfile')" v-if="personalAccount" :client-id="organization.id" />
141
157
  <OrganizationDetailsCard
158
+ v-else
142
159
  :organization="organization"
143
160
  :role="userOrganizationRole"
144
161
  :can-edit="userOrganizationRole === 'owner'"
@@ -20,7 +20,7 @@ const links = [
20
20
  exact: true
21
21
  },
22
22
  {
23
- label: t('settings.organization'),
23
+ label: t('timezones.accountSettings'),
24
24
  icon: 'i-lucide-users',
25
25
  to: '/settings/organization'
26
26
  },
@@ -183,7 +183,11 @@
183
183
  "namePlaceholder": "Enter your name",
184
184
  "profilePicture": "Profile Picture",
185
185
  "profilePictureDescription": "JPG, GIF or PNG. 1MB Max.",
186
- "chooseAvatar": "Choose"
186
+ "chooseAvatar": "Choose",
187
+ "firstName": "First name",
188
+ "lastName": "Last name",
189
+ "displayName": "Display name",
190
+ "displayNameDescription": "The name shown in the portal. This can differ from your first and last name."
187
191
  },
188
192
  "messages": {
189
193
  "error": "Error",
@@ -202,7 +206,10 @@
202
206
  "atLeastOneField": "At least one field (name or image) must be provided",
203
207
  "imageInvalidUrl": "Image must be a valid URL",
204
208
  "nameMaxLength": "Name must be less than 255 characters",
205
- "nameRequired": "Name is required"
209
+ "nameRequired": "Name is required",
210
+ "firstNameRequired": "First name is required",
211
+ "lastNameRequired": "Last name is required",
212
+ "namePartMaxLength": "Use at most 80 characters"
206
213
  }
207
214
  }
208
215
  }
@@ -183,7 +183,11 @@
183
183
  "namePlaceholder": "Voer je naam in",
184
184
  "profilePicture": "Profielfoto",
185
185
  "profilePictureDescription": "JPG, GIF of PNG. Max. 1MB.",
186
- "chooseAvatar": "Kiezen"
186
+ "chooseAvatar": "Kiezen",
187
+ "firstName": "Voornaam",
188
+ "lastName": "Achternaam",
189
+ "displayName": "Weergavenaam",
190
+ "displayNameDescription": "De naam die in het portaal wordt getoond. Deze mag afwijken van je voor- en achternaam."
187
191
  },
188
192
  "messages": {
189
193
  "error": "Fout",
@@ -202,7 +206,10 @@
202
206
  "atLeastOneField": "Ten minste één veld (naam of afbeelding) moet worden ingevuld",
203
207
  "imageInvalidUrl": "Afbeelding moet een geldige URL zijn",
204
208
  "nameMaxLength": "Naam moet minder dan 255 tekens zijn",
205
- "nameRequired": "Naam is verplicht"
209
+ "nameRequired": "Naam is verplicht",
210
+ "firstNameRequired": "Voornaam is verplicht",
211
+ "lastNameRequired": "Achternaam is verplicht",
212
+ "namePartMaxLength": "Gebruik maximaal 80 tekens"
206
213
  }
207
214
  }
208
215
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/organizations",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -39,8 +39,8 @@
39
39
  "pg": "^8.22.0",
40
40
  "zod": "^4.4.3",
41
41
  "@nuxt/ui": "^4.10.0",
42
- "@nuxt-customer-portal/core": "^0.3.3",
43
- "@nuxt-customer-portal/ui": "^0.3.3"
42
+ "@nuxt-customer-portal/core": "^0.4.0",
43
+ "@nuxt-customer-portal/ui": "^0.4.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "nuxt": "^4.5.1"
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'organizations',
3
- version: '0.3.3',
3
+ version: '0.4.0',
4
4
  source: '@nuxt-customer-portal/organizations',
5
5
  dependsOn: ['core', 'ui']
6
6
  }
@@ -1,3 +1,7 @@
1
+ import {
2
+ assertClientInvitationAcceptance,
3
+ isPersonalClient
4
+ } from '@nuxt-customer-portal/core/server/utils/client-account-policy'
1
5
  import { defineEventHandler, createError, readBody } from 'h3'
2
6
  import { z } from 'zod'
3
7
  import { auth, generateId } from '@nuxt-customer-portal/core/server/utils/auth'
@@ -32,6 +36,9 @@ export default defineEventHandler(async (event) => {
32
36
  }
33
37
 
34
38
  const user = session.user as SessionUser
39
+ if (!user.emailVerified) {
40
+ throw createError({ statusCode: 403, message: 'Verify your email before accepting an invitation' })
41
+ }
35
42
  const parsed = z.object({ invitationId: z.string().min(1) }).safeParse(await readBody(event))
36
43
  if (!parsed.success) {
37
44
  throw createError({ statusCode: 400, message: 'Invitation ID is required' })
@@ -72,17 +79,6 @@ export default defineEventHandler(async (event) => {
72
79
  })
73
80
  }
74
81
 
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
82
  // Get organization details
87
83
  const [organization] = await tx
88
84
  .select()
@@ -94,6 +90,28 @@ export default defineEventHandler(async (event) => {
94
90
  throw createError({ statusCode: 404, message: 'Organization not found' })
95
91
  }
96
92
 
93
+ // Reconcile an interrupted Better Auth acceptance where membership was
94
+ // created but the invitation was left pending.
95
+ const [existingMember] = await tx
96
+ .select()
97
+ .from(memberTable)
98
+ .where(and(eq(memberTable.organizationId, invitation.organizationId), eq(memberTable.userId, user.id)))
99
+ .limit(1)
100
+
101
+ if (existingMember) {
102
+ await tx.update(invitationTable).set({ status: 'accepted' }).where(eq(invitationTable.id, invitationId))
103
+ return {
104
+ success: true,
105
+ member: existingMember,
106
+ organization: { id: organization.id, name: organization.name }
107
+ }
108
+ }
109
+
110
+ await assertClientInvitationAcceptance(invitation.organizationId, user.id)
111
+ if (await isPersonalClient(invitation.organizationId)) {
112
+ invitation.role = 'owner'
113
+ }
114
+
97
115
  // Create the member record
98
116
  const memberId = generateId()
99
117
  await tx.insert(memberTable).values({
@@ -125,6 +143,10 @@ export default defineEventHandler(async (event) => {
125
143
  if (err instanceof Error && 'statusCode' in err) {
126
144
  throw err
127
145
  }
146
+ const cause = err as { code?: string; cause?: { code?: string } }
147
+ if (cause.code === '23505' || cause.cause?.code === '23505') {
148
+ throw createError({ statusCode: 409, message: 'Personal account already exists. Contact your coach.' })
149
+ }
128
150
  const errorMessage = err instanceof Error ? err.message : 'Failed to accept invitation'
129
151
  throw createError({ statusCode: 500, message: errorMessage })
130
152
  }
@@ -0,0 +1,161 @@
1
+ import { and, eq } from 'drizzle-orm'
2
+ import { createError, defineEventHandler, readBody } from 'h3'
3
+ import { z } from 'zod'
4
+ import { auth, generateId } from '@nuxt-customer-portal/core/server/utils/auth'
5
+ import { db } from '@nuxt-customer-portal/core/server/utils/db'
6
+ import {
7
+ assertClientInvitationAcceptance,
8
+ getClientAccountIdentity,
9
+ isPersonalClient
10
+ } from '@nuxt-customer-portal/core/server/utils/client-account-policy'
11
+ import {
12
+ invitation as invitationTable,
13
+ member as memberTable,
14
+ organization as organizationTable,
15
+ user as userTable
16
+ } from '@nuxt-customer-portal/core/server/db/schema/auth-schema'
17
+
18
+ const bodySchema = z.object({
19
+ invitationId: z.string().min(1),
20
+ email: z.email(),
21
+ password: z.string().min(8)
22
+ })
23
+
24
+ export default defineEventHandler(async (event) => {
25
+ const parsed = bodySchema.safeParse(await readBody(event))
26
+ if (!parsed.success) {
27
+ throw createError({ statusCode: 400, message: 'Invalid signup details' })
28
+ }
29
+
30
+ const input = parsed.data
31
+ const email = input.email.trim().toLowerCase()
32
+ const [invitation] = await db
33
+ .select()
34
+ .from(invitationTable)
35
+ .where(eq(invitationTable.id, input.invitationId))
36
+ .limit(1)
37
+
38
+ if (!invitation) {
39
+ throw createError({ statusCode: 400, message: 'Invitation is no longer valid' })
40
+ }
41
+ if (invitation.email.trim().toLowerCase() !== email) {
42
+ throw createError({ statusCode: 400, message: 'This invitation was sent to a different email address' })
43
+ }
44
+ if (invitation.status === 'accepted') {
45
+ const [acceptedAccount] = await db
46
+ .select({ userId: userTable.id, organizationId: organizationTable.id, organizationName: organizationTable.name })
47
+ .from(memberTable)
48
+ .innerJoin(userTable, eq(userTable.id, memberTable.userId))
49
+ .innerJoin(organizationTable, eq(organizationTable.id, memberTable.organizationId))
50
+ .where(
51
+ and(
52
+ eq(memberTable.organizationId, invitation.organizationId),
53
+ eq(userTable.email, email),
54
+ eq(userTable.emailVerified, true)
55
+ )
56
+ )
57
+ .limit(1)
58
+ if (acceptedAccount) {
59
+ return {
60
+ success: true,
61
+ organization: { id: acceptedAccount.organizationId, name: acceptedAccount.organizationName }
62
+ }
63
+ }
64
+ throw createError({ statusCode: 400, message: 'Invitation is no longer valid' })
65
+ }
66
+ if (invitation.status !== 'pending') {
67
+ throw createError({ statusCode: 400, message: 'Invitation is no longer valid' })
68
+ }
69
+ if (new Date(invitation.expiresAt) < new Date()) {
70
+ throw createError({ statusCode: 400, message: 'Invitation has expired' })
71
+ }
72
+ const [existingUser] = await db
73
+ .select({ id: userTable.id })
74
+ .from(userTable)
75
+ .where(eq(userTable.email, email))
76
+ .limit(1)
77
+ if (existingUser) {
78
+ throw createError({
79
+ statusCode: 409,
80
+ message: 'An account already exists for this email. Sign in to accept the invitation.'
81
+ })
82
+ }
83
+
84
+ const identity = await getClientAccountIdentity(invitation.organizationId)
85
+ const signup = await auth.api.signUpEmail({
86
+ body: {
87
+ name: identity?.name || email.split('@')[0] || email,
88
+ ...(identity?.firstName ? { firstName: identity.firstName } : {}),
89
+ ...(identity?.lastName ? { lastName: identity.lastName } : {}),
90
+ email,
91
+ password: input.password
92
+ }
93
+ })
94
+
95
+ const createdUserId = signup.user.id
96
+ let organization: { id: string; name: string }
97
+
98
+ try {
99
+ organization = await db.transaction(async (tx) => {
100
+ const [lockedInvitation] = await tx
101
+ .select()
102
+ .from(invitationTable)
103
+ .where(eq(invitationTable.id, input.invitationId))
104
+ .limit(1)
105
+ .for('update')
106
+
107
+ if (!lockedInvitation || lockedInvitation.status !== 'pending') {
108
+ throw createError({ statusCode: 400, message: 'Invitation is no longer valid' })
109
+ }
110
+ if (new Date(lockedInvitation.expiresAt) < new Date()) {
111
+ throw createError({ statusCode: 400, message: 'Invitation has expired' })
112
+ }
113
+ if (lockedInvitation.email.trim().toLowerCase() !== email) {
114
+ throw createError({ statusCode: 400, message: 'This invitation was sent to a different email address' })
115
+ }
116
+
117
+ const [targetOrganization] = await tx
118
+ .select({ id: organizationTable.id, name: organizationTable.name })
119
+ .from(organizationTable)
120
+ .where(eq(organizationTable.id, lockedInvitation.organizationId))
121
+ .limit(1)
122
+ if (!targetOrganization) {
123
+ throw createError({ statusCode: 404, message: 'Organization not found' })
124
+ }
125
+
126
+ await assertClientInvitationAcceptance(lockedInvitation.organizationId, createdUserId)
127
+ const role = (await isPersonalClient(lockedInvitation.organizationId))
128
+ ? 'owner'
129
+ : lockedInvitation.role || 'member'
130
+ const [existingMember] = await tx
131
+ .select({ id: memberTable.id })
132
+ .from(memberTable)
133
+ .where(
134
+ and(eq(memberTable.organizationId, lockedInvitation.organizationId), eq(memberTable.userId, createdUserId))
135
+ )
136
+ .limit(1)
137
+
138
+ if (!existingMember) {
139
+ await tx.insert(memberTable).values({
140
+ id: generateId(),
141
+ organizationId: lockedInvitation.organizationId,
142
+ userId: createdUserId,
143
+ role,
144
+ createdAt: new Date()
145
+ })
146
+ }
147
+
148
+ await tx
149
+ .update(userTable)
150
+ .set({ emailVerified: true, updatedAt: new Date() })
151
+ .where(eq(userTable.id, createdUserId))
152
+ await tx.update(invitationTable).set({ status: 'accepted' }).where(eq(invitationTable.id, lockedInvitation.id))
153
+ return targetOrganization
154
+ })
155
+ } catch (error) {
156
+ await db.delete(userTable).where(eq(userTable.id, createdUserId))
157
+ throw error
158
+ }
159
+
160
+ return { success: true, organization }
161
+ })
@@ -1,5 +1,10 @@
1
+ import {
2
+ runUserDisplayNameChangedHooks,
3
+ runUserIdentityChangedHooks
4
+ } from '@nuxt-customer-portal/core/server/utils/business-hooks'
1
5
  import { defineEventHandler, createError, readBody } from 'h3'
2
6
  import { z } from 'zod'
7
+ import { timezoneSchema } from '@nuxt-customer-portal/core/server/utils/timezone-validation'
3
8
  import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
4
9
  import { db } from '@nuxt-customer-portal/core/server/utils/db'
5
10
  import { user as userTable } from '@nuxt-customer-portal/core/server/db/schema/auth-schema'
@@ -19,6 +24,9 @@ defineRouteMeta({
19
24
  // Zod schema for profile update request
20
25
  const updateProfileSchema = z
21
26
  .object({
27
+ firstName: z.string().trim().min(1).max(80).optional(),
28
+ lastName: z.string().trim().min(1).max(80).optional(),
29
+ timezone: timezoneSchema.nullable().optional(),
22
30
  name: z
23
31
  .string()
24
32
  .trim()
@@ -29,9 +37,19 @@ const updateProfileSchema = z
29
37
  .union([z.string().url('Image must be a valid URL'), z.literal('').transform(() => null), z.null()])
30
38
  .optional()
31
39
  })
32
- .refine((data) => data.name !== undefined || data.image !== undefined, {
33
- message: 'At least one field (name or image) must be provided'
40
+ .refine((data) => (data.firstName === undefined) === (data.lastName === undefined), {
41
+ message: 'Provide both first and last name'
34
42
  })
43
+ .refine(
44
+ (data) =>
45
+ data.firstName !== undefined ||
46
+ data.name !== undefined ||
47
+ data.image !== undefined ||
48
+ data.timezone !== undefined,
49
+ {
50
+ message: 'At least one field (name or image) must be provided'
51
+ }
52
+ )
35
53
 
36
54
  interface UpdateProfileResponse {
37
55
  success: boolean
@@ -39,8 +57,11 @@ interface UpdateProfileResponse {
39
57
  user?: {
40
58
  id: string
41
59
  name: string
60
+ firstName: string | null
61
+ lastName: string | null
42
62
  email: string
43
63
  image: string | null
64
+ timezone: string | null
44
65
  }
45
66
  }
46
67
 
@@ -66,20 +87,48 @@ export default defineEventHandler(async (event): Promise<UpdateProfileResponse>
66
87
  const validatedData = validationResult.data
67
88
 
68
89
  // Build update object
69
- const updateData: { name?: string; image?: string | null } = {}
90
+ const updateData: {
91
+ name?: string
92
+ firstName?: string | null
93
+ lastName?: string | null
94
+ image?: string | null
95
+ timezone?: string | null
96
+ } = {}
97
+ if (validatedData.timezone !== undefined) {
98
+ updateData.timezone = validatedData.timezone
99
+ }
70
100
  if (validatedData.name !== undefined) {
71
101
  updateData.name = validatedData.name.trim()
72
102
  }
103
+ if (validatedData.firstName !== undefined && validatedData.lastName !== undefined) {
104
+ updateData.firstName = validatedData.firstName
105
+ updateData.lastName = validatedData.lastName
106
+ }
73
107
  if (validatedData.image !== undefined) {
74
108
  updateData.image = validatedData.image
75
109
  }
76
110
 
77
- // Update user in database
78
- const [updatedUser] = await db.update(userTable).set(updateData).where(eq(userTable.id, user.id)).returning({
79
- id: userTable.id,
80
- name: userTable.name,
81
- email: userTable.email,
82
- image: userTable.image
111
+ // Save the profile and linked private client names atomically.
112
+ const updatedUser = await db.transaction(async (tx) => {
113
+ const [updated] = await tx.update(userTable).set(updateData).where(eq(userTable.id, user.id)).returning({
114
+ id: userTable.id,
115
+ name: userTable.name,
116
+ firstName: userTable.firstName,
117
+ lastName: userTable.lastName,
118
+ email: userTable.email,
119
+ image: userTable.image,
120
+ timezone: userTable.timezone
121
+ })
122
+ if (updated && updateData.name !== undefined) {
123
+ await runUserDisplayNameChangedHooks(tx, user.id, updated.name)
124
+ }
125
+ if (updated && updateData.firstName !== undefined && updateData.lastName !== undefined) {
126
+ await runUserIdentityChangedHooks(tx, user.id, {
127
+ firstName: updated.firstName,
128
+ lastName: updated.lastName
129
+ })
130
+ }
131
+ return updated
83
132
  })
84
133
 
85
134
  if (!updatedUser) {
@@ -92,8 +141,11 @@ export default defineEventHandler(async (event): Promise<UpdateProfileResponse>
92
141
  user: {
93
142
  id: updatedUser.id,
94
143
  name: updatedUser.name,
144
+ firstName: updatedUser.firstName,
145
+ lastName: updatedUser.lastName,
95
146
  email: updatedUser.email,
96
- image: updatedUser.image
147
+ image: updatedUser.image,
148
+ timezone: updatedUser.timezone
97
149
  }
98
150
  }
99
151
  })
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod'
2
+ import { requireActiveOrganizationRole, db } from '@nuxt-customer-portal/core/server/portal'
3
+ import { organizationSettings } from '@nuxt-customer-portal/core/schema'
4
+ import { timezoneSchema } from '@nuxt-customer-portal/core/server/utils/timezone-validation'
5
+
6
+ export default defineEventHandler(async (event) => {
7
+ const context = await requireActiveOrganizationRole(event)
8
+ if (context.organizationType !== 'PROVIDER' || !['owner', 'admin'].includes(context.role ?? '')) {
9
+ throw createError({ statusCode: 403 })
10
+ }
11
+ const parsed = z.object({ timezone: timezoneSchema }).safeParse(await readBody(event))
12
+ if (!parsed.success) {
13
+ throw createError({ statusCode: 400, message: 'A valid IANA timezone is required' })
14
+ }
15
+ const input = parsed.data
16
+ await db
17
+ .insert(organizationSettings)
18
+ .values({ organizationId: context.organizationId, ...input })
19
+ .onConflictDoUpdate({ target: organizationSettings.organizationId, set: input })
20
+ return input
21
+ })