@flowselections/admin-installatietechniek 1.0.2 → 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.
@@ -3,80 +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
  });
30
+ const roleIdInput = z.object({ roleId: z.string().uuid() });
26
31
  const assignRoleInput = z.object({
27
32
  userId: z.string().uuid(),
28
- role: roleSchema,
33
+ roleId: z.string().uuid(),
29
34
  });
30
- const removeRoleInput = z.object({
31
- userId: z.string().uuid(),
32
- 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([]),
33
46
  });
34
- const setPermissionInput = z.object({
35
- role: roleSchema,
36
- moduleId: z.string().min(1),
37
- 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([]),
38
51
  });
39
52
  const inviteUserInput = z.object({
40
53
  email: z.string().trim().email().max(255),
41
54
  displayName: z.string().trim().min(1).max(100),
42
- role: roleSchema,
55
+ roleId: z.string().uuid(),
43
56
  });
44
57
  const deleteUserInput = z.object({
45
58
  userId: z.string().uuid(),
46
59
  });
60
+ const updateUserNameInput = z.object({
61
+ userId: z.string().uuid(),
62
+ displayName: z.string().trim().min(1).max(100),
63
+ });
47
64
  const createTestUserInput = z.object({
48
65
  displayName: z.string().trim().min(1).max(100),
49
- role: roleSchema,
66
+ roleId: z.string().uuid(),
50
67
  email: z.string().trim().email().max(255).optional(),
51
68
  });
52
- function uniqueRoles(roles) {
53
- 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;
54
96
  }
55
97
  export const getUsersWithRoles = createServerFn({ method: "GET" })
56
98
  .middleware([requireSupabaseAuth])
57
99
  .handler(async ({ context }) => {
58
100
  const { isAdmin } = await requireUserAdmin(context);
59
101
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
60
- const [{ data: profiles, error: profilesError }, { data: roles, error: rolesError }, { data: authList, error: authError }] = await Promise.all([
61
- supabaseAdmin
62
- .from("profiles")
63
- .select("id, email, display_name")
64
- .order("email", { ascending: true }),
65
- supabaseAdmin
66
- .from("user_roles")
67
- .select("id, user_id, role")
68
- .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 }),
69
106
  supabaseAdmin.auth.admin.listUsers({ page: 1, perPage: 1000 }),
70
107
  ]);
71
108
  if (profilesError)
72
109
  throw profilesError;
110
+ if (assignmentsError)
111
+ throw assignmentsError;
73
112
  if (rolesError)
74
113
  throw rolesError;
75
114
  if (authError)
76
115
  throw authError;
77
116
  const safeProfiles = Array.isArray(profiles) ? profiles : [];
117
+ const safeAssignments = Array.isArray(assignments) ? assignments : [];
78
118
  const safeRoles = Array.isArray(roles) ? roles : [];
79
119
  const safeAuthUsers = Array.isArray(authList?.users) ? authList.users : [];
120
+ const roleById = new Map(safeRoles.map((r) => [r.id, r]));
80
121
  const confirmedById = new Map();
81
122
  const isTestById = new Map();
82
123
  for (const u of safeAuthUsers) {
@@ -85,21 +126,28 @@ export const getUsersWithRoles = createServerFn({ method: "GET" })
85
126
  isTestById.set(u.id, Boolean(meta.is_test_account));
86
127
  }
87
128
  const rolesByUser = new Map();
88
- for (const role of safeRoles) {
89
- const list = rolesByUser.get(role.user_id) ?? [];
90
- list.push(role);
91
- 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);
92
138
  }
93
139
  return {
94
140
  users: safeProfiles.map((profile) => ({
95
141
  id: profile.id,
96
142
  email: profile.email ?? "-",
97
143
  displayName: profile.display_name ?? "-",
98
- roles: uniqueRoles((rolesByUser.get(profile.id)?.map((r) => r.role) ?? [])),
144
+ roles: rolesByUser.get(profile.id) ?? [],
99
145
  emailConfirmed: confirmedById.get(profile.id) ?? false,
100
146
  isTestAccount: isTestById.get(profile.id) ?? false,
101
147
  })),
102
- 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 })),
103
151
  isAdmin,
104
152
  };
105
153
  });
