@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 +33 -0
- package/app/components/DeleteAccountModal.vue +6 -1
- package/app/components/OrganizationMembers.vue +1 -1
- package/app/components/OrganizationSettings.vue +2 -0
- package/app/components/OrganizationSwitcher.vue +17 -4
- package/app/components/TimezonePreferences.vue +72 -0
- package/app/components/organization/InviteMemberModal.vue +6 -1
- package/app/composables/useTimezonePreferences.ts +12 -0
- package/app/pages/settings/index.vue +62 -29
- package/app/pages/settings/organization.vue +18 -1
- package/app/pages/settings.vue +1 -1
- package/i18n/locales/en.json +9 -2
- package/i18n/locales/nl.json +9 -2
- package/package.json +3 -3
- package/portal.manifest.mjs +1 -1
- package/server/api/organizations/accept-invitation.post.ts +33 -11
- package/server/api/organizations/invitation-signup.post.ts +161 -0
- package/server/api/profile.patch.ts +62 -10
- package/server/api/provider-timezone.patch.ts +21 -0
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
|
|
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-
|
|
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="
|
|
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="
|
|
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="
|
|
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
|
|
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
|
-
.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 =
|
|
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
|
|
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.
|
|
219
|
-
:description="$t('profile.fields.
|
|
246
|
+
:label="$t('profile.fields.displayName')"
|
|
247
|
+
:description="$t('profile.fields.displayNameDescription')"
|
|
220
248
|
name="name"
|
|
221
249
|
required
|
|
222
250
|
>
|
|
223
|
-
<UInput
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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
|
|
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'"
|
package/app/pages/settings.vue
CHANGED
package/i18n/locales/en.json
CHANGED
|
@@ -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
|
}
|
package/i18n/locales/nl.json
CHANGED
|
@@ -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
|
+
"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.
|
|
43
|
-
"@nuxt-customer-portal/ui": "^0.
|
|
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"
|
package/portal.manifest.mjs
CHANGED
|
@@ -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.
|
|
33
|
-
message: '
|
|
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: {
|
|
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
|
-
//
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
})
|