@nuxt-customer-portal/clients 0.3.2 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +4 -0
  3. package/app/components/ClientsClientDetail.vue +38 -16
  4. package/app/components/ClientsClientForm.vue +134 -21
  5. package/app/components/ClientsClientPicker.vue +7 -1
  6. package/app/components/ClientsPersonalAddress.vue +58 -0
  7. package/app/components/ClientsPersonalProfile.vue +46 -0
  8. package/app/components/ClientsPrivateInvitation.vue +110 -0
  9. package/app/composables/useClients.ts +21 -0
  10. package/app/middleware/personal-onboarding.global.ts +14 -0
  11. package/app/pages/clients/[id].vue +7 -1
  12. package/app/pages/clients/index.vue +91 -8
  13. package/app/pages/personal-onboarding.vue +79 -0
  14. package/app/plugins/clients-feature.ts +10 -2
  15. package/i18n/locales/en.json +28 -2
  16. package/i18n/locales/nl.json +28 -2
  17. package/migrations/0001_person_clients.sql +37 -0
  18. package/migrations/0002_person_names.sql +18 -0
  19. package/nuxt.config.ts +7 -1
  20. package/package.json +3 -3
  21. package/portal.manifest.mjs +1 -1
  22. package/server/api/admin/private-client-invitations/index.get.ts +27 -0
  23. package/server/api/admin/private-client-invitations/index.post.ts +137 -0
  24. package/server/api/client-account.get.ts +12 -0
  25. package/server/api/clients/[id]/index.patch.ts +5 -1
  26. package/server/api/clients/[id]/invitations/[invitationId]/resend.post.ts +78 -0
  27. package/server/api/clients/[id]/invitations/[invitationId].patch.ts +2 -2
  28. package/server/api/clients/[id]/invitations/index.post.ts +35 -12
  29. package/server/api/clients/[id]/members/[memberId].delete.ts +2 -2
  30. package/server/api/clients/[id]/members/[memberId].patch.ts +6 -2
  31. package/server/api/clients/index.post.ts +5 -1
  32. package/server/api/personal-client/address.get.ts +15 -0
  33. package/server/api/personal-client/address.patch.ts +32 -0
  34. package/server/api/personal-client.get.ts +43 -0
  35. package/server/api/personal-client.post.ts +77 -0
  36. package/server/db/schema/clients.ts +29 -3
  37. package/server/middleware/00-client-configuration.ts +5 -0
  38. package/server/plugins/client-accounts.ts +114 -0
  39. package/server/utils/client-access.ts +9 -0
  40. package/server/utils/client-configuration.ts +37 -0
  41. package/server/utils/client-repository.ts +132 -63
  42. package/server/utils/client-validation.ts +33 -5
  43. package/server/utils/purchase-client.ts +90 -0
  44. package/shared/feature.ts +16 -0
  45. package/shared/personal-onboarding.ts +6 -0
  46. package/shared/private-client-invitation.ts +11 -0
  47. package/shared/types/client.ts +5 -0
