@flowselections/admin-installatietechniek 1.0.3 → 1.0.4

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 (27) 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 +212 -197
  4. package/dist-lib/hooks/useModulePermission.d.ts +4 -3
  5. package/dist-lib/hooks/useModulePermission.d.ts.map +1 -1
  6. package/dist-lib/hooks/useModulePermission.js +10 -6
  7. package/dist-lib/index.d.ts +1 -3
  8. package/dist-lib/index.d.ts.map +1 -1
  9. package/dist-lib/index.js +0 -1
  10. package/dist-lib/integrations/supabase/auth-middleware.d.ts +407 -0
  11. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  12. package/dist-lib/integrations/supabase/client.d.ts +407 -0
  13. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  14. package/dist-lib/integrations/supabase/client.server.d.ts +407 -0
  15. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  16. package/dist-lib/integrations/supabase/types.d.ts +411 -0
  17. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  18. package/dist-lib/lib/admin.functions.d.ts +48897 -1136
  19. package/dist-lib/lib/admin.functions.d.ts.map +1 -1
  20. package/dist-lib/lib/admin.functions.js +340 -181
  21. package/dist-lib/lib/moduleDiscovery.d.ts +7 -2
  22. package/dist-lib/lib/moduleDiscovery.d.ts.map +1 -1
  23. package/dist-lib/lib/moduleDiscovery.js +26 -11
  24. package/package.json +87 -87
  25. package/dist-lib/hooks/useModuleOrder.d.ts +0 -30
  26. package/dist-lib/hooks/useModuleOrder.d.ts.map +0 -1
  27. 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,207 @@ 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").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
+ features: safeFeatures
231
+ .filter((f) => f.is_active && f.module_id === m.module_id)
232
+ .map((f) => ({ id: f.feature_id, name: f.name })),
233
+ })),
234
+ permissions: safePerms.map((p) => ({
235
+ roleId: p.role_id,
236
+ moduleId: p.module_id,
237
+ featureId: p.feature_id ?? "",
238
+ canView: Boolean(p.can_view),
239
+ canEdit: Boolean(p.can_edit),
240
+ })),
241
+ };
242
+ });
243
+ async function writeRolePermissions(supabase, roleId, permissions) {
244
+ const { error: delError } = await supabase.from("role_permissions").delete().eq("role_id", roleId);
245
+ if (delError)
246
+ throw delError;
247
+ const rows = permissions
248
+ .filter((p) => p.canView || p.canEdit)
249
+ .map((p) => ({
250
+ role_id: roleId,
251
+ module_id: p.moduleId,
252
+ feature_id: p.featureId ?? "",
253
+ can_view: p.canView || p.canEdit,
254
+ can_edit: p.canEdit,
255
+ updated_at: new Date().toISOString(),
256
+ }));
257
+ if (rows.length === 0)
258
+ return;
259
+ const { error } = await supabase.from("role_permissions").insert(rows);
260
+ if (error)
261
+ throw error;
262
+ }
263
+ export const createRole = createServerFn({ method: "POST" })
264
+ .middleware([requireSupabaseAuth])
265
+ .inputValidator((data) => createRoleInput.parse(data))
266
+ .handler(async ({ context, data }) => {
267
+ await requireAdmin(context);
268
+ const baseKey = slugify(data.name);
269
+ let key = baseKey;
270
+ for (let i = 2; i < 50; i++) {
271
+ const { data: taken, error } = await context.supabase
272
+ .from("roles")
273
+ .select("id")
274
+ .eq("key", key)
275
+ .maybeSingle();
276
+ if (error)
277
+ throw error;
278
+ if (!taken)
279
+ break;
280
+ key = `${baseKey}-${i}`;
281
+ }
282
+ const { data: created, error } = await context.supabase
283
+ .from("roles")
284
+ .insert({ key, name: data.name, is_protected: false })
285
+ .select("id")
286
+ .single();
287
+ if (error)
288
+ throw error;
289
+ await writeRolePermissions(context.supabase, created.id, data.permissions);
290
+ return { ok: true, roleId: created.id };
291
+ });
292
+ export const updateRole = createServerFn({ method: "POST" })
293
+ .middleware([requireSupabaseAuth])
294
+ .inputValidator((data) => updateRoleInput.parse(data))
295
+ .handler(async ({ context, data }) => {
296
+ await requireAdmin(context);
297
+ const { error } = await context.supabase
298
+ .from("roles")
299
+ .update({ name: data.name, updated_at: new Date().toISOString() })
300
+ .eq("id", data.roleId);
301
+ if (error)
302
+ throw error;
303
+ await writeRolePermissions(context.supabase, data.roleId, data.permissions);
304
+ return { ok: true };
305
+ });
306
+ export const duplicateRole = createServerFn({ method: "POST" })
307
+ .middleware([requireSupabaseAuth])
308
+ .inputValidator((data) => roleIdInput.parse(data))
309
+ .handler(async ({ context, data }) => {
310
+ await requireAdmin(context);
311
+ const role = await fetchRole(context.supabase, data.roleId);
312
+ const { data: perms, error: permsError } = await context.supabase
313
+ .from("role_permissions")
314
+ .select("module_id, feature_id, can_view, can_edit")
315
+ .eq("role_id", data.roleId);
316
+ if (permsError)
317
+ throw permsError;
318
+ const name = `${role.name} (kopie)`;
319
+ const baseKey = slugify(name);
320
+ let key = baseKey;
321
+ for (let i = 2; i < 50; i++) {
322
+ const { data: taken } = await context.supabase.from("roles").select("id").eq("key", key).maybeSingle();
323
+ if (!taken)
324
+ break;
325
+ key = `${baseKey}-${i}`;
326
+ }
327
+ const { data: created, error } = await context.supabase
328
+ .from("roles")
329
+ .insert({ key, name, is_protected: false })
330
+ .select("id")
331
+ .single();
332
+ if (error)
333
+ throw error;
334
+ const safePerms = Array.isArray(perms) ? perms : [];
335
+ await writeRolePermissions(context.supabase, created.id, safePerms.map((p) => ({
336
+ moduleId: p.module_id,
337
+ featureId: p.feature_id ?? "",
338
+ canView: Boolean(p.can_view),
339
+ canEdit: Boolean(p.can_edit),
340
+ })));
341
+ return { ok: true, roleId: created.id };
342
+ });
343
+ export const deleteRole = createServerFn({ method: "POST" })
344
+ .middleware([requireSupabaseAuth])
345
+ .inputValidator((data) => roleIdInput.parse(data))
346
+ .handler(async ({ context, data }) => {
347
+ await requireAdmin(context);
348
+ const role = await fetchRole(context.supabase, data.roleId);
349
+ if (role.is_protected) {
350
+ throw new Error("Deze rol is beschermd en kan niet verwijderd worden.");
351
+ }
352
+ const { error } = await context.supabase.from("roles").delete().eq("id", data.roleId);
154
353
  if (error)
155
354
  throw error;
156
355
  return { ok: true };
157
356
  });