@@ -108,53 +156,207 @@ export const assignRole = createServerFn({ method: "POST" })
108
156
  .inputValidator((data) => assignRoleInput.parse(data))
109
157
  .handler(async ({ context, data }) => {
110
158
  const { isAdmin } = await requireUserAdmin(context);
111
- if (!isAdmin && data.role === "admin") {
112
- throw new Error("Alleen een admin kan de adminrol toekennen.");
113
- }
114
- // Schrijft via de user-scoped client: RLS op user_roles is nu de echte gate.
159
+ await assertMayUseRole(context.supabase, data.roleId, isAdmin);
115
160
  const { data: existing, error: checkError } = await context.supabase
116
- .from("user_roles")
161
+ .from("role_assignments")
117
162
  .select("id")
118
163
  .eq("user_id", data.userId)
119
- .eq("role", data.role)
164
+ .eq("role_id", data.roleId)
120
165
  .maybeSingle();
121
166
  if (checkError)
122
167
  throw checkError;
123
- if (existing) {
124
- throw new Error(`Gebruiker heeft de rol '${data.role}' al.`);
125
- }
168
+ if (existing)
169
+ throw new Error("Gebruiker heeft deze rol al.");
126
170
  const { error } = await context.supabase
127
- .from("user_roles")
128
- .insert({ user_id: data.userId, role: data.role });
171
+ .from("role_assignments")
172
+ .insert({ user_id: data.userId, role_id: data.roleId });
129
173
  if (error)
130
174
  throw error;
131
175
  return { ok: true };
132
176
  });
133
177
  export const removeRole = createServerFn({ method: "POST" })
134
178
  .middleware([requireSupabaseAuth])
135
- .inputValidator((data) => removeRoleInput.parse(data))
179
+ .inputValidator((data) => assignRoleInput.parse(data))
136
180
  .handler(async ({ context, data }) => {
137
181
  const { isAdmin } = await requireUserAdmin(context);
138
- if (!isAdmin && data.role === "admin") {
139
- throw new Error("Alleen een admin kan de adminrol verwijderen.");
140
- }
141
- 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) {
142
184
  throw new Error("Je kunt je eigen adminrol niet verwijderen.");
143
185
  }
144
- // Delete via user-scoped client (RLS gate). De DB-trigger
145
- // `prevent_last_admin_delete` is het definitieve vangnet voor de laatste admin.
146
186
  const { error } = await context.supabase
147
- .from("user_roles")
187
+ .from("role_assignments")
148
188
  .delete()
149
189
  .eq("user_id", data.userId)
150
- .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);
151
353
  if (error)
152
354
  throw error;
153
355
  return { ok: true };
154
356
  });
155
357
  /**
156
- * Registreert de modules die de shell heeft geladen. Modules die niet in de
157
- * 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.
158
360
  */
159
361
  export const syncModuleRegistry = createServerFn({ method: "POST" })
160
362
  .middleware([requireSupabaseAuth])
@@ -174,7 +376,6 @@ export const syncModuleRegistry = createServerFn({ method: "POST" })
174
376
  })), { onConflict: "module_id" });
175
377
  if (error)
176
378
  throw error;
177
- // Modules van deze shell die niet meer geleverd worden → inactief.
178
379
  if (data.shellId) {
179
380
  const ids = modules.map((m) => m.id);
180
381
  const { error: deactivateError } = await context.supabase
@@ -185,70 +386,71 @@ export const syncModuleRegistry = createServerFn({ method: "POST" })
185
386
  if (deactivateError)
186
387
  throw deactivateError;
187
388
  }
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;
403
+ }
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
+ }
188
417
  return { ok: true, count: modules.length };
189
418
  });