@@ -0,0 +1,110 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+
4
+ const open = defineModel<boolean>('open', { default: false })
5
+ const emit = defineEmits<{ success: [] }>()
6
+ const { t, locale } = useI18n()
7
+ const toast = useToast()
8
+ const state = reactive({ clientId: '', name: '', email: '' })
9
+ const existing = ref(false)
10
+ const busy = ref(false)
11
+ const error = ref('')
12
+ const requestId = ref('')
13
+ const api = useClients()
14
+ const clients = ref<{ id: string; name: string; email: string | null }[]>([])
15
+ const schema = computed(() =>
16
+ z.object({
17
+ clientId: existing.value ? z.string().min(1, t('features.clients.choosePrivateClient')) : z.string(),
18
+ name: existing.value ? z.string() : z.string().trim().min(2, t('features.clients.validation.name')).max(160),
19
+ email: z.string().trim().email(t('features.clients.validation.email'))
20
+ })
21
+ )
22
+ watch(open, async (value) => {
23
+ if (!value) {
24
+ return
25
+ }
26
+ Object.assign(state, { clientId: '', name: '', email: '' })
27
+ existing.value = false
28
+ requestId.value = crypto.randomUUID()
29
+ error.value = ''
30
+ try {
31
+ clients.value = await api.invitablePrivateClients()
32
+ } catch {
33
+ error.value = t('features.clients.inviteFailed')
34
+ }
35
+ })
36
+ watch(
37
+ () => state.clientId,
38
+ (id) => {
39
+ state.email = clients.value.find((client) => client.id === id)?.email ?? ''
40
+ }
41
+ )
42
+ async function submit() {
43
+ busy.value = true
44
+ error.value = ''
45
+ try {
46
+ const result = await api.invitePrivateClient({
47
+ requestId: requestId.value,
48
+ preferredLocale: locale.value === 'nl' ? 'nl' : 'en',
49
+ email: state.email,
50
+ ...(existing.value ? { clientId: state.clientId } : { name: state.name })
51
+ })
52
+ open.value = false
53
+ emit('success')
54
+ await refreshNuxtData('admin-pending-invitations')
55
+ toast.add({
56
+ title: t(
57
+ result.deliveryFailed ? 'features.clients.invitationDeliveryFailed' : 'features.clients.invitationCreated'
58
+ ),
59
+ color: result.deliveryFailed ? 'warning' : 'success'
60
+ })
61
+ } catch (cause) {
62
+ error.value = (cause as { data?: { message?: string } }).data?.message || t('features.clients.inviteFailed')
63
+ } finally {
64
+ busy.value = false
65
+ }
66
+ }
67
+ </script>
68
+
69
+ <template>
70
+ <UModal
71
+ v-if="open"
72
+ v-model:open="open"
73
+ :title="t('features.clients.invitePrivateClient')"
74
+ :description="t('features.clients.privateInviteDescription')"
75
+ :dismissible="!busy"
76
+ >
77
+ <template #body>
78
+ <UForm :schema="schema" :state="state" novalidate class="space-y-4" @submit="submit">
79
+ <USwitch
80
+ v-if="clients.length"
81
+ v-model="existing"
82
+ :label="t('features.clients.useExistingPrivateClient')"
83
+ :disabled="busy"
84
+ />
85
+ <UFormField v-if="existing" name="clientId" :label="t('features.clients.choosePrivateClient')">
86
+ <USelectMenu
87
+ v-model="state.clientId"
88
+ :items="clients.map((client) => ({ label: client.name, value: client.id }))"
89
+ value-key="value"
90
+ class="w-full"
91
+ :disabled="busy"
92
+ />
93
+ </UFormField>
94
+ <UFormField v-else name="name" :label="t('features.clients.fullName')">
95
+ <UInput v-model="state.name" autocomplete="name" class="w-full" :disabled="busy" />
96
+ </UFormField>
97
+ <UFormField name="email" :label="t('features.clients.invoiceEmail')">
98
+ <UInput v-model="state.email" autocomplete="email" class="w-full" :disabled="busy" />
99
+ </UFormField>
100
+ <UAlert v-if="error" color="error" :title="error" />
101
+ <div class="flex justify-end gap-2">
102
+ <UButton color="neutral" variant="ghost" :disabled="busy" @click="open = false">{{
103
+ t('features.clients.cancel')
104
+ }}</UButton>
105
+ <UButton type="submit" icon="i-lucide-mail-plus" :loading="busy">{{ t('features.clients.invite') }}</UButton>
106
+ </div>
107
+ </UForm>
108
+ </template>
109
+ </UModal>
110
+ </template>
@@ -1,6 +1,27 @@
1
1
  import type { GenericClientDto, ClientListResponse } from '@nuxt-customer-portal/clients/shared/types/client'
2
2
 
