@flowselections/admin-installatietechniek 1.0.3 → 1.0.5

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 (36) hide show
  1. package/dist-lib/components/AdminForInstallatieTechniek.d.ts +4 -0
  2. package/dist-lib/components/AdminForInstallatieTechniek.d.ts.map +1 -1
  3. package/dist-lib/components/AdminForInstallatieTechniek.js +223 -197
  4. package/dist-lib/components/ModuleAccessGuard.d.ts +15 -0
  5. package/dist-lib/components/ModuleAccessGuard.d.ts.map +1 -0
  6. package/dist-lib/components/ModuleAccessGuard.js +40 -0
  7. package/dist-lib/hooks/useModulePermission.d.ts +11 -4
  8. package/dist-lib/hooks/useModulePermission.d.ts.map +1 -1
  9. package/dist-lib/hooks/useModulePermission.js +33 -26
  10. package/dist-lib/index.d.ts +3 -3
  11. package/dist-lib/index.d.ts.map +1 -1
  12. package/dist-lib/index.js +2 -1
  13. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
  14. package/dist-lib/integrations/supabase/auth-attacher.js +5 -1
  15. package/dist-lib/integrations/supabase/auth-middleware.d.ts +978 -133
  16. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  17. package/dist-lib/integrations/supabase/auth-middleware.js +21 -7
  18. package/dist-lib/integrations/supabase/client.d.ts +978 -133
  19. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  20. package/dist-lib/integrations/supabase/client.server.d.ts +978 -133
  21. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  22. package/dist-lib/integrations/supabase/types.d.ts +987 -137
  23. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  24. package/dist-lib/lib/admin.functions.d.ts +78644 -2655
  25. package/dist-lib/lib/admin.functions.d.ts.map +1 -1
  26. package/dist-lib/lib/admin.functions.js +351 -174
  27. package/dist-lib/lib/moduleDiscovery.d.ts +7 -2
  28. package/dist-lib/lib/moduleDiscovery.d.ts.map +1 -1
  29. package/dist-lib/lib/moduleDiscovery.js +26 -11
  30. package/dist-lib/lib/permissions.server.d.ts +14 -0
  31. package/dist-lib/lib/permissions.server.d.ts.map +1 -0
  32. package/dist-lib/lib/permissions.server.js +26 -0
  33. package/package.json +1 -1
  34. package/dist-lib/hooks/useModuleOrder.d.ts +0 -30
  35. package/dist-lib/hooks/useModuleOrder.d.ts.map +0 -1
  36. package/dist-lib/hooks/useModuleOrder.js +0 -99
@@ -3,83 +3,121 @@ import { getRequestHost } from "@tanstack/react-start/server";
3
3
  import { z } from "zod";
4
4
  import { requireSupabaseAuth } from "../integrations/supabase/auth-middleware";
5
5
  import { requireAdmin, requireUserAdmin } from "../lib/admin.server";
6
- const APP_ROLES = ["admin", "planner", "monteur", "administratie"];
7
- const roleSchema = z.enum(["admin", "planner", "monteur", "administratie"]);
8
- const permissionSchema = z.enum(["none", "view", "edit"]);
6
+ const featureSchema = z.object({
7
+ id: z.string().trim().min(1).max(200),
8
+ name: z.string().trim().min(1).max(200),
9
+ });
9
10
  const syncModulesInput = z.object({
10
11
  shellId: z.string().trim().min(1).max(100).optional(),
11
12
  modules: z
12
13
  .array(z.object({
13
14
  id: z.string().trim().min(1).max(100),
14
15
  name: z.string().trim().min(1).max(120),
16
+ features: z.array(featureSchema).max(200).optional(),
15
17
  }))
16
18
  .max(200),
17
19
  });
20
+ const permissionSchema = z.enum(["none", "view", "edit"]);
18
21
  const setUserPermissionInput = z.object({
19
22
  userId: z.string().uuid(),
20
23
  moduleId: z.string().min(1),
24
+ featureId: z.string().max(200).default(""),
21
25
  permission: z.union([permissionSchema, z.literal("inherit")]),
22
26
  });
23
27
  const userPermissionsInput = z.object({
24
28
  userId: z.string().uuid(),
25
29
  });
26
- const moduleOrderInput = z.object({
27
- moduleIds: z.array(z.string().trim().min(1).max(100)).max(200),
28
- });
30
+ const roleIdInput = z.object({ roleId: z.string().uuid() });
29
31
  const assignRoleInput = z.object({
30
32
  userId: z.string().uuid(),
31
- role: roleSchema,
33
+ roleId: z.string().uuid(),
32
34
  });