158
357
  /**
159
- * Registreert de modules die de shell heeft geladen. Modules die niet in de
160
- * lijst voorkomen blijven bestaan maar worden op inactief gezet.
358
+ * Registreert de modules (en hun onderdelen) die de shell heeft geladen.
359
+ * Wat niet meer voorkomt blijft bestaan maar wordt op inactief gezet.
161
360
  */
162
361
  export const syncModuleRegistry = createServerFn({ method: "POST" })
163
362
  .middleware([requireSupabaseAuth])
@@ -177,7 +376,6 @@ export const syncModuleRegistry = createServerFn({ method: "POST" })
177
376
  })), { onConflict: "module_id" });
178
377
  if (error)
179
378
  throw error;
180
- // Modules van deze shell die niet meer geleverd worden → inactief.
181
379
  if (data.shellId) {
182
380
  const ids = modules.map((m) => m.id);
183
381
  const { error: deactivateError } = await context.supabase
@@ -188,117 +386,71 @@ export const syncModuleRegistry = createServerFn({ method: "POST" })
188
386
  if (deactivateError)
189
387
  throw deactivateError;
190
388
  }
191
- return { ok: true, count: modules.length };
192
- });
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
- };
222
- });
223
- /** Slaat de volgorde op: index in de aangeleverde lijst wordt sort_order. */
224
- export const setModuleOrder = createServerFn({ method: "POST" })
225
- .middleware([requireSupabaseAuth])
226
- .inputValidator((data) => moduleOrderInput.parse(data))
227
- .handler(async ({ context, data }) => {
228
- 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;
389
+ // Onderdelen (subpagina's) synchroniseren.
390
+ const featureRows = modules.flatMap((m) => (m.features ?? []).map((f) => ({
391
+ module_id: m.id,
392
+ feature_id: f.id,
393
+ name: f.name,
394
+ is_active: true,
395
+ last_seen_at: now,
396
+ })));
397
+ if (featureRows.length > 0) {
398
+ const { error: featError } = await context.supabase
399
+ .from("module_features")
400
+ .upsert(featureRows, { onConflict: "module_id,feature_id" });
401
+ if (featError)
402
+ throw featError;
237
403
  }
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
- ]);
251
- if (error)
252
- 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
- };
404
+ for (const m of modules) {
405
+ const ids = (m.features ?? []).map((f) => f.id);
406
+ let query = context.supabase
407
+ .from("module_features")
408
+ .update({ is_active: false })
409
+ .eq("module_id", m.id);
410
+ if (ids.length > 0) {
411
+ query = query.not("feature_id", "in", `(${ids.map((i) => `"${i}"`).join(",")})`);
412
+ }
413
+ const { error: deactivateFeatError } = await query;
414
+ if (deactivateFeatError)
415
+ throw deactivateFeatError;
416
+ }
417
+ return { ok: true, count: modules.length };
271
418
  });