190
- export const getModulePermissions = createServerFn({ method: "GET" })
191
- .middleware([requireSupabaseAuth])
192
- .handler(async ({ context }) => {
193
- await requireAdmin(context);
194
- const [{ data, error }, { data: registry, error: registryError }] = await Promise.all([
195
- context.supabase.from("module_permissions").select("role, module_id, permission"),
196
- context.supabase
197
- .from("module_registry")
198
- .select("module_id, name, is_active")
199
- .order("name", { ascending: true }),
200
- ]);
201
- if (error)
202
- throw error;
203
- if (registryError)
204
- throw registryError;
205
- const safe = Array.isArray(data) ? data : [];
206
- const safeRegistry = Array.isArray(registry) ? registry : [];
207
- return {
208
- roles: APP_ROLES,
209
- modules: safeRegistry
210
- .filter((row) => row.is_active)
211
- .map((row) => ({
212
- id: row.module_id,
213
- name: row.name,
214
- })),
215
- permissions: safe.map((row) => ({
216
- role: row.role,
217
- moduleId: row.module_id,
218
- permission: row.permission,
219
- })),
220
- };
221
- });
222
419
  export const getUserModulePermissions = createServerFn({ method: "POST" })
223
420
  .middleware([requireSupabaseAuth])
224
421
  .inputValidator((data) => userPermissionsInput.parse(data))
225
422
  .handler(async ({ context, data }) => {
226
423
  await requireUserAdmin(context);
227
- 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([
228
425
  context.supabase
229
426
  .from("user_module_permissions")
230
- .select("module_id, permission")
427
+ .select("module_id, feature_id, permission")
231
428
  .eq("user_id", data.userId),
232
- context.supabase
233
- .from("module_registry")
234
- .select("module_id, name, is_active")
235
- .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 }),
236
431
  ]);
237
432
  if (error)
238
433
  throw error;
239
434
  if (registryError)
240
435
  throw registryError;
436
+ if (featuresError)
437
+ throw featuresError;
241
438
  const safeOverrides = Array.isArray(overrides) ? overrides : [];
242
439
  const safeRegistry = Array.isArray(registry) ? registry : [];
440
+ const safeFeatures = Array.isArray(featureRows) ? featureRows : [];
243
441
  return {
244
442
  modules: safeRegistry
245
443
  .filter((row) => row.is_active)
246
444
  .map((row) => ({
247
445
  id: row.module_id,
248
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 })),
249
450
  })),
250
451
  overrides: safeOverrides.map((row) => ({
251
452
  moduleId: row.module_id,
453
+ featureId: row.feature_id ?? "",
252
454
  permission: row.permission,
253
455
  })),
254
456
  };
@@ -258,12 +460,14 @@ export const setUserModulePermission = createServerFn({ method: "POST" })
258
460
  .inputValidator((data) => setUserPermissionInput.parse(data))
