@flowselections/admin-installatietechniek 1.0.6 → 1.0.7

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.
@@ -31,6 +31,7 @@ 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(),
34
35
  });
35
36
  const rolePermissionsSchema = z
36
37
  .array(z.object({
@@ -99,11 +100,18 @@ async function assertMayUseRole(supabase, roleId, isAdmin) {
99
100
  const profileScopeInput = z
100
101
  .object({ profileId: z.string().uuid().optional() })
101
102
  .optional();
102
- /** Tenant + rol-/module-scope van een bedrijfsprofiel (of het actieve profiel). */
103
+ /**
104
+ * Tenant + rol-/module-scope van een bedrijfsprofiel.
105
+ * Zonder `profileId` is er geen scope (= overzicht: alles zichtbaar).
106
+ */
103
107
  async function loadProfileScope(supabase, profileId) {
104
- let query = supabase.from("profiles_org").select("id, name, tenant_id, is_default");
105
- query = profileId ? query.eq("id", profileId) : query.eq("is_default", true);
106
- const { data, error } = await query.maybeSingle();
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();
107
115
  if (error)
108
116
  throw error;
109
117
  if (!data)
@@ -120,19 +128,60 @@ async function loadProfileScope(supabase, profileId) {
120
128
  moduleIds: (Array.isArray(moduleRows) ? moduleRows : []).map((m) => m.module_id),
121
129
  };
122
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 };
172
+ }
123
173
  export const getUsersWithRoles = createServerFn({ method: "POST" })
124
174
  .middleware([requireSupabaseAuth])
125
175
  .inputValidator((data) => profileScopeInput.parse(data))
126
176
  .handler(async ({ context, data }) => {
127
177
  const { isAdmin } = await requireUserAdmin(context);
128
178
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
129
- const scope = await loadProfileScope(supabaseAdmin, data?.profileId);
130
- let profilesQuery = supabaseAdmin
179
+ const effective = await resolveEffectiveProfileId(supabaseAdmin, context.userId, data?.profileId, isAdmin);
180
+ const scope = await loadProfileScope(supabaseAdmin, effective.profileId);
181
+ const profilesQuery = supabaseAdmin
131
182
  .from("profiles")
132
183
  .select("id, email, display_name, tenant_id")
133
184
  .order("email", { ascending: true });
134
- if (scope?.tenantId)
135
- profilesQuery = profilesQuery.eq("tenant_id", scope.tenantId);
136
185
  const [{ data: profiles, error: profilesError }, { data: assignments, error: assignmentsError }, { data: roles, error: rolesError }, { data: authList, error: authError },] = await Promise.all([
137
186
  profilesQuery,
138
187
  supabaseAdmin.from("role_assignments").select("user_id, role_id"),
@@ -173,19 +222,51 @@ export const getUsersWithRoles = createServerFn({ method: "POST" })
173
222
  }
174
223
  rolesByUser.set(a.user_id, list);
175
224
  }
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
+ });
176
245
  return {
177
- users: safeProfiles.map((profile) => ({
178
- id: profile.id,
179
- email: profile.email ?? "-",
180
- displayName: profile.display_name ?? "-",
181
- roles: rolesByUser.get(profile.id) ?? [],
182
- emailConfirmed: confirmedById.get(profile.id) ?? false,
183
- isTestAccount: isTestById.get(profile.id) ?? false,
184
- })),
185
- availableRoles: safeRoles
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)
186
266
  .filter((r) => isAdmin || r.key !== "admin")
187
267
  .map((r) => ({ id: r.id, key: r.key, name: r.name, isProtected: r.is_protected })),
188
268
  isAdmin,
269
+ profileLocked: effective.locked,
189
270
  activeProfile: scope ? { id: scope.id, name: scope.name, tenantId: scope.tenantId } : null,
190
271
  };
191
272
  });
@@ -210,6 +291,19 @@ export const assignRole = createServerFn({ method: "POST" })
210
291
  .insert({ user_id: data.userId, role_id: data.roleId });
211
292
  if (error)
212
293
  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
+ }
306
+ }
213
307
  return { ok: true };
214
308
  });
215
309
  export const removeRole = createServerFn({ method: "POST" })
@@ -235,8 +329,10 @@ export const getRoles = createServerFn({ method: "POST" })
235
329
  .middleware([requireSupabaseAuth])