3
3
  export const useClients = () => ({
4
+ invitablePrivateClients: () =>
5
+ $fetch<{ id: string; name: string; email: string | null }[]>('/api/admin/private-client-invitations'),
6
+ invitePrivateClient: (input: {
7
+ requestId: string
8
+ email: string
9
+ clientId?: string
10
+ name?: string
11
+ preferredLocale?: 'en' | 'nl'
12
+ }) =>
13
+ $fetch<{ invitationId: string; deliveryFailed: boolean }>('/api/admin/private-client-invitations', {
14
+ method: 'POST',
15
+ body: input
16
+ }),
17
+ personalAddress: () => $fetch<{ address: string; archived: boolean } | null>('/api/personal-client/address'),
18
+ updatePersonalAddress: (address: string) =>
19
+ $fetch<{ address: string; archived: boolean }>('/api/personal-client/address', {
20
+ method: 'PATCH',
21
+ body: { address }
22
+ }),
23
+ onboard: (input: { firstName: string; lastName: string; preferredLocale: 'nl' | 'en'; timezone: string | null }) =>
24
+ $fetch<GenericClientDto>('/api/personal-client', { method: 'POST', body: input }),
4
25
  list: (query: Record<string, string | number | undefined>) => $fetch<ClientListResponse>('/api/clients', { query }),
5
26
  get: (id: string) => $fetch<GenericClientDto>(`/api/clients/${id}`),
6
27
  create: (input: Record<string, unknown>) => $fetch<GenericClientDto>('/api/clients', { method: 'POST', body: input }),
@@ -0,0 +1,14 @@
1
+ export default defineNuxtRouteMiddleware(async (to) => {
2
+ if (
3
+ to.meta.public ||
4
+ to.path === '/personal-onboarding' ||
5
+ !useClientConfiguration().value.personalSelfRegistration
6
+ ) {
7
+ return
8
+ }
9
+ const request = useRequestFetch()
10
+ const status = await request<{ onboardingRequired: boolean }>('/api/personal-client')
11
+ if (status.onboardingRequired) {
12
+ return navigateTo('/personal-onboarding')
13
+ }
14
+ })
@@ -41,7 +41,13 @@ const handleDeleted = async () => {
41
41
  :title="t('features.clients.notFound')"
42
42
  variant="outline"
43
43
  />
44
- <ClientsClientDetail v-else-if="client" :client="client" :refresh="refresh" @deleted="handleDeleted" />
44
+ <ClientsClientDetail
45
+ v-else-if="client"
46
+ :client="client"
47
+ :refresh="refresh"
48
+ :start-editing="route.query.edit === 'true'"
49
+ @deleted="handleDeleted"
50
+ />
45
51
  </div>
46
52
  </div>
47
53
  </template>
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { clientsIcon } from '@nuxt-customer-portal/clients/shared/feature'
2
3
  import type { ClientListResponse, GenericClientDto } from '@nuxt-customer-portal/clients/shared/types/client'
3
4
 
4
5
  const { t } = useI18n()
@@ -7,7 +8,11 @@ const router = useRouter()
7
8
  const toast = useToast()
8
9
  const api = useClients()
9
10
  const runtimeConfig = useRuntimeConfig()
11
+ const clientConfiguration = useClientConfiguration()
10
12
  const defaultModules = (runtimeConfig.public.clients as { defaultModules?: string[] } | undefined)?.defaultModules ?? []
13
+ const clientType = ref(
14
+ ['organization', 'person'].includes(String(route.query.clientType)) ? String(route.query.clientType) : 'all'
15
+ )
11
16
  const search = ref(String(route.query.search ?? ''))
12
17
  const status = ref(route.query.status === 'archived' ? 'archived' : route.query.status === 'all' ? 'all' : 'active')
13
18
  const sortBy = ref(
@@ -35,6 +40,7 @@ const sortOptions = computed(() => [
35
40
  ])
36
41
 
37
42
  const routeQuery = () => ({
43
+ ...(clientType.value !== 'all' ? { clientType: clientType.value } : {}),
38
44
  ...(search.value.trim() ? { search: search.value.trim() } : {}),
39
45
  ...(status.value !== 'active' ? { status: status.value } : {}),
40
46
  ...(sortBy.value !== 'name' ? { sortBy: sortBy.value } : {}),
@@ -60,12 +66,19 @@ const clientDetailTo = (client: GenericClientDto) => ({
60
66
  query: { returnTo: listReturnPath.value }
61
67
  })
62
68
 
69
+ let loadVersion = 0
63
70
  const load = async () => {
71
+ const version = ++loadVersion
64
72
  pending.value = true
65
73
  try {
66
- result.value = await api.list({ ...requestQuery(), page: page.value, pageSize: 20 })
74
+ const response = await api.list({ ...requestQuery(), page: page.value, pageSize: 20 })
75
+ if (version === loadVersion) {
76
+ result.value = response
77
+ }
67
78
  } finally {
68
- pending.value = false
79
+ if (version === loadVersion) {
80
+ pending.value = false
81
+ }
69
82
  }
70
83
  }
71
84
 
@@ -94,14 +107,55 @@ const syncAndLoad = async (resetPage = false) => {
94
107
  await load()
95
108
  }
96
109
 
110
+ let hydratingRoute = false
97
111
  let searchTimer: ReturnType<typeof setTimeout> | undefined
112
+ watch(
113
+ () => route.query,
114
+ async (query) => {
115
+ const next = {
116
+ search: String(query.search ?? ''),
117
+ status: query.status === 'archived' ? 'archived' : query.status === 'all' ? 'all' : 'active',
118
+ clientType: ['organization', 'person'].includes(String(query.clientType)) ? String(query.clientType) : 'all',
119
+ sortBy: ['name', 'createdAt', 'status'].includes(String(query.sortBy)) ? String(query.sortBy) : 'name',
120
+ sortDir: query.sortDir === 'desc' ? ('desc' as const) : ('asc' as const),
121
+ page: Math.max(1, Number(query.page) || 1)
122
+ }
123
+ if (
124
+ next.search === search.value &&
125
+ next.status === status.value &&
126
+ next.clientType === clientType.value &&
127
+ next.sortBy === sortBy.value &&
128
+ next.sortDir === sortDir.value &&
129
+ next.page === page.value
130
+ ) {
131
+ return
132
+ }
133
+ clearTimeout(searchTimer)
134
+ hydratingRoute = true
135
+ search.value = next.search
136
+ status.value = next.status
137
+ clientType.value = next.clientType
138
+ sortBy.value = next.sortBy
139
+ sortDir.value = next.sortDir
140
+ page.value = next.page
141
+ await nextTick()
142
+ hydratingRoute = false
143
+ await load()
144
+ }
145
+ )
98
146
  watch(search, () => {
147
+ if (hydratingRoute) {
148
+ return
149
+ }
99
150
  clearTimeout(searchTimer)
100
151
  searchTimer = setTimeout(() => {
101
152
  void syncAndLoad(true)
102
153
  }, 300)
103
154
  })
104
- watch([status, sortBy, sortDir], () => {
155
+ watch([status, sortBy, sortDir, clientType], () => {
156
+ if (hydratingRoute) {
157
+ return
158
+ }
105
159
  void syncAndLoad(true)
106
160
  })
107
161
  watch(pending, (value) => {
@@ -152,7 +206,7 @@ onMounted(() => {
152
206
  <div class="mx-auto flex max-w-[1440px] flex-col gap-4 p-4 sm:p-6 lg:p-8">
153
207
  <header class="flex items-center justify-between gap-3 border-b border-default pb-4 sm:items-end">
154
208
  <div class="flex min-w-0 gap-3">
155
- <UIcon name="i-lucide-building-2" class="mt-1 size-6 shrink-0 text-primary" />
209
+ <UIcon :name="clientsIcon(clientConfiguration.allowedTypes)" class="mt-1 size-6 shrink-0 text-primary" />
156
210
  <div class="min-w-0">
157
211
  <h1 class="text-2xl font-semibold">{{ t('features.clients.title') }}</h1>
158
212
  <p class="hidden text-sm text-muted sm:block">{{ t('features.clients.description') }}</p>
@@ -191,12 +245,26 @@ onMounted(() => {
191
245
  <PortalListToolbar
192
246
  v-model:search="search"
193
247
  :search-placeholder="t('features.clients.search')"
194
- :filters="[{ key: 'status', placeholder: t('features.clients.status'), items: statusOptions }]"
195
- :filter-values="{ status }"
248
+ :filters="[
249
+ { key: 'status', placeholder: t('features.clients.status'), items: statusOptions },
250
+ ...((clientConfiguration.allowedTypes?.length ?? 1) > 1
251
+ ? [
252
+ {
253
+ key: 'clientType',
254
+ placeholder: t('features.clients.clientType'),
255
+ items: ['all', 'organization', 'person'].map((value) => ({
256
+ value,
257
+ label: t(value === 'all' ? 'features.clients.allTypes' : `features.clients.types.${value}`)
258
+ }))
259
+ }
260
+ ]
261
+ : [])
262
+ ]"
263
+ :filter-values="{ status, clientType }"
196
264
  :sort-options="sortOptions"
197
265
  :sort-by="sortBy"
198
266
  :sort-dir="sortDir"
199
- @filter="(_key, value) => (status = value || 'all')"
267
+ @filter="(key, value) => (key === 'clientType' ? (clientType = value || 'all') : (status = value || 'all'))"
200
268
  @sort="sortBy = $event"
201
269
  @toggle-direction="sortDir = sortDir === 'asc' ? 'desc' : 'asc'"
202
270
  />
@@ -228,12 +296,27 @@ onMounted(() => {
228
296
  <UAvatar :src="client.avatarLogo || client.logo || undefined" :alt="client.name" />
229
297
  <div class="min-w-0 flex-1">
230
298
  <div class="flex flex-wrap items-center gap-2">
299
+ <UTooltip :text="t(`features.clients.types.${client.clientType}`)">
300
+ <span
301
+ tabindex="0"
302
+ role="img"
303
+ :aria-label="t(`features.clients.types.${client.clientType}`)"
304
+ class="inline-flex shrink-0 rounded text-muted focus-visible:outline-2 focus-visible:outline-primary"
305
+ >
306
+ <UIcon
307
+ :name="client.clientType === 'person' ? 'i-lucide-user-round' : 'i-lucide-building-2'"
308
+ class="size-4"
309
+ />
310
+ </span>
311
+ </UTooltip>
231
312
  <p class="truncate font-semibold">{{ client.name }}</p>
232
313
  <UBadge :color="client.archivedAt ? 'neutral' : 'success'" variant="subtle">{{
233
314
  t(client.archivedAt ? 'features.clients.archived' : 'features.clients.active')
234
315
  }}</UBadge>
235
316
  </div>
236
- <p class="mt-1 truncate text-sm text-muted">{{ client.officialName }} · {{ client.slug }}</p>
317
+ <p v-if="client.clientType === 'organization'" class="mt-1 truncate text-sm text-muted">
318
+ {{ client.slug }}
319
+ </p>
237
320
  </div>
238
321
  </NuxtLink>
239
322
  <NuxtLink
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ import { z } from 'zod'
3
+ import { isValidTimezone } from '@nuxt-customer-portal/core/shared/timezone'
4
+ import { authClient } from '@nuxt-customer-portal/core/app/utils/auth-client'
5
+
6
+ const { t, locale } = useI18n()
7
+ const config = useClientConfiguration()
8
+ if (!config.value.personalSelfRegistration) {
9
+ throw createError({ statusCode: 404 })
10
+ }
11
+ const { data: session } = await authClient.getSession()
12
+ const state = reactive({
13
+ firstName: session?.user.firstName ?? '',
14
+ lastName: session?.user.lastName ?? '',
15
+ preferredLocale: (locale.value === 'nl' ? 'nl' : 'en') as 'nl' | 'en',
16
+ timezone: null as string | null
17
+ })
18
+ onMounted(() => {
19
+ state.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
20
+ })
21
+ const schema = computed(() =>
22
+ z.object({
23
+ firstName: z.string().trim().min(1, t('features.clients.validation.name')).max(80),
24
+ lastName: z.string().trim().min(1, t('features.clients.validation.name')).max(80),
25
+ preferredLocale: z.enum(['nl', 'en']),
26
+ timezone: z
27
+ .string()
28
+ .nullable()
29
+ .refine((value) => value === null || isValidTimezone(value), t('timezones.invalid'))
30
+ })
31
+ )
32
+ const busy = ref(false)
33
+ const error = ref('')
34
+ const api = useClients()
35
+ const userStore = useUserStore()
36
+ const submit = async () => {
37
+ busy.value = true
38
+ error.value = ''
39
+ try {
40
+ const client = await api.onboard(state)
41
+ await userStore.refreshOrganizations()
42
+ await userStore.setActiveOrganizationId(client.id)
43
+ await navigateTo('/dashboard')
44
+ } catch {
45
+ error.value = t('features.clients.onboardingFailed')
46
+ } finally {
47
+ busy.value = false
48
+ }
49
+ }
50
+ </script>
51
+
52
+ <template>
53
+ <div class="mx-auto max-w-xl p-6">
54
+ <h1 class="mb-4 text-2xl font-semibold">{{ t('features.clients.personalAccount') }}</h1>
55
+ <UForm :schema="schema" :state="state" novalidate class="space-y-4" @submit="submit">
56
+ <UFormField name="firstName" :label="t('features.clients.firstName')"
57
+ ><UInput v-model="state.firstName" autocomplete="given-name" class="w-full"
58
+ /></UFormField>
59
+ <UFormField name="lastName" :label="t('features.clients.lastName')"
60
+ ><UInput v-model="state.lastName" autocomplete="family-name" class="w-full"
61
+ /></UFormField>
62
+ <UFormField name="preferredLocale" :label="t('features.clients.locale')"
63
+ ><USelect
64
+ v-model="state.preferredLocale"
65
+ class="w-full"
66
+ :items="[
67
+ { label: 'Nederlands', value: 'nl' },
68
+ { label: 'English', value: 'en' }
69
+ ]"
70
+ value-key="value"
71
+ /></UFormField>
72
+ <UFormField name="timezone" :label="t('timezones.label')" :description="t('timezones.savedPreference')"
73
+ ><PortalTimezoneSelect v-model="state.timezone"
74
+ /></UFormField>
75
+ <UAlert v-if="error" color="error" :title="error" />
76
+ <UButton type="submit" :loading="busy">{{ t('features.clients.openPersonalAccount') }}</UButton>
77
+ </UForm>
78
+ </div>
79
+ </template>
@@ -1,3 +1,11 @@
1
- import { clientsFeature } from '@nuxt-customer-portal/clients/shared/feature'
1
+ import { createClientsFeature } from '@nuxt-customer-portal/clients/shared/feature'
2
2
 
3
- export default defineNuxtPlugin(() => usePortalFeatures().registerFeature(clientsFeature))
3
+ export default defineNuxtPlugin(() => {
4
+ const config = useClientConfiguration()
5
+ const features = usePortalFeatures()
6
+ watch(
7
+ () => config.value.allowedTypes,
8
+ (types) => features.registerFeature(createClientsFeature(types ?? ['organization'])),
9
+ { immediate: true }
10
+ )
11
+ })
@@ -2,7 +2,7 @@
2
2
  "features": {
3
3
  "clients": {
4
4
  "title": "Clients",
5
- "description": "Manage client companies, members, profiles, and module access.",
5
+ "description": "Manage clients, profiles, members, and module access.",
6
6
  "backToClients": "Back to clients",
7
7
  "openClient": "Open {name}",
8
8
  "loading": "Loading client",
@@ -65,10 +65,36 @@
65
65
  "resultCount": "{count} Client | {count} Clients",
66
66
  "validation": {
67
67
  "name": "Enter at least two characters.",
68
+ "firstName": "First name is required.",
69
+ "lastName": "Last name is required.",
68
70
  "slug": "Use lowercase letters, numbers, and single hyphens only.",
69
71
  "officialName": "Enter the official company name.",
70
72
  "email": "Enter a valid email address."
71
- }
73
+ },
74
+ "clientType": "Client type",
75
+ "types": {
76
+ "organization": "Organization",
77
+ "person": "Private person"
78
+ },
79
+ "fullName": "Full name",
80
+ "personalAccount": "Personal account",
81
+ "openPersonalAccount": "Open personal account",
82
+ "onboardingFailed": "Could not open your personal account. Verify your email or contact your coach if you already have an account.",
83
+ "editPersonalProfile": "Edit personal profile",
84
+ "allTypes": "All client types",
85
+ "firstName": "First name",
86
+ "lastName": "Last name",
87
+ "personalAddress": "Personal address",
88
+ "personalAddressDescription": "The address on your private client profile, used for billing.",
89
+ "addressTooLong": "Use at most 1,000 characters",
90
+ "addressSaved": "Address saved",
91
+ "addressLoadFailed": "Could not load your personal address",
92
+ "invitePrivateClient": "Invite private client",
93
+ "choosePrivateClient": "Choose a private client",
94
+ "useExistingPrivateClient": "Invite an existing private client",
95
+ "privateInviteDescription": "Invite someone to their private client account. They will create or sign in to their own login to accept.",
96
+ "invitationCreated": "Invitation created",
97
+ "invitationDeliveryFailed": "Invitation saved, but the email could not be sent. Revoke it from Pending invitations, then invite the existing private client again."
72
98
  }
73
99
  }
74
100
  }
@@ -2,7 +2,7 @@
2
2
  "features": {
3
3
  "clients": {
4
4
  "title": "Klanten",
5
- "description": "Beheer klantbedrijven, leden, profielen en moduletoegang.",
5
+ "description": "Beheer klanten, profielen, leden en moduletoegang.",
6
6
  "backToClients": "Terug naar klanten",
7
7
  "openClient": "Open {name}",
8
8
  "loading": "Klant laden",
@@ -65,10 +65,36 @@
65
65
  "resultCount": "{count} klant | {count} klanten",
66
66
  "validation": {
67
67
  "name": "Voer minimaal twee tekens in.",
68
+ "firstName": "Voornaam is verplicht.",
69
+ "lastName": "Achternaam is verplicht.",
68
70
  "slug": "Gebruik alleen kleine letters, cijfers en enkele koppeltekens.",
69
71
  "officialName": "Voer de officiële bedrijfsnaam in.",
70
72
  "email": "Voer een geldig e-mailadres in."
71
- }
73
+ },
74
+ "clientType": "Type klant",
75
+ "types": {
76
+ "organization": "Organisatie",
77
+ "person": "Privépersoon"
78
+ },
79
+ "fullName": "Volledige naam",
80
+ "personalAccount": "Persoonlijk account",
81
+ "openPersonalAccount": "Persoonlijk account openen",
82
+ "onboardingFailed": "Je persoonlijke account kon niet worden geopend. Verifieer je e-mail of neem contact op met je coach als je al een account hebt.",
83
+ "editPersonalProfile": "Persoonlijk profiel bewerken",
84
+ "allTypes": "Alle klanttypen",
85
+ "firstName": "Voornaam",
86
+ "lastName": "Achternaam",
87
+ "personalAddress": "Persoonlijk adres",
88
+ "personalAddressDescription": "Het adres op je particuliere klantprofiel, gebruikt voor facturatie.",
89
+ "addressTooLong": "Gebruik maximaal 1.000 tekens",
90
+ "addressSaved": "Adres opgeslagen",
91
+ "addressLoadFailed": "Je persoonlijke adres kon niet worden geladen",
92
+ "invitePrivateClient": "Particuliere klant uitnodigen",
93
+ "choosePrivateClient": "Kies een particuliere klant",
94
+ "useExistingPrivateClient": "Een bestaande particuliere klant uitnodigen",
95
+ "privateInviteDescription": "Nodig iemand uit voor hun particuliere klantaccount. De ontvanger maakt een login aan of logt in om te accepteren.",
96
+ "invitationCreated": "Uitnodiging aangemaakt",
97
+ "invitationDeliveryFailed": "Uitnodiging opgeslagen, maar de e-mail kon niet worden verstuurd. Trek deze in via Openstaande uitnodigingen en nodig daarna de bestaande privécliënt opnieuw uit."
72
98
  }
73
99
  }
74
100
  }
@@ -0,0 +1,37 @@
1
+ ALTER TABLE clients.client_profile ADD COLUMN client_type text NOT NULL DEFAULT 'organization'
2
+ CHECK (client_type IN ('organization', 'person'));
3
+ ALTER TABLE clients.client_profile ADD COLUMN timezone text;
4
+ ALTER TABLE clients.client_profile ADD CONSTRAINT person_no_company_fields
5
+ CHECK (client_type <> 'person' OR (registration_number IS NULL AND vat_number IS NULL));
6
+ -- Unique constraints serialize competing invitations and onboarding requests.
7
+ CREATE TABLE clients.personal_membership (
8
+ organization_id text PRIMARY KEY REFERENCES clients.client_profile(organization_id) ON DELETE CASCADE,
9
+ user_id text NOT NULL UNIQUE REFERENCES "user"(id) ON DELETE CASCADE,
10
+ member_id text NOT NULL UNIQUE REFERENCES member(id) ON DELETE CASCADE
11
+ );
12
+ CREATE FUNCTION clients.enforce_personal_membership() RETURNS trigger LANGUAGE plpgsql AS $$
13
+ BEGIN
14
+ IF TG_OP = 'UPDATE' AND (OLD.user_id <> NEW.user_id OR OLD.organization_id <> NEW.organization_id)
15
+ AND EXISTS (SELECT 1 FROM clients.personal_membership WHERE member_id = OLD.id) THEN
16
+ RAISE EXCEPTION 'Personal membership cannot be reassigned' USING ERRCODE = '23514';
17
+ END IF;
18
+ IF EXISTS (SELECT 1 FROM clients.client_profile WHERE organization_id = NEW.organization_id AND client_type = 'person') THEN
19
+ IF NEW.role <> 'owner' THEN RAISE EXCEPTION 'A personal client has one owner' USING ERRCODE = '23514'; END IF;
20
+ IF TG_OP = 'UPDATE' AND (OLD.user_id <> NEW.user_id OR OLD.organization_id <> NEW.organization_id) THEN
21
+ RAISE EXCEPTION 'Personal membership cannot be reassigned' USING ERRCODE = '23514';
22
+ END IF;
23
+ IF TG_OP = 'INSERT' OR NOT EXISTS (SELECT 1 FROM clients.personal_membership WHERE member_id = NEW.id) THEN
24
+ INSERT INTO clients.personal_membership VALUES (NEW.organization_id, NEW.user_id, NEW.id);
25
+ END IF;
26
+ END IF;
27
+ RETURN NEW;
28
+ END $$;
29
+ CREATE TRIGGER enforce_personal_membership AFTER INSERT OR UPDATE ON member
30
+ FOR EACH ROW EXECUTE FUNCTION clients.enforce_personal_membership();
31
+ CREATE FUNCTION clients.immutable_client_type() RETURNS trigger LANGUAGE plpgsql AS $$
32
+ BEGIN
33
+ IF OLD.client_type <> NEW.client_type THEN RAISE EXCEPTION 'Client type cannot be changed' USING ERRCODE = '23514'; END IF;
34
+ RETURN NEW;
35
+ END $$;
36
+ CREATE TRIGGER immutable_client_type BEFORE UPDATE ON clients.client_profile
37
+ FOR EACH ROW EXECUTE FUNCTION clients.immutable_client_type();
@@ -0,0 +1,18 @@
1
+ ALTER TABLE clients.client_profile ADD COLUMN first_name text;
2
+ ALTER TABLE clients.client_profile ADD COLUMN last_name text;
3
+ UPDATE clients.client_profile AS profile
4
+ SET
5
+ first_name = COALESCE(account.first_name, split_part(client.name, ' ', 1)),
6
+ last_name = COALESCE(
7
+ account.last_name,
8
+ NULLIF(trim(regexp_replace(client.name, '^\S+\s*', '')), ''),
9
+ client.name
10
+ )
11
+ FROM organization AS client
12
+ LEFT JOIN member AS membership ON membership.organization_id = client.id
13
+ LEFT JOIN "user" AS account ON account.id = membership.user_id
14
+ WHERE profile.organization_id = client.id AND profile.client_type = 'person';
15
+ ALTER TABLE clients.client_profile ADD CONSTRAINT person_name_parts_check CHECK (
16
+ client_type <> 'person'
17
+ OR (first_name IS NOT NULL AND length(trim(first_name)) > 0 AND last_name IS NOT NULL AND length(trim(last_name)) > 0)
18
+ );
package/nuxt.config.ts CHANGED
@@ -12,6 +12,12 @@ export default defineNuxtConfig({
12
12
  ]
13
13
  },
14
14
  runtimeConfig: {
15
- public: { clients: { defaultModules: [] as string[] } }
15
+ public: {
16
+ clients: {
17
+ defaultModules: [] as string[],
18
+ allowedTypes: ['organization'] as string[],
19
+ personalSelfRegistration: false
20
+ }
21
+ }
16
22
  }
17
23
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/clients",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -41,8 +41,8 @@
41
41
  "nanoid": "^5.1.6",
42
42
  "pg": "^8.22.0",
43
43
  "zod": "^4.4.3",
44
- "@nuxt-customer-portal/core": "^0.3.2",
45
- "@nuxt-customer-portal/ui": "^0.3.2"
44
+ "@nuxt-customer-portal/core": "^0.4.0",
45
+ "@nuxt-customer-portal/ui": "^0.4.0"
46
46
  },
47
47
  "peerDependencies": {
48
48
  "nuxt": "^4.5.1",
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'clients',
3
- version: '0.3.2',
3
+ version: '0.4.0',
4
4
  source: '@nuxt-customer-portal/clients',
5
5
  dependsOn: ['core', 'ui'],
6
6
  schema: './server/db/schema/clients.ts',
@@ -0,0 +1,27 @@
1
+ import { and, eq, isNull, notExists } from 'drizzle-orm'
2
+ import { db, requireSession } from '@nuxt-customer-portal/core/server/portal'
3
+ import { organization, member } from '@nuxt-customer-portal/core/schema'
4
+ import { clientProfile } from '../../../db/schema/clients'
5
+ import { requireAllowedClientType } from '../../../utils/client-configuration'
6
+
7
+ export default defineEventHandler(async (event) => {
8
+ const session = await requireSession(event)
9
+ if (session.user.role !== 'admin') {
10
+ throw createError({ statusCode: 403 })
11
+ }
12
+ await requireAllowedClientType('person')
13
+ return db
14
+ .select({ id: clientProfile.organizationId, name: organization.name, email: clientProfile.invoiceEmail })
15
+ .from(clientProfile)
16
+ .innerJoin(organization, eq(organization.id, clientProfile.organizationId))
17
+ .where(
18
+ and(
19
+ eq(clientProfile.clientType, 'person'),
20
+ isNull(clientProfile.archivedAt),
21
+ notExists(
22
+ db.select({ id: member.id }).from(member).where(eq(member.organizationId, clientProfile.organizationId))
23
+ )
24
+ )
25
+ )
26
+ .orderBy(organization.name)
27
+ })