259
461
  .handler(async ({ context, data }) => {
260
462
  await requireUserAdmin(context);
463
+ const featureId = data.featureId ?? "";
261
464
  if (data.permission === "inherit") {
262
465
  const { error } = await context.supabase
263
466
  .from("user_module_permissions")
264
467
  .delete()
265
468
  .eq("user_id", data.userId)
266
- .eq("module_id", data.moduleId);
469
+ .eq("module_id", data.moduleId)
470
+ .eq("feature_id", featureId);
267
471
  if (error)
268
472
  throw error;
269
473
  return { ok: true };
@@ -271,27 +475,10 @@ export const setUserModulePermission = createServerFn({ method: "POST" })
271
475
  const { error } = await context.supabase.from("user_module_permissions").upsert({
272
476
  user_id: data.userId,
273
477
  module_id: data.moduleId,
478
+ feature_id: featureId,
274
479
  permission: data.permission,
275
480
  updated_at: new Date().toISOString(),
276
- }, { onConflict: "user_id,module_id" });
277
- if (error)
278
- throw error;
279
- return { ok: true };
280
- });
281
- export const setModulePermission = createServerFn({ method: "POST" })
282
- .middleware([requireSupabaseAuth])
283
- .inputValidator((data) => setPermissionInput.parse(data))
284
- .handler(async ({ context, data }) => {
285
- await requireAdmin(context);
286
- // Upsert via user-scoped client — RLS write-policy op module_permissions
287
- // vereist admin (via JWT-claim of has_role-fallback).
288
- const { error } = await context.supabase
289
- .from("module_permissions")
290
- .upsert({
291
- role: data.role,
292
- module_id: data.moduleId,
293
- permission: data.permission,
294
- }, { onConflict: "role,module_id" });
481
+ }, { onConflict: "user_id,module_id,feature_id" });
295
482
  if (error)
296
483
  throw error;
297
484
  return { ok: true };
@@ -301,9 +488,7 @@ export const inviteUser = createServerFn({ method: "POST" })
301
488
  .inputValidator((data) => inviteUserInput.parse(data))
302
489
  .handler(async ({ context, data }) => {
303
490
  const { isAdmin } = await requireUserAdmin(context);
304
- if (!isAdmin && data.role === "admin") {
305
- throw new Error("Alleen een admin kan een account met de adminrol aanmaken.");
306
- }
491
+ await assertMayUseRole(context.supabase, data.roleId, isAdmin);
307
492
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
308
493
  let redirectTo;
309
494
  try {
@@ -316,10 +501,7 @@ export const inviteUser = createServerFn({ method: "POST" })
316
501
  catch {
317
502
  redirectTo = undefined;
318
503
  }
319
- const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(data.email, {
320
- data: { display_name: data.displayName },
321
- redirectTo,
322
- });
504
+ const { data: invited, error: inviteError } = await supabaseAdmin.auth.admin.inviteUserByEmail(data.email, { data: { display_name: data.displayName }, redirectTo });
323
505
  if (inviteError || !invited?.user) {
324
506
  const msg = inviteError?.message ?? "Uitnodigen mislukt";
325
507
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
@@ -331,8 +513,8 @@ export const inviteUser = createServerFn({ method: "POST" })
331
513
  if (profileError)
332
514
  throw profileError;
333
515
  const { error: roleError } = await supabaseAdmin
334
- .from("user_roles")
335
- .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 });
336
518
  if (roleError)
337
519
  throw roleError;
338
520
  return { ok: true, userId };
@@ -347,13 +529,13 @@ export const deleteUser = createServerFn({ method: "POST" })
347
529
  }
348
530
  if (!isAdmin) {
349
531
  const { data: targetRoles, error: targetError } = await context.supabase
350
- .from("user_roles")
351
- .select("role")
532
+ .from("role_assignments")
533
+ .select("roles(key)")
352
534
  .eq("user_id", data.userId);
353
535
  if (targetError)
354
536
  throw targetError;
355
537
  const safeTargetRoles = Array.isArray(targetRoles) ? targetRoles : [];
356
- if (safeTargetRoles.some((r) => r.role === "admin")) {
538
+ if (safeTargetRoles.some((r) => r?.roles?.key === "admin")) {
357
539
  throw new Error("Alleen een admin kan een adminaccount verwijderen.");
358
540
  }
359
541
  }
@@ -363,6 +545,37 @@ export const deleteUser = createServerFn({ method: "POST" })
363
545
  throw error;
364
546
  return { ok: true };
365
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
+ });
366
579
  function generatePassword(length = 16) {
367
580
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%*";
368
581
  const bytes = new Uint8Array(length);
@@ -377,12 +590,9 @@ export const createTestUser = createServerFn({ method: "POST" })
377
590
  .inputValidator((data) => createTestUserInput.parse(data))
378
591
  .handler(async ({ context, data }) => {
379
592
  const { isAdmin } = await requireUserAdmin(context);
380
- if (!isAdmin && data.role === "admin") {
381
- throw new Error("Alleen een admin kan een account met de adminrol aanmaken.");
382
- }
593
+ await assertMayUseRole(context.supabase, data.roleId, isAdmin);
383
594
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
384
- const email = data.email?.trim() ||
385
- `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`;
386
596
  const password = generatePassword(16);
387
597
  const { data: created, error: createError } = await supabaseAdmin.auth.admin.createUser({
388
598
  email,
@@ -395,15 +605,14 @@ export const createTestUser = createServerFn({ method: "POST" })
395
605
  throw new Error(/already|exists|registered/i.test(msg) ? "Deze gebruiker bestaat al." : msg);
396
606
  }
397
607
  const userId = created.user.id;
398
- // Trigger vult profiles al; toch upsert voor zekerheid (idempotent).
399
608
  const { error: profileError } = await supabaseAdmin
400
609
  .from("profiles")
401
610
  .upsert({ id: userId, email, display_name: data.displayName }, { onConflict: "id" });
402
611
  if (profileError)
403
612
  throw profileError;
404
613
  const { error: roleError } = await supabaseAdmin
405
- .from("user_roles")
406
- .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 });
407
616
  if (roleError)
408
617
  throw roleError;
409
618
  return { ok: true, userId, email, password };