236
330
  .inputValidator((data) => profileScopeInput.parse(data))
237
331
  .handler(async ({ context, data }) => {
238
- await requireUserAdmin(context);
239
- const scope = await loadProfileScope(context.supabase, data?.profileId);
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);
240
336
  const [{ data: roles, error: rolesError }, { data: perms, error: permsError }, { data: registry, error: registryError }, { data: featureRows, error: featuresError },] = await Promise.all([
241
337
  context.supabase.from("roles").select("id, key, name, is_protected").order("name", { ascending: true }),
242
338
  context.supabase.from("role_permissions").select("role_id, module_id, feature_id, can_view, can_edit"),
@@ -251,12 +347,13 @@ export const getRoles = createServerFn({ method: "POST" })
251
347
  throw registryError;
252
348
  if (featuresError)
253
349
  throw featuresError;
254
- const allRoles = Array.isArray(roles) ? roles : [];
350
+ const noProfileLock = effective.locked && !scope;
351
+ const allRoles = noProfileLock ? [] : Array.isArray(roles) ? roles : [];
255
352
  const safeRoles = scope && scope.roleIds.length > 0
256
353
  ? allRoles.filter((r) => scope.roleIds.includes(r.id))
257
354
  : allRoles;
258
355
  const safePerms = Array.isArray(perms) ? perms : [];
259
- const allRegistry = Array.isArray(registry) ? registry : [];
356
+ const allRegistry = noProfileLock ? [] : Array.isArray(registry) ? registry : [];
260
357
  const safeRegistry = scope && scope.moduleIds.length > 0
261
358
  ? allRegistry.filter((m) => scope.moduleIds.includes(m.module_id))
262
359
  : allRegistry;
@@ -285,6 +382,7 @@ export const getRoles = createServerFn({ method: "POST" })
285
382
  canView: Boolean(p.can_view),
286
383
  canEdit: Boolean(p.can_edit),
287
384
  })),
385
+ profileLocked: effective.locked,
288
386
  activeProfile: scope ? { id: scope.id, name: scope.name, tenantId: scope.tenantId } : null,
289
387
  };
290
388
  });
@@ -415,12 +513,22 @@ export const syncModuleRegistry = createServerFn({ method: "POST" })
415
513
  if (modules.length === 0)
416
514
  return { ok: true, count: 0 };
417
515
  const now = new Date().toISOString();
516
+ const { data: existingRows, error: existingError } = await context.supabase
517
+ .from("module_registry")
518
+ .select("module_id")
519
+ .in("module_id", modules.map((m) => m.id));
520
+ if (existingError)
521
+ throw existingError;
522
+ const knownIds = new Set((Array.isArray(existingRows) ? existingRows : []).map((r) => r.module_id));
418
523
  const { error } = await context.supabase.from("module_registry").upsert(modules.map((m) => ({
419
524
  module_id: m.id,
420
525
  name: m.name,
421
526
  shell_id: data.shellId ?? null,
422
527
  is_active: true,
423
528
  last_seen_at: now,
529
+ // Nieuwe modules komen standaard met afdwingen aan binnen; bestaande
530
+ // modules behouden hun eigen instelling.
531
+ ...(knownIds.has(m.id) ? {} : { enforce: true }),
424
532
  })), { onConflict: "module_id" });
425
533
  if (error)
426
534
  throw error;
@@ -697,11 +805,51 @@ const profileIdInput = z.object({ profileId: z.string().uuid() });
697
805
  const profileBodySchema = {
698
806
  name: z.string().trim().min(1).max(120),
699
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(),
700
810
  roleIds: z.array(z.string().uuid()).max(200).default([]),
701
811
  moduleIds: z.array(z.string().trim().min(1).max(100)).max(200).default([]),
702
812
  };
703
813
  const createProfileInput = z.object(profileBodySchema);