272
419
  export const getUserModulePermissions = createServerFn({ method: "POST" })
273
420
  .middleware([requireSupabaseAuth])
274
421
  .inputValidator((data) => userPermissionsInput.parse(data))
275
422
  .handler(async ({ context, data }) => {
276
423
  await requireUserAdmin(context);
277
- const [{ data: overrides, error }, { data: registry, error: registryError }] = await Promise.all([
424
+ const [{ data: overrides, error }, { data: registry, error: registryError }, { data: featureRows, error: featuresError },] = await Promise.all([
278
425
  context.supabase
279
426
  .from("user_module_permissions")
280
- .select("module_id, permission")
427
+ .select("module_id, feature_id, permission")
281
428
  .eq("user_id", data.userId),
282
- context.supabase
283
- .from("module_registry")
284
- .select("module_id, name, is_active")
285
- .order("name", { ascending: true }),
429
+ context.supabase.from("module_registry").select("module_id, name, is_active").order("name", { ascending: true }),
430
+ context.supabase.from("module_features").select("module_id, feature_id, name, is_active").order("name", { ascending: true }),
286
431
  ]);
287
432
  if (error)
288
433
  throw error;
289
434
  if (registryError)
290
435
  throw registryError;
436
+ if (featuresError)
437
+ throw featuresError;
291
438
  const safeOverrides = Array.isArray(overrides) ? overrides : [];
292
439
  const safeRegistry = Array.isArray(registry) ? registry : [];
440
+ const safeFeatures = Array.isArray(featureRows) ? featureRows : [];
293
441
  return {
294
442
  modules: safeRegistry
295
443
  .filter((row) => row.is_active)
296
444
  .map((row) => ({
297
445
  id: row.module_id,
298
446
  name: row.name,
447
+ features: safeFeatures
448
+ .filter((f) => f.is_active && f.module_id === row.module_id)
449
+ .map((f) => ({ id: f.feature_id, name: f.name })),
299
450
  })),
300
451
  overrides: safeOverrides.map((row) => ({
301
452
  moduleId: row.module_id,
453
+ featureId: row.feature_id ?? "",
302
454
  permission: row.permission,
303
455
  })),
304
456
  };
@@ -308,12 +460,14 @@ export const setUserModulePermission = createServerFn({ method: "POST" })
308
460
  .inputValidator((data) => setUserPermissionInput.parse(data))
309
461
  .handler(async ({ context, data }) => {
310
462
  await requireUserAdmin(context);
463
+ const featureId = data.featureId ?? "";
311
464
  if (data.permission === "inherit") {
312
465
  const { error } = await context.supabase
313
466
  .from("user_module_permissions")
314
467
  .delete()
315
468
  .eq("user_id", data.userId)
316
- .eq("module_id", data.moduleId);
469
+ .eq("module_id", data.moduleId)
470
+ .eq("feature_id", featureId);
317
471
  if (error)
318
472
  throw error;
319
473
  return { ok: true };
@@ -321,27 +475,10 @@ export const setUserModulePermission = createServerFn({ method: "POST" })
321
475
  const { error } = await context.supabase.from("user_module_permissions").upsert({
322
476
  user_id: data.userId,
323
477
  module_id: data.moduleId,
478
+ feature_id: featureId,
324
479
  permission: data.permission,
325
480
  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" });
481
+ }, { onConflict: "user_id,module_id,feature_id" });
345
482
  if (error)
346
483
  throw error;
347
484
  return { ok: true };
@@ -351,9 +488,7 @@ export const inviteUser = createServerFn({ method: "POST" })
351
488
  .inputValidator((data) => inviteUserInput.parse(data))
352
489
  .handler(async ({ context, data }) => {
353
490
  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
- }
491
+ await assertMayUseRole(context.supabase, data.roleId, isAdmin);
357
492
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
358
493
  let redirectTo;
359
494
  try {
@@ -366,10 +501,7 @@ export const inviteUser = createServerFn({ method: "POST" })
366
501
  catch {
367
502
  redirectTo = undefined;
368
503
  }
369
- const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(data.email, {
370
- data: { display_name: data.displayName },
371
- redirectTo,
372
- });
504
+ const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(data.email, { data: { display_name: data.displayName }, redirectTo });
373
505
  if (inviteError || !invited?.user) {
374
506
  const msg = inviteError?.message ?? "Uitnodigen mislukt";
375
507
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
@@ -381,8 +513,8 @@ export const inviteUser = createServerFn({ method: "POST" })
381
513
  if (profileError)
382
514
  throw profileError;
383
515
  const { error: roleError } = await supabaseAdmin
384
- .from("user_roles")
385
- .upsert({ user_id: userId, role: data.role }, { onConflict: "user_id,role", ignoreDuplicates: true });
516
+ .from("role_assignments")
517
+ .upsert({ user_id: userId, role_id: data.roleId }, { onConflict: "user_id,role_id", ignoreDuplicates: true });
386
518
  if (roleError)
387
519
  throw roleError;
388
520
  return { ok: true, userId };
@@ -397,13 +529,13 @@ export const deleteUser = createServerFn({ method: "POST" })
397
529
  }
398
530
  if (!isAdmin) {
399
531
  const { data: targetRoles, error: targetError } = await context.supabase
400
- .from("user_roles")
401
- .select("role")
532
+ .from("role_assignments")
533
+ .select("roles(key)")
402
534
  .eq("user_id", data.userId);
403
535
  if (targetError)
404
536
  throw targetError;
405
537
  const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
406
- if (safeTargetRoles.some((r) => r.role === "admin")) {
538
+ if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
407
539
  throw new Error("Alleen een admin kan een adminaccount verwijderen.");
408
540
  }
409
541
  }
@@ -413,6 +545,37 @@ export const deleteUser = createServerFn({ method: "POST" })
413
545
  throw error;
414
546
  return { ok: true };
415
547
  });
548
+ export const updateUserName = createServerFn({ method: "POST" })
549
+ .middleware([requireSupabaseAuth])
550
+ .inputValidator((data) => updateUserNameInput.parse(data))
551
+ .handler(async ({ context, data }) => {
552
+ const { isAdmin } = await requireUserAdmin(context);
553
+ if (!isAdmin && data.userId !== context.userId) {
554
+ const { data: targetRoles, error: targetError } = await context.supabase
555
+ .from("role_assignments")
556
+ .select("roles(key)")
557
+ .eq("user_id", data.userId);
558
+ if (targetError)
559
+ throw targetError;
560
+ const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
561
+ if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
562
+ throw new Error("Alleen een admin kan een adminaccount aanpassen.");
563
+ }
564
+ }
565
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
566
+ const { error: authError } = await supabaseAdmin.auth.admin.updateUserById(data.userId, {
567
+ user_metadata: { display_name: data.displayName },
568
+ });
569
+ if (authError)
570
+ throw authError;
571
+ const { error } = await supabaseAdmin
572
+ .from("profiles")
573
+ .update({ display_name: data.displayName })
574
+ .eq("id", data.userId);
575
+ if (error)
576
+ throw error;
577
+ return { ok: true };
578
+ });
416
579
  function generatePassword(length = 16) {
417
580
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*";
418
581
  const bytes = new Uint8Array(length);
@@ -427,12 +590,9 @@ export const createTestUser = createServerFn({ method: "POST" })
427
590
  .inputValidator((data) => createTestUserInput.parse(data))
428
591
  .handler(async ({ context, data }) => {
429
592
  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
- }
593
+ await assertMayUseRole(context.supabase, data.roleId, isAdmin);
433
594
  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`;
595
+ const email = data.email?.trim() || `test-${Date.now()}-${Math.floor(Math.random() * 1000)}@test.local`;
436
596
  const password = generatePassword(16);
437
597
  const { data: created, error: createError } = await supabaseAdmin.auth.admin.createUser({
438
598
  email,
@@ -445,15 +605,14 @@ export const createTestUser = createServerFn({ method: "POST" })
445
605
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
446
606
  }
447
607
  const userId = created.user.id;
448
- // Trigger vult profiles al; toch upsert voor zekerheid (idempotent).
449
608
  const { error: profileError } = await supabaseAdmin
450
609
  .from("profiles")
451
610
  .upsert({ id: userId, email, display_name: data.displayName }, { onConflict: "id" });
452
611
  if (profileError)
453
612
  throw profileError;
454
613
  const { error: roleError } = await supabaseAdmin
455
- .from("user_roles")
456
- .upsert({ user_id: userId, role: data.role }, { onConflict: "user_id,role", ignoreDuplicates: true });
614
+ .from("role_assignments")
615
+ .upsert({ user_id: userId, role_id: data.roleId }, { onConflict: "user_id,role_id", ignoreDuplicates: true });
457
616
  if (roleError)
458
617
  throw roleError;
459
618
  return { ok: true, userId, email, password };