@open-mercato/core 0.6.8-develop.6924.1.a8d208fcdc → 0.6.8-develop.6925.1.cefa200fa4
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/role/index.js +2 -0
- package/dist/generated/entities/role/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/api.js +4 -0
- package/dist/helpers/integration/api.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js +5 -0
- package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js +5 -0
- package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js.map +2 -2
- package/dist/modules/audit_logs/backend/audit-logs/page.js +4 -4
- package/dist/modules/audit_logs/backend/audit-logs/page.js.map +2 -2
- package/dist/modules/audit_logs/components/AuditLogsActions.js +9 -2
- package/dist/modules/audit_logs/components/AuditLogsActions.js.map +2 -2
- package/dist/modules/auth/api/login.js +5 -2
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +8 -5
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/backend/users/[id]/edit/page.js +15 -1
- package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/backend/users/page.js +7 -1
- package/dist/modules/auth/backend/users/page.js.map +2 -2
- package/dist/modules/auth/commands/users.js +175 -76
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/auth/data/entities.js +4 -0
- package/dist/modules/auth/data/entities.js.map +2 -2
- package/dist/modules/auth/lib/sessionIntegrity.js +1 -1
- package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
- package/dist/modules/auth/lib/setup-app.js +1 -1
- package/dist/modules/auth/lib/setup-app.js.map +2 -2
- package/dist/modules/auth/migrations/Migration20260728134212_auth.js +14 -0
- package/dist/modules/auth/migrations/Migration20260728134212_auth.js.map +7 -0
- package/generated/entities/role/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/helpers/integration/api.ts +13 -0
- package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +7 -0
- package/src/modules/audit_logs/api/audit-logs/actions/undo/route.ts +9 -0
- package/src/modules/audit_logs/backend/audit-logs/page.tsx +4 -4
- package/src/modules/audit_logs/components/AuditLogsActions.tsx +20 -4
- package/src/modules/auth/api/login.ts +9 -2
- package/src/modules/auth/api/users/route.ts +8 -4
- package/src/modules/auth/backend/users/[id]/edit/page.tsx +17 -1
- package/src/modules/auth/backend/users/page.tsx +11 -0
- package/src/modules/auth/commands/users.ts +251 -85
- package/src/modules/auth/data/entities.ts +3 -0
- package/src/modules/auth/i18n/de.json +6 -0
- package/src/modules/auth/i18n/en.json +6 -0
- package/src/modules/auth/i18n/es.json +6 -0
- package/src/modules/auth/i18n/ko.json +6 -0
- package/src/modules/auth/i18n/pl.json +6 -0
- package/src/modules/auth/lib/sessionIntegrity.ts +1 -1
- package/src/modules/auth/lib/setup-app.ts +1 -1
- package/src/modules/auth/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/auth/migrations/Migration20260728134212_auth.ts +14 -0
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "@open-mercato/shared/lib/commands/helpers";
|
|
10
10
|
import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
11
11
|
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
12
|
-
import { UniqueConstraintViolationException } from "@mikro-orm/core";
|
|
12
|
+
import { UniqueConstraintViolationException, LockMode } from "@mikro-orm/core";
|
|
13
13
|
import { User, UserRole, Role, UserAcl, Session, PasswordReset } from "@open-mercato/core/modules/auth/data/entities";
|
|
14
14
|
import { Organization } from "@open-mercato/core/modules/directory/data/entities";
|
|
15
15
|
import { E } from "../../../generated/entities.ids.generated.js";
|
|
@@ -74,7 +74,8 @@ const updateSchema = z.object({
|
|
|
74
74
|
name: displayNameSchema,
|
|
75
75
|
password: passwordSchema.optional(),
|
|
76
76
|
organizationId: z.string().uuid().optional(),
|
|
77
|
-
roles: z.array(z.string()).optional()
|
|
77
|
+
roles: z.array(z.string()).optional(),
|
|
78
|
+
isConfirmed: z.boolean().optional()
|
|
78
79
|
});
|
|
79
80
|
const userCrudEvents = {
|
|
80
81
|
module: "auth",
|
|
@@ -256,6 +257,9 @@ const createUserCommand = {
|
|
|
256
257
|
let removed = null;
|
|
257
258
|
await withAtomicFlush(em, [
|
|
258
259
|
async () => {
|
|
260
|
+
const current = await findOneWithDecryption(em, User, { id: userId, deletedAt: null }, {}, { tenantId: null, organizationId: null });
|
|
261
|
+
const floorTenantId = current?.tenantId ? String(current.tenantId) : snapshot?.tenantId ?? null;
|
|
262
|
+
await enforceProtectedRoleFloor(em, floorTenantId, userId, { deleting: true }, ctx);
|
|
259
263
|
await em.nativeDelete(UserAcl, { user: userId });
|
|
260
264
|
await em.nativeDelete(UserRole, { user: userId });
|
|
261
265
|
await em.nativeDelete(Session, { user: userId });
|
|
@@ -280,7 +284,7 @@ const createUserCommand = {
|
|
|
280
284
|
soft: false
|
|
281
285
|
});
|
|
282
286
|
}
|
|
283
|
-
], { transaction: true });
|
|
287
|
+
], { transaction: true, label: "auth.users.create.undo" });
|
|
284
288
|
await emitCrudUndoSideEffects({
|
|
285
289
|
dataEngine: de,
|
|
286
290
|
action: "deleted",
|
|
@@ -425,6 +429,12 @@ const updateUserCommand = {
|
|
|
425
429
|
async execute(rawInput, ctx) {
|
|
426
430
|
const { parsed, custom } = parseWithCustomFields(updateSchema, rawInput);
|
|
427
431
|
const em = ctx.container.resolve("em");
|
|
432
|
+
const actorTenantScope = resolveActorTenantScope(ctx);
|
|
433
|
+
const existing = await findOneWithDecryption(em, User, { id: parsed.id, deletedAt: null }, {}, { tenantId: null, organizationId: null });
|
|
434
|
+
if (!existing) throw new CrudHttpError(404, { error: "User not found" });
|
|
435
|
+
if (actorTenantScope && existing.tenantId && String(existing.tenantId) !== actorTenantScope) {
|
|
436
|
+
throw new CrudHttpError(404, { error: "User not found" });
|
|
437
|
+
}
|
|
428
438
|
const rolesBefore = Array.isArray(parsed.roles) ? await loadUserRoleNames(em, parsed.id) : null;
|
|
429
439
|
let tenantId;
|
|
430
440
|
if (parsed.organizationId !== void 0) {
|
|
@@ -438,22 +448,9 @@ const updateUserCommand = {
|
|
|
438
448
|
if (!organization) throw new CrudHttpError(400, { error: "Organization not found" });
|
|
439
449
|
tenantId = organization.tenant?.id ? String(organization.tenant.id) : null;
|
|
440
450
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
em,
|
|
445
|
-
User,
|
|
446
|
-
{
|
|
447
|
-
$or: [{ email: parsed.email }, { emailHash: { $in: emailHashLookupValues(parsed.email) } }],
|
|
448
|
-
deletedAt: null,
|
|
449
|
-
tenantId: targetTenantId,
|
|
450
|
-
id: { $ne: parsed.id }
|
|
451
|
-
},
|
|
452
|
-
{},
|
|
453
|
-
{ tenantId: null, organizationId: null }
|
|
454
|
-
);
|
|
455
|
-
if (duplicate) await throwDuplicateEmailError();
|
|
456
|
-
}
|
|
451
|
+
const userTenantId = existing.tenantId ? String(existing.tenantId) : null;
|
|
452
|
+
const targetTenantId = tenantId !== void 0 ? tenantId : userTenantId;
|
|
453
|
+
const isTenantChanging = targetTenantId !== userTenantId;
|
|
457
454
|
let hashed = null;
|
|
458
455
|
let emailHash = null;
|
|
459
456
|
if (parsed.password) {
|
|
@@ -463,49 +460,75 @@ const updateUserCommand = {
|
|
|
463
460
|
if (parsed.email !== void 0) {
|
|
464
461
|
emailHash = computeEmailHash(parsed.email);
|
|
465
462
|
}
|
|
466
|
-
const actorTenantScope = resolveActorTenantScope(ctx);
|
|
467
463
|
const updateWhere = { id: parsed.id, deletedAt: null };
|
|
468
464
|
if (actorTenantScope) updateWhere.tenantId = actorTenantScope;
|
|
469
465
|
const de = ctx.container.resolve("dataEngine");
|
|
470
466
|
let user;
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
467
|
+
await withAtomicFlush(em, [
|
|
468
|
+
async () => {
|
|
469
|
+
await enforceProtectedRoleFloor(em, userTenantId, parsed.id, {
|
|
470
|
+
deactivating: parsed.isConfirmed === false || isTenantChanging,
|
|
471
|
+
newRoles: parsed.roles
|
|
472
|
+
}, ctx);
|
|
473
|
+
if (parsed.email !== void 0) {
|
|
474
|
+
const duplicate = await findOneWithDecryption(
|
|
475
|
+
em,
|
|
476
|
+
User,
|
|
477
|
+
{
|
|
478
|
+
$or: [{ email: parsed.email }, { emailHash: { $in: emailHashLookupValues(parsed.email) } }],
|
|
479
|
+
deletedAt: null,
|
|
480
|
+
tenantId: targetTenantId,
|
|
481
|
+
id: { $ne: parsed.id }
|
|
482
|
+
},
|
|
483
|
+
{},
|
|
484
|
+
{ tenantId: null, organizationId: null }
|
|
485
|
+
);
|
|
486
|
+
if (duplicate) await throwDuplicateEmailError();
|
|
488
487
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
488
|
+
try {
|
|
489
|
+
const updated = await de.updateOrmEntity({
|
|
490
|
+
entity: User,
|
|
491
|
+
where: updateWhere,
|
|
492
|
+
apply: (entity) => {
|
|
493
|
+
if (parsed.email !== void 0) {
|
|
494
|
+
entity.email = parsed.email;
|
|
495
|
+
entity.emailHash = emailHash;
|
|
496
|
+
}
|
|
497
|
+
if (parsed.name !== void 0) {
|
|
498
|
+
entity.name = parsed.name;
|
|
499
|
+
}
|
|
500
|
+
if (parsed.organizationId !== void 0) {
|
|
501
|
+
entity.organizationId = parsed.organizationId;
|
|
502
|
+
entity.tenantId = tenantId ?? null;
|
|
503
|
+
}
|
|
504
|
+
if (parsed.isConfirmed !== void 0) {
|
|
505
|
+
entity.isConfirmed = parsed.isConfirmed;
|
|
506
|
+
}
|
|
507
|
+
if (hashed) entity.passwordHash = hashed;
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
if (updated) user = updated;
|
|
511
|
+
} catch (error) {
|
|
512
|
+
if (isUniqueViolation(error)) await throwDuplicateEmailError();
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
if (!user) throw new CrudHttpError(404, { error: "User not found" });
|
|
516
|
+
if (hashed || parsed.isConfirmed === false) {
|
|
517
|
+
await em.nativeDelete(Session, { user: parsed.id });
|
|
518
|
+
}
|
|
519
|
+
if (Array.isArray(parsed.roles)) {
|
|
520
|
+
await syncUserRoles(em, user, parsed.roles, user.tenantId ? String(user.tenantId) : tenantId ?? null);
|
|
521
|
+
}
|
|
522
|
+
await setCustomFieldsIfAny({
|
|
523
|
+
dataEngine: de,
|
|
524
|
+
entityId: E.auth.user,
|
|
525
|
+
recordId: String(user.id),
|
|
526
|
+
organizationId: user.organizationId ? String(user.organizationId) : null,
|
|
527
|
+
tenantId: user.tenantId ? String(user.tenantId) : tenantId ?? null,
|
|
528
|
+
values: custom
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
], { transaction: true });
|
|
509
532
|
const identifiers = {
|
|
510
533
|
id: String(user.id),
|
|
511
534
|
organizationId: user.organizationId ? String(user.organizationId) : null,
|
|
@@ -588,22 +611,33 @@ const updateUserCommand = {
|
|
|
588
611
|
const userId = before.id;
|
|
589
612
|
const em = ctx.container.resolve("em");
|
|
590
613
|
const de = ctx.container.resolve("dataEngine");
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
614
|
+
const restoredTenantId = before.tenantId ? String(before.tenantId) : null;
|
|
615
|
+
let updated = null;
|
|
616
|
+
await withAtomicFlush(em, [
|
|
617
|
+
async () => {
|
|
618
|
+
const current = await findOneWithDecryption(em, User, { id: userId, deletedAt: null }, {}, { tenantId: null, organizationId: null });
|
|
619
|
+
const currentTenantId = current?.tenantId ? String(current.tenantId) : null;
|
|
620
|
+
await enforceProtectedRoleFloor(em, currentTenantId, userId, {
|
|
621
|
+
deactivating: before.isConfirmed === false || restoredTenantId !== currentTenantId,
|
|
622
|
+
newRoles: before.roles
|
|
623
|
+
}, ctx);
|
|
624
|
+
updated = await de.updateOrmEntity({
|
|
625
|
+
entity: User,
|
|
626
|
+
where: { id: userId, deletedAt: null },
|
|
627
|
+
apply: (entity) => {
|
|
628
|
+
entity.email = before.email;
|
|
629
|
+
entity.organizationId = before.organizationId ?? null;
|
|
630
|
+
entity.tenantId = before.tenantId ?? null;
|
|
631
|
+
entity.passwordHash = before.passwordHash ?? null;
|
|
632
|
+
entity.name = before.name ?? null;
|
|
633
|
+
entity.isConfirmed = before.isConfirmed;
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
if (updated) {
|
|
637
|
+
await syncUserRoles(em, updated, before.roles, before.tenantId);
|
|
638
|
+
}
|
|
601
639
|
}
|
|
602
|
-
});
|
|
603
|
-
if (updated) {
|
|
604
|
-
await syncUserRoles(em, updated, before.roles, before.tenantId);
|
|
605
|
-
await em.flush();
|
|
606
|
-
}
|
|
640
|
+
], { transaction: true, label: "auth.users.update.undo" });
|
|
607
641
|
const reset = buildCustomFieldResetMap(before.custom, after?.custom);
|
|
608
642
|
if (Object.keys(reset).length) {
|
|
609
643
|
await setCustomFieldsIfAny({
|
|
@@ -658,11 +692,18 @@ const deleteUserCommand = {
|
|
|
658
692
|
const em = ctx.container.resolve("em");
|
|
659
693
|
const de = ctx.container.resolve("dataEngine");
|
|
660
694
|
const actorTenantScope = resolveActorTenantScope(ctx);
|
|
695
|
+
const existing = await findOneWithDecryption(em, User, { id, deletedAt: null }, {}, { tenantId: null, organizationId: null });
|
|
696
|
+
if (!existing) throw new CrudHttpError(404, { error: "User not found" });
|
|
697
|
+
if (actorTenantScope && existing.tenantId && String(existing.tenantId) !== actorTenantScope) {
|
|
698
|
+
throw new CrudHttpError(404, { error: "User not found" });
|
|
699
|
+
}
|
|
661
700
|
const deleteWhere = { id, deletedAt: null };
|
|
662
701
|
if (actorTenantScope) deleteWhere.tenantId = actorTenantScope;
|
|
663
702
|
let user;
|
|
664
703
|
await withAtomicFlush(em, [
|
|
665
704
|
async () => {
|
|
705
|
+
const userTenantId = existing.tenantId ? String(existing.tenantId) : null;
|
|
706
|
+
await enforceProtectedRoleFloor(em, userTenantId, id, { deleting: true }, ctx);
|
|
666
707
|
await em.nativeDelete(UserAcl, { user: id });
|
|
667
708
|
await em.nativeDelete(UserRole, { user: id });
|
|
668
709
|
await em.nativeDelete(Session, { user: id });
|
|
@@ -913,10 +954,6 @@ function arrayEquals(left, right) {
|
|
|
913
954
|
if (left.length !== right.length) return false;
|
|
914
955
|
return left.every((value, idx) => value === right[idx]);
|
|
915
956
|
}
|
|
916
|
-
async function resolveUserTenantId(em, id) {
|
|
917
|
-
const existing = await findOneWithDecryption(em, User, { id, deletedAt: null }, {}, { tenantId: null, organizationId: null });
|
|
918
|
-
return existing?.tenantId ? String(existing.tenantId) : null;
|
|
919
|
-
}
|
|
920
957
|
async function throwDuplicateEmailError() {
|
|
921
958
|
const { translate } = await resolveTranslations();
|
|
922
959
|
const message = translate("auth.users.errors.emailExists", "Email already in use");
|
|
@@ -926,6 +963,68 @@ async function throwDuplicateEmailError() {
|
|
|
926
963
|
details: [{ path: ["email"], message, code: "duplicate", origin: "validation" }]
|
|
927
964
|
});
|
|
928
965
|
}
|
|
966
|
+
function couldReduceActiveHolders(options) {
|
|
967
|
+
return options.deleting === true || options.deactivating === true || options.newRoles !== void 0;
|
|
968
|
+
}
|
|
969
|
+
async function enforceProtectedRoleFloor(em, tenantId, userId, options, ctx) {
|
|
970
|
+
if (ctx?.systemActor === true) return;
|
|
971
|
+
if (!couldReduceActiveHolders(options)) return;
|
|
972
|
+
const normalizedTenantId = normalizeTenantId(tenantId) ?? null;
|
|
973
|
+
if (!normalizedTenantId) return;
|
|
974
|
+
const protectedRoles = await findWithDecryption(em, Role, {
|
|
975
|
+
tenantId: normalizedTenantId,
|
|
976
|
+
minActiveHolders: { $gt: 0 },
|
|
977
|
+
deletedAt: null
|
|
978
|
+
}, {
|
|
979
|
+
lockMode: LockMode.PESSIMISTIC_WRITE,
|
|
980
|
+
orderBy: { id: "ASC" }
|
|
981
|
+
}, { tenantId: normalizedTenantId, organizationId: null });
|
|
982
|
+
if (protectedRoles.length === 0) return;
|
|
983
|
+
const { translate } = await resolveTranslations();
|
|
984
|
+
for (const role of protectedRoles) {
|
|
985
|
+
const minFloor = role.minActiveHolders ?? 0;
|
|
986
|
+
if (minFloor <= 0) continue;
|
|
987
|
+
const activeLinks = await findWithDecryption(em, UserRole, {
|
|
988
|
+
role: role.id,
|
|
989
|
+
deletedAt: null,
|
|
990
|
+
user: {
|
|
991
|
+
deletedAt: null,
|
|
992
|
+
isConfirmed: true,
|
|
993
|
+
tenantId: normalizedTenantId
|
|
994
|
+
}
|
|
995
|
+
}, {}, { tenantId: null, organizationId: null });
|
|
996
|
+
const activeUserIds = Array.from(
|
|
997
|
+
new Set(
|
|
998
|
+
activeLinks.map((link) => {
|
|
999
|
+
const userRef = link.user;
|
|
1000
|
+
const linkedUserId = typeof userRef === "string" ? userRef : userRef?.id;
|
|
1001
|
+
return linkedUserId ? String(linkedUserId) : null;
|
|
1002
|
+
}).filter((id) => !!id)
|
|
1003
|
+
)
|
|
1004
|
+
);
|
|
1005
|
+
if (activeUserIds.includes(userId)) {
|
|
1006
|
+
let willStillBeActiveHolder = true;
|
|
1007
|
+
if (options.deleting || options.deactivating) {
|
|
1008
|
+
willStillBeActiveHolder = false;
|
|
1009
|
+
} else if (options.newRoles !== void 0) {
|
|
1010
|
+
const desiredUnique = Array.from(new Set(options.newRoles.map((r) => r.trim().toLowerCase()).filter(Boolean)));
|
|
1011
|
+
const roleNameLower = role.name.toLowerCase();
|
|
1012
|
+
const hasRoleByName = desiredUnique.includes(roleNameLower) || desiredUnique.includes(String(role.id).toLowerCase());
|
|
1013
|
+
if (!hasRoleByName) {
|
|
1014
|
+
willStillBeActiveHolder = false;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
if (!willStillBeActiveHolder) {
|
|
1018
|
+
const remaining = activeUserIds.length - 1;
|
|
1019
|
+
if (remaining < minFloor) {
|
|
1020
|
+
throw new CrudHttpError(400, {
|
|
1021
|
+
error: translate("auth.users.errors.lastHolderOfCriticalRole", 'Cannot remove the last active holder of role "{roleName}"', { roleName: role.name })
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
929
1028
|
export {
|
|
930
1029
|
userCrudEvents,
|
|
931
1030
|
userCrudIndexer
|