704
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
+ }
705
853
  async function writeProfileLinks(supabase, profileId, roleIds, moduleIds) {
706
854
  const { error: delRoles } = await supabase.from("profile_roles").delete().eq("profile_id", profileId);
707
855
  if (delRoles)
@@ -730,7 +878,9 @@ async function writeProfileLinks(supabase, profileId, roleIds, moduleIds) {
730
878
  export const getProfiles = createServerFn({ method: "GET" })
731
879
  .middleware([requireSupabaseAuth])
732
880
  .handler(async ({ context }) => {
733
- await requireUserAdmin(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);
734
884
  const [{ data: profilesOrg, error: profilesError }, { data: profileRoles, error: profileRolesError }, { data: profileModules, error: profileModulesError }, { data: tenants, error: tenantsError },] = await Promise.all([
735
885
  context.supabase
736
886
  .from("profiles_org")
@@ -738,7 +888,9 @@ export const getProfiles = createServerFn({ method: "GET" })
738
888
  .order("name", { ascending: true }),
739
889
  context.supabase.from("profile_roles").select("profile_id, role_id"),
740
890
  context.supabase.from("profile_modules").select("profile_id, module_id"),
741
- context.supabase.from("tenants").select("id, name").order("name", { ascending: true }),
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 }),
742
894
  ]);
743
895
  if (profilesError)
744
896
  throw profilesError;
@@ -748,13 +900,16 @@ export const getProfiles = createServerFn({ method: "GET" })
748
900
  throw profileModulesError;
749
901
  if (tenantsError)
750
902
  throw tenantsError;
751
- const { supabaseAdmin } = await import("../integrations/supabase/client.server");
752
903
  const { data: userRows, error: userError } = await supabaseAdmin
753
904
  .from("profiles")
754
905
  .select("id, tenant_id");
755
906
  if (userError)
756
907
  throw userError;
757
- const safeProfiles = Array.isArray(profilesOrg) ? profilesOrg : [];
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;
758
913
  const safeProfileRoles = Array.isArray(profileRoles) ? profileRoles : [];
759
914
  const safeProfileModules = Array.isArray(profileModules) ? profileModules : [];
760
915
  const safeTenants = Array.isArray(tenants) ? tenants : [];
@@ -780,6 +935,8 @@ export const getProfiles = createServerFn({ method: "GET" })
780
935
  userCount: p.tenant_id ? usersByTenant.get(p.tenant_id) ?? 0 : 0,
781
936
  })),
782
937
  tenants: safeTenants.map((t) => ({ id: t.id, name: t.name })),
938
+ profileLocked: effective.locked,
939
+ lockedProfileId: effective.locked ? effective.profileId ?? null : null,
783
940
  };
784
941
  });
785
942
  export const createProfile = createServerFn({ method: "POST" })
@@ -787,9 +944,13 @@ export const createProfile = createServerFn({ method: "POST" })
787
944
  .inputValidator((data) => createProfileInput.parse(data))
788
945
  .handler(async ({ context, data }) => {
789
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);
790
951
  const { data: created, error } = await context.supabase
791
952
  .from("profiles_org")
792
- .insert({ name: data.name, tenant_id: data.tenantId ?? null })
953
+ .insert({ name: data.name, tenant_id: tenantId })
793
954
  .select("id")
794
955
  .single();
795
956
  if (error)
@@ -802,9 +963,11 @@ export const updateProfile = createServerFn({ method: "POST" })
802
963
  .inputValidator((data) => updateProfileInput.parse(data))
803
964
  .handler(async ({ context, data }) => {
804
965
  await requireAdmin(context);
966
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
967
+ const tenantId = await resolveTenant(supabaseAdmin, data.tenantId, data.tenantName);
805
968
  const { error } = await context.supabase
806
969
  .from("profiles_org")
807
- .update({ name: data.name, tenant_id: data.tenantId ?? null, updated_at: new Date().toISOString() })
970
+ .update({ name: data.name, tenant_id: tenantId, updated_at: new Date().toISOString() })
808
971
  .eq("id", data.profileId);
809
972
  if (error)
810
973
  throw error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowselections/admin-installatietechniek",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "flowselections": {
5
5
  "moduleId": "admin-installatietechniek",
6
6
  "pages": [
@@ -40,7 +40,7 @@
40
40
  "lint": "eslint ."
41
41
  },
42
42
  "peerDependencies": {
43
- "@flowselections/core": "*",
43
+ "@flowselections/core": ">=1.0.18",
44
44
  "@tanstack/react-query": "^5.0.0",
45
45
  "@tanstack/react-router": "^1.0.0",
46
46
  "@tanstack/react-start": "^1.0.0",