@flowselections/admin-installatietechniek 1.0.7 → 1.0.9

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.
@@ -1,8 +1,8 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
- import { getRequestHost } from "@tanstack/react-start/server";
3
2
  import { z } from "zod";
4
3
  import { requireSupabaseAuth } from "../integrations/supabase/auth-middleware";
5
- import { requireAdmin, requireUserAdmin } from "../lib/admin.server";
4
+ import { normalizeServerErrors } from "../lib/serverErrors";
5
+ import { isFullAdminRoleKey, requireAdmin, requireUserAdmin, resolveAppBaseUrl, verifyAddressWithGoogle } from "../lib/admin.server";
6
6
  const featureSchema = z.object({
7
7
  id: z.string().trim().min(1).max(200),
8
8
  name: z.string().trim().min(1).max(200),
@@ -31,7 +31,6 @@ const roleIdInput = z.object({ roleId: z.string().uuid() });
31
31
  const assignRoleInput = z.object({
32
32
  userId: z.string().uuid(),
33
33
  roleId: z.string().uuid(),
34
- profileId: z.string().uuid().optional(),
35
34
  });
36
35
  const rolePermissionsSchema = z
37
36
  .array(z.object({
@@ -50,11 +49,33 @@ const updateRoleInput = z.object({
50
49
  name: z.string().trim().min(1).max(80),
51
50
  permissions: rolePermissionsSchema.default([]),
52
51
  });
52
+ const CONTACT_REQUIRED_ROLE_KEYS = ["monteur", "planner", "administratie"];
53
+ const phoneRegex = /^[0-9+()\-\s]{8,25}$/;
54
+ const optionalAddress = z.string().trim().max(200).optional().default("");
55
+ const optionalPhone = z
56
+ .string()
57
+ .trim()
58
+ .max(25)
59
+ .optional()
60
+ .default("")
61
+ .refine((v) => v === "" || phoneRegex.test(v), { message: "Ongeldig telefoonnummer" });
62
+ function assertContactForRole(roleKey, address, _phone) {
63
+ if (!roleKey || !CONTACT_REQUIRED_ROLE_KEYS.includes(roleKey))
64
+ return;
65
+ if (address.trim().length < 5)
66
+ throw new Error("Adres is verplicht voor deze rol (minimaal 5 tekens).");
67
+ }
53
68
  const inviteUserInput = z.object({
54
69
  email: z.string().trim().email().max(255),
55
70
  displayName: z.string().trim().min(1).max(100),
56
71
  roleId: z.string().uuid(),
57
- profileId: z.string().uuid().optional(),
72
+ address: optionalAddress,
73
+ phone: optionalPhone,
74
+ });
75
+ const updateUserContactInput = z.object({
76
+ userId: z.string().uuid(),
77
+ address: optionalAddress,
78
+ phone: optionalPhone,
58
79
  });
59
80
  const deleteUserInput = z.object({
60
81
  userId: z.string().uuid(),
@@ -67,7 +88,6 @@ const createTestUserInput = z.object({
67
88
  displayName: z.string().trim().min(1).max(100),
68
89
  roleId: z.string().uuid(),
69
90
  email: z.string().trim().email().max(255).optional(),
70
- profileId: z.string().uuid().optional(),
71
91
  });
72
92
  function slugify(value) {
73
93
  return (value
@@ -92,95 +112,30 @@ async function fetchRole(supabase, roleId) {
92
112
  }
93
113
  async function assertMayUseRole(supabase, roleId, isAdmin) {
94
114
  const role = await fetchRole(supabase, roleId);
95
- if (!isAdmin && role.key === "admin") {
115
+ if (!isAdmin && isFullAdminRoleKey(role.key)) {
96
116
  throw new Error("Alleen een admin kan de adminrol gebruiken.");
97
117
  }
98
118
  return role;
99
119
  }
100
- const profileScopeInput = z
101
- .object({ profileId: z.string().uuid().optional() })
102
- .optional();
103
120
  /**
104
- * Tenant + rol-/module-scope van een bedrijfsprofiel.
105
- * Zonder `profileId` is er geen scope (= overzicht: alles zichtbaar).
121
+ * Eén database = één organisatie. Bestaat er precies één tenant, dan is dat
122
+ * de tenant voor nieuwe gebruikers; anders wordt er niets gezet.
106
123
  */
107
- async function loadProfileScope(supabase, profileId) {
108
- if (!profileId)
109
- return null;
110
- const { data, error } = await supabase
111
- .from("profiles_org")
112
- .select("id, name, tenant_id, is_default")
113
- .eq("id", profileId)
114
- .maybeSingle();
124
+ async function resolveSingleTenantId(admin) {
125
+ const { data, error } = await admin.from("tenants").select("id").limit(2);
115
126
  if (error)
116
127
  throw error;
117
- if (!data)
118
- return null;
119
- const [{ data: roleRows }, { data: moduleRows }] = await Promise.all([
120
- supabase.from("profile_roles").select("role_id").eq("profile_id", data.id),
121
- supabase.from("profile_modules").select("module_id").eq("profile_id", data.id),
122
- ]);
123
- return {
124
- id: data.id,
125
- name: data.name,
126
- tenantId: (data.tenant_id ?? null),
127
- roleIds: (Array.isArray(roleRows) ? roleRows : []).map((r) => r.role_id),
128
- moduleIds: (Array.isArray(moduleRows) ? moduleRows : []).map((m) => m.module_id),
129
- };
130
- }
131
- /**
132
- * Bepaalt op welk bedrijfsprofiel deze aanvraag draait.
133
- * - Admin: vrije keuze (leeg = overzicht met alles).
134
- * - Niet-admin (administratie): altijd vastgezet op het eigen profiel,
135
- * afgeleid uit de tenant van de gebruiker of anders uit de profielen
136
- * waaraan een van zijn rollen gekoppeld is.
137
- */
138
- async function resolveEffectiveProfileId(admin, userId, requestedProfileId, isAdmin) {
139
- if (isAdmin)
140
- return { profileId: requestedProfileId, locked: false };
141
- const { data: me } = await admin
142
- .from("profiles")
143
- .select("tenant_id")
144
- .eq("id", userId)
145
- .maybeSingle();
146
- if (me?.tenant_id) {
147
- const { data: byTenant } = await admin
148
- .from("profiles_org")
149
- .select("id")
150
- .eq("tenant_id", me.tenant_id)
151
- .limit(1);
152
- const first = Array.isArray(byTenant) ? byTenant[0] : null;
153
- if (first?.id)
154
- return { profileId: first.id, locked: true };
155
- }
156
- const { data: myRoles } = await admin
157
- .from("role_assignments")
158
- .select("role_id")
159
- .eq("user_id", userId);
160
- const roleIds = (Array.isArray(myRoles) ? myRoles : []).map((r) => r.role_id);
161
- if (roleIds.length > 0) {
162
- const { data: links } = await admin
163
- .from("profile_roles")
164
- .select("profile_id")
165
- .in("role_id", roleIds)
166
- .limit(1);
167
- const link = Array.isArray(links) ? links[0] : null;
168
- if (link?.profile_id)
169
- return { profileId: link.profile_id, locked: true };
170
- }
171
- return { profileId: undefined, locked: true };
128
+ const rows = Array.isArray(data) ? data : [];
129
+ return rows.length === 1 ? rows[0].id : null;
172
130
  }
173
131
  export const getUsersWithRoles = createServerFn({ method: "POST" })
174
- .middleware([requireSupabaseAuth])
175
- .inputValidator((data) => profileScopeInput.parse(data))
176
- .handler(async ({ context, data }) => {
132
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
133
+ .handler(async ({ context }) => {
177
134
  const { isAdmin } = await requireUserAdmin(context);
178
135
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
179
- const effective = await resolveEffectiveProfileId(supabaseAdmin, context.userId, data?.profileId, isAdmin);
180
- const scope = await loadProfileScope(supabaseAdmin, effective.profileId);
181
136
  const profilesQuery = supabaseAdmin
182
137
  .from("profiles")
183
- .select("id, email, display_name, tenant_id")
138
+ .select("id, email, display_name, tenant_id, address, phone")
184
139
  .order("email", { ascending: true });
185
140
  const [{ data: profiles, error: profilesError }, { data: assignments, error: assignmentsError }, { data: roles, error: rolesError }, { data: authList, error: authError },] = await Promise.all([
186
141
  profilesQuery,
@@ -199,15 +154,15 @@ export const getUsersWithRoles = createServerFn({ method: "POST" })
199
154
  const safeProfiles = Array.isArray(profiles) ? profiles : [];
200
155
  const safeAssignments = Array.isArray(assignments) ? assignments : [];
201
156
  const allRoles = Array.isArray(roles) ? roles : [];
202
- const safeRoles = scope && scope.roleIds.length > 0
203
- ? allRoles.filter((r) => scope.roleIds.includes(r.id))
204
- : allRoles;
205
157
  const safeAuthUsers = Array.isArray(authList?.users) ? authList.users : [];
206
158
  const roleById = new Map(allRoles.map((r) => [r.id, r]));
207
159
  const confirmedById = new Map();
208
160
  const isTestById = new Map();
161
+ const authEmailById = new Map();
209
162
  for (const u of safeAuthUsers) {
210
163
  confirmedById.set(u.id, Boolean(u.email_confirmed_at));
164
+ if (u.email)
165
+ authEmailById.set(u.id, u.email);
211
166
  const meta = (u.user_metadata ?? {});
212
167
  isTestById.set(u.id, Boolean(meta.is_test_account));
213
168
  }
@@ -222,56 +177,25 @@ export const getUsersWithRoles = createServerFn({ method: "POST" })
222
177
  }
223
178
  rolesByUser.set(a.user_id, list);
224
179
  }
225
- // Binnen een profiel horen gebruikers erbij via de tenant OF via een rol
226
- // die aan het profiel gekoppeld is. Zonder profiel: iedereen.
227
- const scopedRoleIds = new Set(scope?.roleIds ?? []);
228
- const usersViaRole = new Set();
229
- if (scope && scopedRoleIds.size > 0) {
230
- for (const a of safeAssignments) {
231
- if (scopedRoleIds.has(a.role_id))
232
- usersViaRole.add(a.user_id);
233
- }
234
- }
235
- // Vastgezette gebruiker zonder gekoppeld profiel: niets tonen.
236
- const noProfileLock = effective.locked && !scope;
237
- const scopedProfiles = noProfileLock
238
- ? []
239
- : !scope
240
- ? safeProfiles
241
- : safeProfiles.filter((p) => {
242
- const viaTenant = Boolean(scope.tenantId) && p.tenant_id === scope.tenantId;
243
- return viaTenant || usersViaRole.has(p.id);
244
- });
245
180
  return {
246
- users: scopedProfiles.map((profile) => {
247
- const viaTenant = Boolean(scope?.tenantId) && profile.tenant_id === scope?.tenantId;
248
- const viaRole = usersViaRole.has(profile.id);
249
- return {
250
- id: profile.id,
251
- email: profile.email ?? "-",
252
- displayName: profile.display_name ?? "-",
253
- roles: rolesByUser.get(profile.id) ?? [],
254
- emailConfirmed: confirmedById.get(profile.id) ?? false,
255
- isTestAccount: isTestById.get(profile.id) ?? false,
256
- matchedVia: !scope
257
- ? null
258
- : viaTenant && viaRole
259
- ? "both"
260
- : viaTenant
261
- ? "tenant"
262
- : "role",
263
- };
264
- }),
265
- availableRoles: (noProfileLock ? [] : safeRoles)
266
- .filter((r) => isAdmin || r.key !== "admin")
181
+ users: safeProfiles.map((profile) => ({
182
+ id: profile.id,
183
+ email: profile.email ?? authEmailById.get(profile.id) ?? "-",
184
+ displayName: profile.display_name ?? "-",
185
+ address: typeof profile.address === "string" ? profile.address : "",
186
+ phone: typeof profile.phone === "string" ? profile.phone : "",
187
+ roles: rolesByUser.get(profile.id) ?? [],
188
+ emailConfirmed: confirmedById.get(profile.id) ?? false,
189
+ isTestAccount: isTestById.get(profile.id) ?? false,
190
+ })),
191
+ availableRoles: allRoles
192
+ .filter((r) => isAdmin || !isFullAdminRoleKey(r.key))
267
193
  .map((r) => ({ id: r.id, key: r.key, name: r.name, isProtected: r.is_protected })),
268
194
  isAdmin,
269
- profileLocked: effective.locked,
270
- activeProfile: scope ? { id: scope.id, name: scope.name, tenantId: scope.tenantId } : null,
271
195
  };
272
196
  });
273
197
  export const assignRole = createServerFn({ method: "POST" })
274
- .middleware([requireSupabaseAuth])
198
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
275
199
  .inputValidator((data) => assignRoleInput.parse(data))
276
200
  .handler(async ({ context, data }) => {
277
201
  const { isAdmin } = await requireUserAdmin(context);
@@ -291,23 +215,21 @@ export const assignRole = createServerFn({ method: "POST" })
291
215
  .insert({ user_id: data.userId, role_id: data.roleId });
292
216
  if (error)
293
217
  throw error;
294
- // Binnen een actief bedrijfsprofiel: koppel de gebruiker aan de tenant van
295
- // dat profiel als die nog leeg is, zodat de koppeling vanzelf dichtgroeit.
296
- if (data.profileId) {
297
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
298
- const scope = await loadProfileScope(supabaseAdmin, data.profileId);
299
- if (scope?.tenantId) {
300
- await supabaseAdmin
301
- .from("profiles")
302
- .update({ tenant_id: scope.tenantId })
303
- .eq("id", data.userId)
304
- .is("tenant_id", null);
305
- }
218
+ // Eén organisatie per database: bestaat er precies één tenant, koppel de
219
+ // gebruiker daaraan als zijn tenant_id nog leeg is.
220
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
221
+ const tenantId = await resolveSingleTenantId(supabaseAdmin);
222
+ if (tenantId) {
223
+ await supabaseAdmin
224
+ .from("profiles")
225
+ .update({ tenant_id: tenantId })
226
+ .eq("id", data.userId)
227
+ .is("tenant_id", null);
306
228
  }
307
229
  return { ok: true };
308
230
  });
309
231
  export const removeRole = createServerFn({ method: "POST" })
310
- .middleware([requireSupabaseAuth])
232
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
311
233
  .inputValidator((data) => assignRoleInput.parse(data))
312
234
  .handler(async ({ context, data }) => {
313
235
  const { isAdmin } = await requireUserAdmin(context);
@@ -326,13 +248,9 @@ export const removeRole = createServerFn({ method: "POST" })
326
248
  });
327
249
  /** Alle rollen met hun rechten, plus de geregistreerde modules en onderdelen. */
328
250
  export const getRoles = createServerFn({ method: "POST" })
329
- .middleware([requireSupabaseAuth])
330
- .inputValidator((data) => profileScopeInput.parse(data))
331
- .handler(async ({ context, data }) => {
332
- const { isAdmin } = await requireUserAdmin(context);
333
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
334
- const effective = await resolveEffectiveProfileId(supabaseAdmin, context.userId, data?.profileId, isAdmin);
335
- const scope = await loadProfileScope(supabaseAdmin, effective.profileId);
251
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
252
+ .handler(async ({ context }) => {
253
+ await requireUserAdmin(context);
336
254
  const [{ data: roles, error: rolesError }, { data: perms, error: permsError }, { data: registry, error: registryError }, { data: featureRows, error: featuresError },] = await Promise.all([
337
255
  context.supabase.from("roles").select("id, key, name, is_protected").order("name", { ascending: true }),
338
256
  context.supabase.from("role_permissions").select("role_id, module_id, feature_id, can_view, can_edit"),
@@ -347,23 +265,21 @@ export const getRoles = createServerFn({ method: "POST" })
347
265
  throw registryError;
348
266
  if (featuresError)
349
267
  throw featuresError;
350
- const noProfileLock = effective.locked && !scope;
351
- const allRoles = noProfileLock ? [] : Array.isArray(roles) ? roles : [];
352
- const safeRoles = scope && scope.roleIds.length > 0
353
- ? allRoles.filter((r) => scope.roleIds.includes(r.id))
354
- : allRoles;
268
+ const safeRoles = Array.isArray(roles) ? roles : [];
355
269
  const safePerms = Array.isArray(perms) ? perms : [];
356
- const allRegistry = noProfileLock ? [] : Array.isArray(registry) ? registry : [];
357
- const safeRegistry = scope && scope.moduleIds.length > 0
358
- ? allRegistry.filter((m) => scope.moduleIds.includes(m.module_id))
359
- : allRegistry;
270
+ const safeRegistry = Array.isArray(registry) ? registry : [];
360
271
  const safeFeatures = Array.isArray(featureRows) ? featureRows : [];
272
+ // Rollen waarvan de sleutel niet in de app_role-lijst voorkomt: die worden
273
+ // door de sync-trigger stil overgeslagen en werken nergens.
274
+ const { data: unmappedRows } = await context.supabase.rpc("roles_without_enum_mapping");
275
+ const unmappedKeys = new Set((Array.isArray(unmappedRows) ? unmappedRows : []).map((r) => r?.key));
361
276
  return {
362
277
  roles: safeRoles.map((r) => ({
363
278
  id: r.id,
364
279
  key: r.key,
365
280
  name: r.name,
366
281
  isProtected: r.is_protected,
282
+ unmapped: unmappedKeys.has(r.key),
367
283
  })),
368
284
  modules: safeRegistry
369
285
  .filter((m) => m.is_active)
@@ -382,8 +298,6 @@ export const getRoles = createServerFn({ method: "POST" })
382
298
  canView: Boolean(p.can_view),
383
299
  canEdit: Boolean(p.can_edit),
384
300
  })),
385
- profileLocked: effective.locked,
386
- activeProfile: scope ? { id: scope.id, name: scope.name, tenantId: scope.tenantId } : null,
387
301
  };
388
302
  });
389
303
  async function writeRolePermissions(supabase, roleId, permissions) {
@@ -407,7 +321,7 @@ async function writeRolePermissions(supabase, roleId, permissions) {
407
321
  throw error;
408
322
  }
409
323
  export const createRole = createServerFn({ method: "POST" })
410
- .middleware([requireSupabaseAuth])
324
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
411
325
  .inputValidator((data) => createRoleInput.parse(data))
412
326
  .handler(async ({ context, data }) => {
413
327
  await requireAdmin(context);
@@ -436,7 +350,7 @@ export const createRole = createServerFn({ method: "POST" })
436
350
  return { ok: true, roleId: created.id };
437
351
  });
438
352
  export const updateRole = createServerFn({ method: "POST" })
439
- .middleware([requireSupabaseAuth])
353
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
440
354
  .inputValidator((data) => updateRoleInput.parse(data))
441
355
  .handler(async ({ context, data }) => {
442
356
  await requireAdmin(context);
@@ -450,7 +364,7 @@ export const updateRole = createServerFn({ method: "POST" })
450
364
  return { ok: true };
451
365
  });
452
366
  export const duplicateRole = createServerFn({ method: "POST" })
453
- .middleware([requireSupabaseAuth])
367
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
454
368
  .inputValidator((data) => roleIdInput.parse(data))
455
369
  .handler(async ({ context, data }) => {
456
370
  await requireAdmin(context);
@@ -487,7 +401,7 @@ export const duplicateRole = createServerFn({ method: "POST" })
487
401
  return { ok: true, roleId: created.id };
488
402
  });
489
403
  export const deleteRole = createServerFn({ method: "POST" })
490
- .middleware([requireSupabaseAuth])
404
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
491
405
  .inputValidator((data) => roleIdInput.parse(data))
492
406
  .handler(async ({ context, data }) => {
493
407
  await requireAdmin(context);
@@ -505,7 +419,7 @@ export const deleteRole = createServerFn({ method: "POST" })
505
419
  * Wat niet meer voorkomt blijft bestaan maar wordt op inactief gezet.
506
420
  */
507
421
  export const syncModuleRegistry = createServerFn({ method: "POST" })
508
- .middleware([requireSupabaseAuth])
422
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
509
423
  .inputValidator((data) => syncModulesInput.parse(data))
510
424
  .handler(async ({ context, data }) => {
511
425
  await requireAdmin(context);
@@ -577,7 +491,7 @@ const setModuleEnforceInput = z.object({
577
491
  enforce: z.boolean(),
578
492
  });
579
493
  export const setModuleEnforce = createServerFn({ method: "POST" })
580
- .middleware([requireSupabaseAuth])
494
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
581
495
  .inputValidator((data) => setModuleEnforceInput.parse(data))
582
496
  .handler(async ({ context, data }) => {
583
497
  await requireAdmin(context);
@@ -590,7 +504,7 @@ export const setModuleEnforce = createServerFn({ method: "POST" })
590
504
  return { ok: true };
591
505
  });
592
506
  export const getUserModulePermissions = createServerFn({ method: "POST" })
593
- .middleware([requireSupabaseAuth])
507
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
594
508
  .inputValidator((data) => userPermissionsInput.parse(data))
595
509
  .handler(async ({ context, data }) => {
596
510
  await requireUserAdmin(context);
@@ -629,7 +543,7 @@ export const getUserModulePermissions = createServerFn({ method: "POST" })
629
543
  };
630
544
  });
631
545
  export const setUserModulePermission = createServerFn({ method: "POST" })
632
- .middleware([requireSupabaseAuth])
546
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
633
547
  .inputValidator((data) => setUserPermissionInput.parse(data))
634
548
  .handler(async ({ context, data }) => {
635
549
  await requireUserAdmin(context);
@@ -657,35 +571,36 @@ export const setUserModulePermission = createServerFn({ method: "POST" })
657
571
  return { ok: true };
658
572
  });
659
573
  export const inviteUser = createServerFn({ method: "POST" })
660
- .middleware([requireSupabaseAuth])
574
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
661
575
  .inputValidator((data) => inviteUserInput.parse(data))
662
576
  .handler(async ({ context, data }) => {
663
577
  const { isAdmin } = await requireUserAdmin(context);
664
578
  await assertMayUseRole(context.supabase, data.roleId, isAdmin);
579
+ const { data: roleRow, error: roleLookupError } = await context.supabase
580
+ .from("roles")
581
+ .select("key")
582
+ .eq("id", data.roleId)
583
+ .maybeSingle();
584
+ if (roleLookupError)
585
+ throw roleLookupError;
586
+ assertContactForRole(roleRow?.key ?? null, data.address, data.phone);
587
+ const verifiedAddress = await verifyAddressWithGoogle(data.address ?? "");
665
588
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
666
- let redirectTo;
667
- try {
668
- const host = getRequestHost();
669
- if (host) {
670
- const proto = host.startsWith("localhost") ? "http" : "https";
671
- redirectTo = `${proto}://${host}/reset-wachtwoord`;
672
- }
673
- }
674
- catch {
675
- redirectTo = undefined;
676
- }
589
+ const redirectTo = `${resolveAppBaseUrl()}/reset-wachtwoord`;
677
590
  const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(data.email, { data: { display_name: data.displayName }, redirectTo });
678
591
  if (inviteError || !invited?.user) {
679
592
  const msg = inviteError?.message ?? "Uitnodigen mislukt";
680
593
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
681
594
  }
682
595
  const userId = invited.user.id;
683
- const scope = await loadProfileScope(supabaseAdmin, data.profileId);
596
+ const tenantId = await resolveSingleTenantId(supabaseAdmin);
684
597
  const { error: profileError } = await supabaseAdmin.from("profiles").upsert({
685
598
  id: userId,
686
599
  email: data.email,
687
600
  display_name: data.displayName,
688
- ...(scope?.tenantId ? { tenant_id: scope.tenantId } : {}),
601
+ address: verifiedAddress ? verifiedAddress : null,
602
+ phone: data.phone ? data.phone.trim() : null,
603
+ ...(tenantId ? { tenant_id: tenantId } : {}),
689
604
  }, { onConflict: "id" });
690
605
  if (profileError)
691
606
  throw profileError;
@@ -697,7 +612,7 @@ export const inviteUser = createServerFn({ method: "POST" })
697
612
  return { ok: true, userId };
698
613
  });
699
614
  export const deleteUser = createServerFn({ method: "POST" })
700
- .middleware([requireSupabaseAuth])
615
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
701
616
  .inputValidator((data) => deleteUserInput.parse(data))
702
617
  .handler(async ({ context, data }) => {
703
618
  const { isAdmin } = await requireUserAdmin(context);
@@ -712,7 +627,7 @@ export const deleteUser = createServerFn({ method: "POST" })
712
627
  if (targetError)
713
628
  throw targetError;
714
629
  const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
715
- if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
630
+ if (safeTargetRoles.some((r) => isFullAdminRoleKey(r?.roles?.key))) {
716
631
  throw new Error("Alleen een admin kan een adminaccount verwijderen.");
717
632
  }
718
633
  }
@@ -723,7 +638,7 @@ export const deleteUser = createServerFn({ method: "POST" })
723
638
  return { ok: true };
724
639
  });
725
640
  export const updateUserName = createServerFn({ method: "POST" })
726
- .middleware([requireSupabaseAuth])
641
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
727
642
  .inputValidator((data) => updateUserNameInput.parse(data))
728
643
  .handler(async ({ context, data }) => {
729
644
  const { isAdmin } = await requireUserAdmin(context);
@@ -735,7 +650,7 @@ export const updateUserName = createServerFn({ method: "POST" })
735
650
  if (targetError)
736
651
  throw targetError;
737
652
  const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
738
- if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
653
+ if (safeTargetRoles.some((r) => isFullAdminRoleKey(r?.roles?.key))) {
739
654
  throw new Error("Alleen een admin kan een adminaccount aanpassen.");
740
655
  }
741
656
  }
@@ -753,6 +668,36 @@ export const updateUserName = createServerFn({ method: "POST" })
753
668
  throw error;
754
669
  return { ok: true };
755
670
  });
671
+ export const updateUserContact = createServerFn({ method: "POST" })
672
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
673
+ .inputValidator((data) => updateUserContactInput.parse(data))
674
+ .handler(async ({ context, data }) => {
675
+ await requireUserAdmin(context);
676
+ const { data: targetRoles, error: targetError } = await context.supabase
677
+ .from("role_assignments")
678
+ .select("roles(key)")
679
+ .eq("user_id", data.userId);
680
+ if (targetError)
681
+ throw targetError;
682
+ const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
683
+ const keys = safeTargetRoles
684
+ .map((r) => r?.roles?.key)
685
+ .filter((k) => typeof k === "string");
686
+ const requiredKey = keys.find((k) => CONTACT_REQUIRED_ROLE_KEYS.includes(k)) ?? null;
687
+ assertContactForRole(requiredKey, data.address, data.phone);
688
+ const verifiedAddress = await verifyAddressWithGoogle(data.address ?? "");
689
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
690
+ const { error } = await supabaseAdmin
691
+ .from("profiles")
692
+ .update({
693
+ address: verifiedAddress ? verifiedAddress : null,
694
+ phone: data.phone ? data.phone.trim() : null,
695
+ })
696
+ .eq("id", data.userId);
697
+ if (error)
698
+ throw error;
699
+ return { ok: true };
700
+ });
756
701
  function generatePassword(length = 16) {
757
702
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*";
758
703
  const bytes = new Uint8Array(length);
@@ -763,7 +708,7 @@ function generatePassword(length = 16) {
763
708
  return out;
764
709
  }
765
710
  export const createTestUser = createServerFn({ method: "POST" })
766
- .middleware([requireSupabaseAuth])
711
+ .middleware([normalizeServerErrors, requireSupabaseAuth])
767
712
  .inputValidator((data) => createTestUserInput.parse(data))
768
713
  .handler(async ({ context, data }) => {
769
714
  const { isAdmin } = await requireUserAdmin(context);
@@ -782,12 +727,12 @@ export const createTestUser = createServerFn({ method: "POST" })
782
727
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
783
728
  }
784
729
  const userId = created.user.id;
785
- const scope = await loadProfileScope(supabaseAdmin, data.profileId);
730
+ const tenantId = await resolveSingleTenantId(supabaseAdmin);
786
731
  const { error: profileError } = await supabaseAdmin.from("profiles").upsert({
787
732
  id: userId,
788
733
  email,
789
734
  display_name: data.displayName,
790
- ...(scope?.tenantId ? { tenant_id: scope.tenantId } : {}),
735
+ ...(tenantId ? { tenant_id: tenantId } : {}),
791
736
  }, { onConflict: "id" });
792
737
  if (profileError)
793
738
  throw profileError;
@@ -798,261 +743,3 @@ export const createTestUser = createServerFn({ method: "POST" })
798
743
  throw roleError;
799
744
  return { ok: true, userId, email, password };
800
745
  });
801
- // ============================================================================
802
- // Bedrijfsprofielen: bundelen rollen + modules per organisatie (tenant)
803
- // ============================================================================
804
- const profileIdInput = z.object({ profileId: z.string().uuid() });
805
- const profileBodySchema = {
806
- name: z.string().trim().min(1).max(120),
807
- tenantId: z.string().uuid().nullable().optional(),
808
- /** Naam van het bedrijf; maakt een nieuw bedrijf aan of hernoemt het gekozen bedrijf. */
809
- tenantName: z.string().trim().max(120).optional(),
810
- roleIds: z.array(z.string().uuid()).max(200).default([]),
811
- moduleIds: z.array(z.string().trim().min(1).max(100)).max(200).default([]),
812
- };
813
- const createProfileInput = z.object(profileBodySchema);
814
- const updateProfileInput = z.object({ profileId: z.string().uuid(), ...profileBodySchema });
815
- /**
816
- * Bepaalt het tenant-id voor een profiel:
817
- * - bestaand bedrijf + gewijzigde naam -> hernoemen
818
- * - geen bedrijf + naam -> nieuw bedrijf aanmaken
819
- */
820
- async function resolveTenant(supabase, tenantId, tenantName) {
821
- const name = (tenantName ?? "").trim();
822
- if (tenantId) {
823
- if (name) {
824
- const { data: current, error: readError } = await supabase
825
- .from("tenants")
826
- .select("name")
827
- .eq("id", tenantId)
828
- .maybeSingle();
829
- if (readError)
830
- throw readError;
831
- if (current && current.name !== name) {
832
- const { error } = await supabase
833
- .from("tenants")
834
- .update({ name, updated_at: new Date().toISOString() })
835
- .eq("id", tenantId);
836
- if (error)
837
- throw error;
838
- }
839
- }
840
- return tenantId;
841
- }
842
- if (!name)
843
- return null;
844
- const { data: created, error } = await supabase
845
- .from("tenants")
846
- .insert({ name })
847
- .select("id")
848
- .single();
849
- if (error)
850
- throw error;
851
- return created.id;
852
- }
853
- async function writeProfileLinks(supabase, profileId, roleIds, moduleIds) {
854
- const { error: delRoles } = await supabase.from("profile_roles").delete().eq("profile_id", profileId);
855
- if (delRoles)
856
- throw delRoles;
857
- const { error: delModules } = await supabase.from("profile_modules").delete().eq("profile_id", profileId);
858
- if (delModules)
859
- throw delModules;
860
- const uniqueRoles = Array.from(new Set(roleIds));
861
- if (uniqueRoles.length > 0) {
862
- const { error } = await supabase
863
- .from("profile_roles")
864
- .insert(uniqueRoles.map((roleId) => ({ profile_id: profileId, role_id: roleId })));
865
- if (error)
866
- throw error;
867
- }
868
- const uniqueModules = Array.from(new Set(moduleIds));
869
- if (uniqueModules.length > 0) {
870
- const { error } = await supabase
871
- .from("profile_modules")
872
- .insert(uniqueModules.map((moduleId) => ({ profile_id: profileId, module_id: moduleId })));
873
- if (error)
874
- throw error;
875
- }
876
- }
877
- /** Alle bedrijfsprofielen met hun rollen, modules en tellingen. */
878
- export const getProfiles = createServerFn({ method: "GET" })
879
- .middleware([requireSupabaseAuth])
880
- .handler(async ({ context }) => {
881
- const { isAdmin } = await requireUserAdmin(context);
882
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
883
- const effective = await resolveEffectiveProfileId(supabaseAdmin, context.userId, undefined, isAdmin);
884
- const [{ data: profilesOrg, error: profilesError }, { data: profileRoles, error: profileRolesError }, { data: profileModules, error: profileModulesError }, { data: tenants, error: tenantsError },] = await Promise.all([
885
- context.supabase
886
- .from("profiles_org")
887
- .select("id, name, tenant_id, is_default")
888
- .order("name", { ascending: true }),
889
- context.supabase.from("profile_roles").select("profile_id, role_id"),
890
- context.supabase.from("profile_modules").select("profile_id, module_id"),
891
- // `tenants` is via RLS beperkt tot de eigen tenant; namen tonen mag hier
892
- // omdat de aanvrager al is geverifieerd als admin/administratie.
893
- supabaseAdmin.from("tenants").select("id, name").order("name", { ascending: true }),
894
- ]);
895
- if (profilesError)
896
- throw profilesError;
897
- if (profileRolesError)
898
- throw profileRolesError;
899
- if (profileModulesError)
900
- throw profileModulesError;
901
- if (tenantsError)
902
- throw tenantsError;
903
- const { data: userRows, error: userError } = await supabaseAdmin
904
- .from("profiles")
905
- .select("id, tenant_id");
906
- if (userError)
907
- throw userError;
908
- const allProfilesOrg = Array.isArray(profilesOrg) ? profilesOrg : [];
909
- // Niet-admin: alleen het eigen (vastgezette) profiel.
910
- const safeProfiles = effective.locked
911
- ? allProfilesOrg.filter((p) => p.id === effective.profileId)
912
- : allProfilesOrg;
913
- const safeProfileRoles = Array.isArray(profileRoles) ? profileRoles : [];
914
- const safeProfileModules = Array.isArray(profileModules) ? profileModules : [];
915
- const safeTenants = Array.isArray(tenants) ? tenants : [];
916
- const safeUsers = Array.isArray(userRows) ? userRows : [];
917
- const usersByTenant = new Map();
918
- for (const u of safeUsers) {
919
- if (!u.tenant_id)
920
- continue;
921
- usersByTenant.set(u.tenant_id, (usersByTenant.get(u.tenant_id) ?? 0) + 1);
922
- }
923
- const tenantName = new Map(safeTenants.map((t) => [t.id, t.name]));
924
- return {
925
- profiles: safeProfiles.map((p) => ({
926
- id: p.id,
927
- name: p.name,
928
- tenantId: (p.tenant_id ?? null),
929
- tenantName: p.tenant_id ? tenantName.get(p.tenant_id) ?? null : null,
930
- isDefault: Boolean(p.is_default),
931
- roleIds: safeProfileRoles.filter((r) => r.profile_id === p.id).map((r) => r.role_id),
932
- moduleIds: safeProfileModules
933
- .filter((m) => m.profile_id === p.id)
934
- .map((m) => m.module_id),
935
- userCount: p.tenant_id ? usersByTenant.get(p.tenant_id) ?? 0 : 0,
936
- })),
937
- tenants: safeTenants.map((t) => ({ id: t.id, name: t.name })),
938
- profileLocked: effective.locked,
939
- lockedProfileId: effective.locked ? effective.profileId ?? null : null,
940
- };
941
- });
942
- export const createProfile = createServerFn({ method: "POST" })
943
- .middleware([requireSupabaseAuth])
944
- .inputValidator((data) => createProfileInput.parse(data))
945
- .handler(async ({ context, data }) => {
946
- await requireAdmin(context);
947
- // `tenants` heeft geen insert-policy voor admins; na de adminguard mag dit
948
- // veilig via de serverclient.
949
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
950
- const tenantId = await resolveTenant(supabaseAdmin, data.tenantId, data.tenantName);
951
- const { data: created, error } = await context.supabase
952
- .from("profiles_org")
953
- .insert({ name: data.name, tenant_id: tenantId })
954
- .select("id")
955
- .single();
956
- if (error)
957
- throw error;
958
- await writeProfileLinks(context.supabase, created.id, data.roleIds, data.moduleIds);
959
- return { ok: true, profileId: created.id };
960
- });
961
- export const updateProfile = createServerFn({ method: "POST" })
962
- .middleware([requireSupabaseAuth])
963
- .inputValidator((data) => updateProfileInput.parse(data))
964
- .handler(async ({ context, data }) => {
965
- await requireAdmin(context);
966
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
967
- const tenantId = await resolveTenant(supabaseAdmin, data.tenantId, data.tenantName);
968
- const { error } = await context.supabase
969
- .from("profiles_org")
970
- .update({ name: data.name, tenant_id: tenantId, updated_at: new Date().toISOString() })
971
- .eq("id", data.profileId);
972
- if (error)
973
- throw error;
974
- await writeProfileLinks(context.supabase, data.profileId, data.roleIds, data.moduleIds);
975
- return { ok: true };
976
- });
977
- export const duplicateProfile = createServerFn({ method: "POST" })
978
- .middleware([requireSupabaseAuth])
979
- .inputValidator((data) => profileIdInput.parse(data))
980
- .handler(async ({ context, data }) => {
981
- await requireAdmin(context);
982
- const { data: source, error: sourceError } = await context.supabase
983
- .from("profiles_org")
984
- .select("name, tenant_id")
985
- .eq("id", data.profileId)
986
- .maybeSingle();
987
- if (sourceError)
988
- throw sourceError;
989
- if (!source)
990
- throw new Error("Profiel bestaat niet.");
991
- const [{ data: roleRows }, { data: moduleRows }] = await Promise.all([
992
- context.supabase.from("profile_roles").select("role_id").eq("profile_id", data.profileId),
993
- context.supabase.from("profile_modules").select("module_id").eq("profile_id", data.profileId),
994
- ]);
995
- const { data: created, error } = await context.supabase
996
- .from("profiles_org")
997
- .insert({ name: `${source.name} (kopie)`, tenant_id: source.tenant_id })
998
- .select("id")
999
- .single();
1000
- if (error)
1001
- throw error;
1002
- await writeProfileLinks(context.supabase, created.id, (Array.isArray(roleRows) ? roleRows : []).map((r) => r.role_id), (Array.isArray(moduleRows) ? moduleRows : []).map((m) => m.module_id));
1003
- return { ok: true, profileId: created.id };
1004
- });
1005
- export const deleteProfile = createServerFn({ method: "POST" })
1006
- .middleware([requireSupabaseAuth])
1007
- .inputValidator((data) => profileIdInput.parse(data))
1008
- .handler(async ({ context, data }) => {
1009
- await requireAdmin(context);
1010
- const { data: profile, error: profileError } = await context.supabase
1011
- .from("profiles_org")
1012
- .select("id, tenant_id, is_default")
1013
- .eq("id", data.profileId)
1014
- .maybeSingle();
1015
- if (profileError)
1016
- throw profileError;
1017
- if (!profile)
1018
- throw new Error("Profiel bestaat niet.");
1019
- if (profile.is_default)
1020
- throw new Error("Het actieve profiel kan niet verwijderd worden.");
1021
- if (profile.tenant_id) {
1022
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
1023
- const { count, error: countError } = await supabaseAdmin
1024
- .from("profiles")
1025
- .select("id", { count: "exact", head: true })
1026
- .eq("tenant_id", profile.tenant_id);
1027
- if (countError)
1028
- throw countError;
1029
- if ((count ?? 0) > 0) {
1030
- throw new Error("Er zijn nog gebruikers aan dit bedrijf gekoppeld.");
1031
- }
1032
- }
1033
- const { error } = await context.supabase.from("profiles_org").delete().eq("id", data.profileId);
1034
- if (error)
1035
- throw error;
1036
- return { ok: true };
1037
- });
1038
- /** Zet precies één profiel als actief (is_default). */
1039
- export const setActiveProfile = createServerFn({ method: "POST" })
1040
- .middleware([requireSupabaseAuth])
1041
- .inputValidator((data) => profileIdInput.parse(data))
1042
- .handler(async ({ context, data }) => {
1043
- await requireAdmin(context);
1044
- const { error: clearError } = await context.supabase
1045
- .from("profiles_org")
1046
- .update({ is_default: false })
1047
- .eq("is_default", true)
1048
- .neq("id", data.profileId);
1049
- if (clearError)
1050
- throw clearError;
1051
- const { error } = await context.supabase
1052
- .from("profiles_org")
1053
- .update({ is_default: true, updated_at: new Date().toISOString() })
1054
- .eq("id", data.profileId);
1055
- if (error)
1056
- throw error;
1057
- return { ok: true };
1058
- });