33
- const removeRoleInput = z.object({
34
- userId: z.string().uuid(),
35
- role: roleSchema,
35
+ const rolePermissionsSchema = z
36
+ .array(z.object({
37
+ moduleId: z.string().trim().min(1).max(100),
38
+ featureId: z.string().trim().max(200).default(""),
39
+ canView: z.boolean(),
40
+ canEdit: z.boolean(),
41
+ }))
42
+ .max(2000);
43
+ const createRoleInput = z.object({
44
+ name: z.string().trim().min(1).max(80),
45
+ permissions: rolePermissionsSchema.default([]),
36
46
  });
37
- const setPermissionInput = z.object({
38
- role: roleSchema,
39
- moduleId: z.string().min(1),
40
- permission: permissionSchema,
47
+ const updateRoleInput = z.object({
48
+ roleId: z.string().uuid(),
49
+ name: z.string().trim().min(1).max(80),
50
+ permissions: rolePermissionsSchema.default([]),
41
51
  });
42
52
  const inviteUserInput = z.object({
43
53
  email: z.string().trim().email().max(255),
44
54
  displayName: z.string().trim().min(1).max(100),
45
- role: roleSchema,
55
+ roleId: z.string().uuid(),
46
56
  });
47
57
  const deleteUserInput = z.object({
48
58
  userId: z.string().uuid(),
49
59
  });
60
+ const updateUserNameInput = z.object({
61
+ userId: z.string().uuid(),
62
+ displayName: z.string().trim().min(1).max(100),
63
+ });
50
64
  const createTestUserInput = z.object({
51
65
  displayName: z.string().trim().min(1).max(100),
52
- role: roleSchema,
66
+ roleId: z.string().uuid(),
53
67
  email: z.string().trim().email().max(255).optional(),
54
68
  });
55
- function uniqueRoles(roles) {
56
- return Array.from(new Set(roles));
69
+ function slugify(value) {
70
+ return (value
71
+ .toLowerCase()
72
+ .normalize("NFD")
73
+ .replace(/[\u0300-\u036f]/g, "")
74
+ .replace(/[^a-z0-9]+/g, "-")
75
+ .replace(/^-+|-+$/g, "")
76
+ .slice(0, 60) || `rol-${Date.now()}`);
77
+ }
78
+ async function fetchRole(supabase, roleId) {
79
+ const { data, error } = await supabase
80
+ .from("roles")
81
+ .select("id, key, name, is_protected")
82
+ .eq("id", roleId)
83
+ .maybeSingle();
84
+ if (error)
85
+ throw error;
86
+ if (!data)
87
+ throw new Error("Rol bestaat niet.");
88
+ return data;
89
+ }
90
+ async function assertMayUseRole(supabase, roleId, isAdmin) {
91
+ const role = await fetchRole(supabase, roleId);
92
+ if (!isAdmin && role.key === "admin") {
93
+ throw new Error("Alleen een admin kan de adminrol gebruiken.");
94
+ }
95
+ return role;
57
96
  }
58
97
  export const getUsersWithRoles = createServerFn({ method: "GET" })
59
98
  .middleware([requireSupabaseAuth])
60
99
  .handler(async ({ context }) => {
61
100
  const { isAdmin } = await requireUserAdmin(context);
62
101
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
63
- const [{ data: profiles, error: profilesError }, { data: roles, error: rolesError }, { data: authList, error: authError }] = await Promise.all([
64
- supabaseAdmin
65
- .from("profiles")
66
- .select("id, email, display_name")
67
- .order("email", { ascending: true }),
68
- supabaseAdmin
69
- .from("user_roles")
70
- .select("id, user_id, role")
71
- .order("created_at", { ascending: false }),
102
+ const [{ data: profiles, error: profilesError }, { data: assignments, error: assignmentsError }, { data: roles, error: rolesError }, { data: authList, error: authError },] = await Promise.all([
103
+ supabaseAdmin.from("profiles").select("id, email, display_name").order("email", { ascending: true }),
104
+ supabaseAdmin.from("role_assignments").select("user_id, role_id"),
105
+ supabaseAdmin.from("roles").select("id, key, name, is_protected").order("name", { ascending: true }),
72
106
  supabaseAdmin.auth.admin.listUsers({ page: 1, perPage: 1000 }),
73
107
  ]);
74
108
  if (profilesError)
75
109
  throw profilesError;
110
+ if (assignmentsError)
111
+ throw assignmentsError;
76
112
  if (rolesError)
77
113
  throw rolesError;
78
114
  if (authError)
79
115
  throw authError;
80
116
  const safeProfiles = Array.isArray(profiles) ? profiles : [];
117
+ const safeAssignments = Array.isArray(assignments) ? assignments : [];
81
118
  const safeRoles = Array.isArray(roles) ? roles : [];
82
119
  const safeAuthUsers = Array.isArray(authList?.users) ? authList.users : [];
120
+ const roleById = new Map(safeRoles.map((r) => [r.id, r]));
83
121
  const confirmedById = new Map();
84
122
  const isTestById = new Map();
85
123
  for (const u of safeAuthUsers) {
@@ -88,21 +126,28 @@ export const getUsersWithRoles = createServerFn({ method: "GET" })
88
126
  isTestById.set(u.id, Boolean(meta.is_test_account));
89
127
  }
90
128
  const rolesByUser = new Map();
91
- for (const role of safeRoles) {
92
- const list = rolesByUser.get(role.user_id) ?? [];
93
- list.push(role);
94
- rolesByUser.set(role.user_id, list);
129
+ for (const a of safeAssignments) {
130
+ const role = roleById.get(a.role_id);
131
+ if (!role)
132
+ continue;
133
+ const list = rolesByUser.get(a.user_id) ?? [];
134
+ if (!list.some((r) => r.id === role.id)) {
135
+ list.push({ id: role.id, key: role.key, name: role.name });
136
+ }
137
+ rolesByUser.set(a.user_id, list);
95
138
  }
96
139
  return {
97
140
  users: safeProfiles.map((profile) => ({
98
141
  id: profile.id,
99
142
  email: profile.email ?? "-",
100
143
  displayName: profile.display_name ?? "-",
101
- roles: uniqueRoles((rolesByUser.get(profile.id)?.map((r) => r.role) ?? [])),
144
+ roles: rolesByUser.get(profile.id) ?? [],
102
145
  emailConfirmed: confirmedById.get(profile.id) ?? false,
103
146
  isTestAccount: isTestById.get(profile.id) ?? false,
104
147
  })),
105
- availableRoles: isAdmin ? APP_ROLES : APP_ROLES.filter((r) => r !== "admin"),
148
+ availableRoles: safeRoles
149
+ .filter((r) => isAdmin || r.key !== "admin")
150
+ .map((r) => ({ id: r.id, key: r.key, name: r.name, isProtected: r.is_protected })),
106
151
  isAdmin,
107
152
  };
108
153
  });
@@ -111,53 +156,208 @@ export const assignRole = createServerFn({ method: "POST" })
111
156
  .inputValidator((data) => assignRoleInput.parse(data))
112
157
  .handler(async ({ context, data }) => {
113
158
  const { isAdmin } = await requireUserAdmin(context);
114
- if (!isAdmin && data.role === "admin") {
115
- throw new Error("Alleen een admin kan de adminrol toekennen.");
116
- }
117
- // Schrijft via de user-scoped client: RLS op user_roles is nu de echte gate.
159
+ await assertMayUseRole(context.supabase, data.roleId, isAdmin);
118
160
  const { data: existing, error: checkError } = await context.supabase
119
- .from("user_roles")
161
+ .from("role_assignments")
120
162
  .select("id")
121
163
  .eq("user_id", data.userId)
122
- .eq("role", data.role)
164
+ .eq("role_id", data.roleId)
123
165
  .maybeSingle();
124
166
  if (checkError)
125
167
  throw checkError;
126
- if (existing) {
127
- throw new Error(`Gebruiker heeft de rol '${data.role}' al.`);
128
- }
168
+ if (existing)
169
+ throw new Error("Gebruiker heeft deze rol al.");
129
170
  const { error } = await context.supabase
130
- .from("user_roles")
131
- .insert({ user_id: data.userId, role: data.role });
171
+ .from("role_assignments")
172
+ .insert({ user_id: data.userId, role_id: data.roleId });
132
173
  if (error)
133
174
  throw error;
134
175
  return { ok: true };
135
176
  });
136
177
  export const removeRole = createServerFn({ method: "POST" })
137
178
  .middleware([requireSupabaseAuth])
138
- .inputValidator((data) => removeRoleInput.parse(data))
179
+ .inputValidator((data) => assignRoleInput.parse(data))
139
180
  .handler(async ({ context, data }) => {
140
181
  const { isAdmin } = await requireUserAdmin(context);
141
- if (!isAdmin && data.role === "admin") {
142
- throw new Error("Alleen een admin kan de adminrol verwijderen.");
143
- }
144
- if (data.role === "admin" && data.userId === context.userId) {
182
+ const role = await assertMayUseRole(context.supabase, data.roleId, isAdmin);
183
+ if (role.key === "admin" && data.userId === context.userId) {
145
184
  throw new Error("Je kunt je eigen adminrol niet verwijderen.");
146
185
  }
147
- // Delete via user-scoped client (RLS gate). De DB-trigger
148
- // `prevent_last_admin_delete` is het definitieve vangnet voor de laatste admin.
149
186
  const { error } = await context.supabase
150
- .from("user_roles")
187
+ .from("role_assignments")
151
188
  .delete()
152
189
  .eq("user_id", data.userId)
153
- .eq("role", data.role);
190
+ .eq("role_id", data.roleId);
191
+ if (error)
192
+ throw error;
193
+ return { ok: true };
194
+ });
195
+ /** Alle rollen met hun rechten, plus de geregistreerde modules en onderdelen. */
196
+ export const getRoles = createServerFn({ method: "GET" })
197
+ .middleware([requireSupabaseAuth])
198
+ .handler(async ({ context }) => {
199
+ await requireUserAdmin(context);
200
+ const [{ data: roles, error: rolesError }, { data: perms, error: permsError }, { data: registry, error: registryError }, { data: featureRows, error: featuresError },] = await Promise.all([
201
+ context.supabase.from("roles").select("id, key, name, is_protected").order("name", { ascending: true }),
202
+ context.supabase.from("role_permissions").select("role_id, module_id, feature_id, can_view, can_edit"),
203
+ context.supabase.from("module_registry").select("module_id, name, is_active, enforce").order("name", { ascending: true }),
204
+ context.supabase.from("module_features").select("module_id, feature_id, name, is_active").order("name", { ascending: true }),
205
+ ]);
206
+ if (rolesError)
207
+ throw rolesError;
208
+ if (permsError)
209
+ throw permsError;
210
+ if (registryError)
211
+ throw registryError;
212
+ if (featuresError)
213
+ throw featuresError;
214
+ const safeRoles = Array.isArray(roles) ? roles : [];
215
+ const safePerms = Array.isArray(perms) ? perms : [];
216
+ const safeRegistry = Array.isArray(registry) ? registry : [];
217
+ const safeFeatures = Array.isArray(featureRows) ? featureRows : [];
218
+ return {
219
+ roles: safeRoles.map((r) => ({
220
+ id: r.id,
221
+ key: r.key,
222
+ name: r.name,
223
+ isProtected: r.is_protected,
224
+ })),
225
+ modules: safeRegistry
226
+ .filter((m) => m.is_active)
227
+ .map((m) => ({
228
+ id: m.module_id,
229
+ name: m.name,
230
+ enforce: Boolean(m.enforce),
231
+ features: safeFeatures
232
+ .filter((f) => f.is_active && f.module_id === m.module_id)
233
+ .map((f) => ({ id: f.feature_id, name: f.name })),
234
+ })),
235
+ permissions: safePerms.map((p) => ({
236
+ roleId: p.role_id,
237
+ moduleId: p.module_id,
238
+ featureId: p.feature_id ?? "",
239
+ canView: Boolean(p.can_view),
240
+ canEdit: Boolean(p.can_edit),
241
+ })),
242
+ };
243
+ });
244
+ async function writeRolePermissions(supabase, roleId, permissions) {
245
+ const { error: delError } = await supabase.from("role_permissions").delete().eq("role_id", roleId);
246
+ if (delError)
247
+ throw delError;
248
+ const rows = permissions
249
+ .filter((p) => p.canView || p.canEdit)
250
+ .map((p) => ({
251
+ role_id: roleId,
252
+ module_id: p.moduleId,
253
+ feature_id: p.featureId ?? "",
254
+ can_view: p.canView || p.canEdit,
255
+ can_edit: p.canEdit,
256
+ updated_at: new Date().toISOString(),
257
+ }));
258
+ if (rows.length === 0)
259
+ return;
260
+ const { error } = await supabase.from("role_permissions").insert(rows);
261
+ if (error)
262
+ throw error;
263
+ }
264
+ export const createRole = createServerFn({ method: "POST" })
265
+ .middleware([requireSupabaseAuth])
266
+ .inputValidator((data) => createRoleInput.parse(data))
267
+ .handler(async ({ context, data }) => {
268
+ await requireAdmin(context);
269
+ const baseKey = slugify(data.name);
270
+ let key = baseKey;
271
+ for (let i = 2; i < 50; i++) {
272
+ const { data: taken, error } = await context.supabase
273
+ .from("roles")
274
+ .select("id")
275
+ .eq("key", key)
276
+ .maybeSingle();
277
+ if (error)
278
+ throw error;
279
+ if (!taken)
280
+ break;
281
+ key = `${baseKey}-${i}`;
282
+ }
283
+ const { data: created, error } = await context.supabase
284
+ .from("roles")
285
+ .insert({ key, name: data.name, is_protected: false })
286
+ .select("id")
287
+ .single();
288
+ if (error)
289
+ throw error;
290
+ await writeRolePermissions(context.supabase, created.id, data.permissions);
291
+ return { ok: true, roleId: created.id };
292
+ });
293
+ export const updateRole = createServerFn({ method: "POST" })
294
+ .middleware([requireSupabaseAuth])
295
+ .inputValidator((data) => updateRoleInput.parse(data))
296
+ .handler(async ({ context, data }) => {
297
+ await requireAdmin(context);
298
+ const { error } = await context.supabase
299
+ .from("roles")
300
+ .update({ name: data.name, updated_at: new Date().toISOString() })
301
+ .eq("id", data.roleId);
302
+ if (error)
303
+ throw error;
304
+ await writeRolePermissions(context.supabase, data.roleId, data.permissions);
305
+ return { ok: true };
306
+ });
307
+ export const duplicateRole = createServerFn({ method: "POST" })
308
+ .middleware([requireSupabaseAuth])
309
+ .inputValidator((data) => roleIdInput.parse(data))
310
+ .handler(async ({ context, data }) => {
311
+ await requireAdmin(context);
312
+ const role = await fetchRole(context.supabase, data.roleId);
313
+ const { data: perms, error: permsError } = await context.supabase
314
+ .from("role_permissions")
315
+ .select("module_id, feature_id, can_view, can_edit")
316
+ .eq("role_id", data.roleId);
317
+ if (permsError)
318
+ throw permsError;
319
+ const name = `${role.name} (kopie)`;
320
+ const baseKey = slugify(name);
321
+ let key = baseKey;
322
+ for (let i = 2; i < 50; i++) {
323
+ const { data: taken } = await context.supabase.from("roles").select("id").eq("key", key).maybeSingle();
324
+ if (!taken)
325
+ break;
326
+ key = `${baseKey}-${i}`;
327
+ }
328
+ const { data: created, error } = await context.supabase
329
+ .from("roles")
330
+ .insert({ key, name, is_protected: false })
331
+ .select("id")
332
+ .single();
333
+ if (error)
334
+ throw error;
335
+ const safePerms = Array.isArray(perms) ? perms : [];
336
+ await writeRolePermissions(context.supabase, created.id, safePerms.map((p) => ({
337
+ moduleId: p.module_id,
338
+ featureId: p.feature_id ?? "",
339
+ canView: Boolean(p.can_view),
340
+ canEdit: Boolean(p.can_edit),
341
+ })));
342
+ return { ok: true, roleId: created.id };
343
+ });
344
+ export const deleteRole = createServerFn({ method: "POST" })
345
+ .middleware([requireSupabaseAuth])
346
+ .inputValidator((data) => roleIdInput.parse(data))
347
+ .handler(async ({ context, data }) => {
348
+ await requireAdmin(context);
349
+ const role = await fetchRole(context.supabase, data.roleId);
350
+ if (role.is_protected) {
351
+ throw new Error("Deze rol is beschermd en kan niet verwijderd worden.");
352
+ }
353
+ const { error } = await context.supabase.from("roles").delete().eq("id", data.roleId);
154
354
  if (error)
155
355
  throw error;
156
356
  return { ok: true };
157
357
  });
158
358
  /**
159
- * Registreert de modules die de shell heeft geladen. Modules die niet in de
160
- * lijst voorkomen blijven bestaan maar worden op inactief gezet.
359
+ * Registreert de modules (en hun onderdelen) die de shell heeft geladen.
360
+ * Wat niet meer voorkomt blijft bestaan maar wordt op inactief gezet.
161
361
  */
162
362
  export const syncModuleRegistry = createServerFn({ method: "POST" })
163
363
  .middleware([requireSupabaseAuth])
@@ -177,7 +377,6 @@ export const syncModuleRegistry = createServerFn({ method: "POST" })
177
377
  })), { onConflict: "module_id" });
178
378
  if (error)
179
379
  throw error;
180
- // Modules van deze shell die niet meer geleverd worden → inactief.
181
380
  if (data.shellId) {
182
381
  const ids = modules.map((m) => m.id);
183
382
  const { error: deactivateError } = await context.supabase
@@ -188,117 +387,88 @@ export const syncModuleRegistry = createServerFn({ method: "POST" })
188
387
  if (deactivateError)
189
388
  throw deactivateError;
190
389
  }
390
+ // Onderdelen (subpagina's) synchroniseren.
391
+ const featureRows = modules.flatMap((m) => (m.features ?? []).map((f) => ({
392
+ module_id: m.id,
393
+ feature_id: f.id,
394
+ name: f.name,
395
+ is_active: true,
396
+ last_seen_at: now,
397
+ })));
398
+ if (featureRows.length > 0) {
399
+ const { error: featError } = await context.supabase
400
+ .from("module_features")
401
+ .upsert(featureRows, { onConflict: "module_id,feature_id" });
402
+ if (featError)
403
+ throw featError;
404
+ }
405
+ for (const m of modules) {
406
+ const ids = (m.features ?? []).map((f) => f.id);
407
+ let query = context.supabase
408
+ .from("module_features")
409
+ .update({ is_active: false })
410
+ .eq("module_id", m.id);
411
+ if (ids.length > 0) {
412
+ query = query.not("feature_id", "in", `(${ids.map((i) => `"${i}"`).join(",")})`);
413
+ }
414
+ const { error: deactivateFeatError } = await query;
415
+ if (deactivateFeatError)
416
+ throw deactivateFeatError;
417
+ }
191
418
  return { ok: true, count: modules.length };
192
419
  });
193
- /**
194
- * Volgorde van modules in de zijbalk. Leesbaar voor elke ingelogde gebruiker,
195
- * zodat de shell hem kan toepassen.
196
- */
197
- export const getModuleOrder = createServerFn({ method: "GET" })
198
- .middleware([requireSupabaseAuth])
199
- .handler(async ({ context }) => {
200
- const { data, error } = await context.supabase
201
- .from("module_registry")
202
- .select("module_id, name, sort_order, is_active")
203
- .eq("is_active", true);
204
- if (error)
205
- throw error;
206
- const safe = Array.isArray(data) ? data : [];
207
- // Rijen zonder sort_order blijven `null`: de client bepaalt hun plek op
208
- // basis van de bronvolgorde van de shell (geen alfabetische fallback).
209
- return {
210
- modules: safe
211
- .map((row) => ({
212
- id: row.module_id,
213
- name: row.name,
214
- sortOrder: typeof row.sort_order === "number" ? row.sort_order : null,
215
- }))
216
- .sort((a, b) => {
217
- const ao = a.sortOrder ?? Number.MAX_SAFE_INTEGER;
218
- const bo = b.sortOrder ?? Number.MAX_SAFE_INTEGER;
219
- return ao - bo;
220
- }),
221
- };
420
+ const setModuleEnforceInput = z.object({
421
+ moduleId: z.string().trim().min(1).max(100),
422
+ enforce: z.boolean(),
222
423
  });
223
- /** Slaat de volgorde op: index in de aangeleverde lijst wordt sort_order. */
224
- export const setModuleOrder = createServerFn({ method: "POST" })
424
+ export const setModuleEnforce = createServerFn({ method: "POST" })
225
425
  .middleware([requireSupabaseAuth])
226
- .inputValidator((data) => moduleOrderInput.parse(data))
426
+ .inputValidator((data) => setModuleEnforceInput.parse(data))
227
427
  .handler(async ({ context, data }) => {
228
428
  await requireAdmin(context);
229
- const ids = Array.isArray(data.moduleIds) ? data.moduleIds : [];
230
- for (let i = 0; i < ids.length; i++) {
231
- const { error } = await context.supabase
232
- .from("module_registry")
233
- .update({ sort_order: i })
234
- .eq("module_id", ids[i]);
235
- if (error)
236
- throw error;
237
- }
238
- return { ok: true, count: ids.length };
239
- });
240
- export const getModulePermissions = createServerFn({ method: "GET" })
241
- .middleware([requireSupabaseAuth])
242
- .handler(async ({ context }) => {
243
- await requireAdmin(context);
244
- const [{ data, error }, { data: registry, error: registryError }] = await Promise.all([
245
- context.supabase.from("module_permissions").select("role, module_id, permission"),
246
- context.supabase
247
- .from("module_registry")
248
- .select("module_id, name, is_active")
249
- .order("name", { ascending: true }),
250
- ]);
429
+ const { error } = await context.supabase
430
+ .from("module_registry")
431
+ .update({ enforce: data.enforce })
432
+ .eq("module_id", data.moduleId);
251
433
  if (error)
252
434
  throw error;
253
- if (registryError)
254
- throw registryError;
255
- const safe = Array.isArray(data) ? data : [];
256
- const safeRegistry = Array.isArray(registry) ? registry : [];
257
- return {
258
- roles: APP_ROLES,
259
- modules: safeRegistry
260
- .filter((row) => row.is_active)
261
- .map((row) => ({
262
- id: row.module_id,
263
- name: row.name,
264
- })),
265
- permissions: safe.map((row) => ({
266
- role: row.role,
267
- moduleId: row.module_id,
268
- permission: row.permission,
269
- })),
270
- };
435
+ return { ok: true };
271
436
  });
272
437
  export const getUserModulePermissions = createServerFn({ method: "POST" })
273
438
  .middleware([requireSupabaseAuth])
274
439
  .inputValidator((data) => userPermissionsInput.parse(data))
275
440
  .handler(async ({ context, data }) => {
276
441
  await requireUserAdmin(context);
277
- const [{ data: overrides, error }, { data: registry, error: registryError }] = await Promise.all([
442
+ const [{ data: overrides, error }, { data: registry, error: registryError }, { data: featureRows, error: featuresError },] = await Promise.all([
278
443
  context.supabase
279
444
  .from("user_module_permissions")
280
- .select("module_id, permission")
445
+ .select("module_id, feature_id, permission")
281
446
  .eq("user_id", data.userId),
282
- context.supabase
283
- .from("module_registry")
284
- .select("module_id, name, is_active")
285
- .order("name", { ascending: true }),
447
+ context.supabase.from("module_registry").select("module_id, name, is_active").order("name", { ascending: true }),
448
+ context.supabase.from("module_features").select("module_id, feature_id, name, is_active").order("name", { ascending: true }),
286
449
  ]);
287
450
  if (error)
288
451
  throw error;
289
452
  if (registryError)
290
453
  throw registryError;
454
+ if (featuresError)
455
+ throw featuresError;
291
456
  const safeOverrides = Array.isArray(overrides) ? overrides : [];
292
457
  const safeRegistry = Array.isArray(registry) ? registry : [];
458
+ const safeFeatures = Array.isArray(featureRows) ? featureRows : [];
293
459
  return {
294
460
  modules: safeRegistry
295
461
  .filter((row) => row.is_active)
296
462
  .map((row) => ({
297
463
  id: row.module_id,
298
464
  name: row.name,
465
+ features: safeFeatures
466
+ .filter((f) => f.is_active && f.module_id === row.module_id)
467
+ .map((f) => ({ id: f.feature_id, name: f.name })),
299
468
  })),
300
469
  overrides: safeOverrides.map((row) => ({
301
470
  moduleId: row.module_id,
471
+ featureId: row.feature_id ?? "",
302
472
  permission: row.permission,
303
473
  })),
304
474
  };
@@ -308,12 +478,14 @@ export const setUserModulePermission = createServerFn({ method: "POST" })
308
478
  .inputValidator((data) => setUserPermissionInput.parse(data))
309
479
  .handler(async ({ context, data }) => {
310
480
  await requireUserAdmin(context);
481
+ const featureId = data.featureId ?? "";
311
482
  if (data.permission === "inherit") {
312
483
  const { error } = await context.supabase
313
484
  .from("user_module_permissions")
314
485
  .delete()
315
486
  .eq("user_id", data.userId)
316
- .eq("module_id", data.moduleId);
487
+ .eq("module_id", data.moduleId)
488
+ .eq("feature_id", featureId);
317
489
  if (error)
318
490
  throw error;
319
491
  return { ok: true };
@@ -321,27 +493,10 @@ export const setUserModulePermission = createServerFn({ method: "POST" })
321
493
  const { error } = await context.supabase.from("user_module_permissions").upsert({
322
494
  user_id: data.userId,
323
495
  module_id: data.moduleId,
496
+ feature_id: featureId,
324
497
  permission: data.permission,
325
498
  updated_at: new Date().toISOString(),
326
- }, { onConflict: "user_id,module_id" });
327
- if (error)
328
- throw error;
329
- return { ok: true };
330
- });
331
- export const setModulePermission = createServerFn({ method: "POST" })
332
- .middleware([requireSupabaseAuth])
333
- .inputValidator((data) => setPermissionInput.parse(data))
334
- .handler(async ({ context, data }) => {
335
- await requireAdmin(context);
336
- // Upsert via user-scoped client — RLS write-policy op module_permissions
337
- // vereist admin (via JWT-claim of has_role-fallback).
338
- const { error } = await context.supabase
339
- .from("module_permissions")
340
- .upsert({
341
- role: data.role,
342
- module_id: data.moduleId,
343
- permission: data.permission,
344
- }, { onConflict: "role,module_id" });
499
+ }, { onConflict: "user_id,module_id,feature_id" });
345
500
  if (error)
346
501
  throw error;
347
502
  return { ok: true };
@@ -351,9 +506,7 @@ export const inviteUser = createServerFn({ method: "POST" })
351
506
  .inputValidator((data) => inviteUserInput.parse(data))
352
507
  .handler(async ({ context, data }) => {
353
508
  const { isAdmin } = await requireUserAdmin(context);
354
- if (!isAdmin && data.role === "admin") {
355
- throw new Error("Alleen een admin kan een account met de adminrol aanmaken.");
356
- }
509
+ await assertMayUseRole(context.supabase, data.roleId, isAdmin);
357
510
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
358
511
  let redirectTo;
359
512
  try {
@@ -366,10 +519,7 @@ export const inviteUser = createServerFn({ method: "POST" })
366
519
  catch {
367
520
  redirectTo = undefined;
368
521
  }
369
- const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(data.email, {
370
- data: { display_name: data.displayName },
371
- redirectTo,
372
- });
522
+ const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(data.email, { data: { display_name: data.displayName }, redirectTo });
373
523
  if (inviteError || !invited?.user) {
374
524
  const msg = inviteError?.message ?? "Uitnodigen mislukt";
375
525
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
@@ -381,8 +531,8 @@ export const inviteUser = createServerFn({ method: "POST" })
381
531
  if (profileError)
382
532
  throw profileError;
383
533
  const { error: roleError } = await supabaseAdmin
384
- .from("user_roles")
385
- .upsert({ user_id: userId, role: data.role }, { onConflict: "user_id,role", ignoreDuplicates: true });
534
+ .from("role_assignments")
535
+ .upsert({ user_id: userId, role_id: data.roleId }, { onConflict: "user_id,role_id", ignoreDuplicates: true });
386
536
  if (roleError)
387
537
  throw roleError;
388
538
  return { ok: true, userId };
@@ -397,13 +547,13 @@ export const deleteUser = createServerFn({ method: "POST" })
397
547
  }
398
548
  if (!isAdmin) {
399
549
  const { data: targetRoles, error: targetError } = await context.supabase
400
- .from("user_roles")
401
- .select("role")
550
+ .from("role_assignments")
551
+ .select("roles(key)")
402
552
  .eq("user_id", data.userId);
403
553
  if (targetError)
404
554
  throw targetError;
405
555
  const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
406
- if (safeTargetRoles.some((r) => r.role === "admin")) {
556
+ if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
407
557
  throw new Error("Alleen een admin kan een adminaccount verwijderen.");
408
558
  }
409
559
  }
@@ -413,6 +563,37 @@ export const deleteUser = createServerFn({ method: "POST" })
413
563
  throw error;
414
564
  return { ok: true };
415
565
  });
566
+ export const updateUserName = createServerFn({ method: "POST" })
567
+ .middleware([requireSupabaseAuth])
568
+ .inputValidator((data) => updateUserNameInput.parse(data))
569
+ .handler(async ({ context, data }) => {
570
+ const { isAdmin } = await requireUserAdmin(context);
571
+ if (!isAdmin && data.userId !== context.userId) {
572
+ const { data: targetRoles, error: targetError } = await context.supabase
573
+ .from("role_assignments")
574
+ .select("roles(key)")
575
+ .eq("user_id", data.userId);
576
+ if (targetError)
577
+ throw targetError;
578
+ const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
579
+ if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
580
+ throw new Error("Alleen een admin kan een adminaccount aanpassen.");
581
+ }
582
+ }
583
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
584
+ const { error: authError } = await supabaseAdmin.auth.admin.updateUserById(data.userId, {
585
+ user_metadata: { display_name: data.displayName },
586
+ });
587
+ if (authError)
588
+ throw authError;
589
+ const { error } = await supabaseAdmin
590
+ .from("profiles")
591
+ .update({ display_name: data.displayName })
592
+ .eq("id", data.userId);
593
+ if (error)
594
+ throw error;
595
+ return { ok: true };
596
+ });
416
597
  function generatePassword(length = 16) {
417
598
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*";
418
599
  const bytes = new Uint8Array(length);
@@ -427,12 +608,9 @@ export const createTestUser = createServerFn({ method: "POST" })
427
608
  .inputValidator((data) => createTestUserInput.parse(data))
428
609
  .handler(async ({ context, data }) => {
429
610
  const { isAdmin } = await requireUserAdmin(context);
430
- if (!isAdmin && data.role === "admin") {
431
- throw new Error("Alleen een admin kan een account met de adminrol aanmaken.");
432
- }
611
+ await assertMayUseRole(context.supabase, data.roleId, isAdmin);
433
612
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
434
- const email = data.email?.trim() ||
435
- `test-${Date.now()}-${Math.floor(Math.random() * 1000)}@test.local`;
613
+ const email = data.email?.trim() || `test-${Date.now()}-${Math.floor(Math.random() * 1000)}@test.local`;
436
614
  const password = generatePassword(16);
437
615
  const { data: created, error: createError } = await supabaseAdmin.auth.admin.createUser({
438
616
  email,
@@ -445,15 +623,14 @@ export const createTestUser = createServerFn({ method: "POST" })
445
623
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
446
624
  }
447
625
  const userId = created.user.id;
448
- // Trigger vult profiles al; toch upsert voor zekerheid (idempotent).
449
626
  const { error: profileError } = await supabaseAdmin
450
627
  .from("profiles")
451
628
  .upsert({ id: userId, email, display_name: data.displayName }, { onConflict: "id" });
452
629
  if (profileError)
453
630
  throw profileError;
454
631
  const { error: roleError } = await supabaseAdmin
455
- .from("user_roles")
456
- .upsert({ user_id: userId, role: data.role }, { onConflict: "user_id,role", ignoreDuplicates: true });
632
+ .from("role_assignments")
633
+ .upsert({ user_id: userId, role_id: data.roleId }, { onConflict: "user_id,role_id", ignoreDuplicates: true });
457
634
  if (roleError)
458
635
  throw roleError;
459
636
  return { ok: true, userId, email, password };