@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.
Files changed (56) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/role/index.js +2 -0
  3. package/dist/generated/entities/role/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/helpers/integration/api.js +4 -0
  7. package/dist/helpers/integration/api.js.map +2 -2
  8. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js +5 -0
  9. package/dist/modules/audit_logs/api/audit-logs/actions/redo/route.js.map +2 -2
  10. package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js +5 -0
  11. package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js.map +2 -2
  12. package/dist/modules/audit_logs/backend/audit-logs/page.js +4 -4
  13. package/dist/modules/audit_logs/backend/audit-logs/page.js.map +2 -2
  14. package/dist/modules/audit_logs/components/AuditLogsActions.js +9 -2
  15. package/dist/modules/audit_logs/components/AuditLogsActions.js.map +2 -2
  16. package/dist/modules/auth/api/login.js +5 -2
  17. package/dist/modules/auth/api/login.js.map +2 -2
  18. package/dist/modules/auth/api/users/route.js +8 -5
  19. package/dist/modules/auth/api/users/route.js.map +2 -2
  20. package/dist/modules/auth/backend/users/[id]/edit/page.js +15 -1
  21. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  22. package/dist/modules/auth/backend/users/page.js +7 -1
  23. package/dist/modules/auth/backend/users/page.js.map +2 -2
  24. package/dist/modules/auth/commands/users.js +175 -76
  25. package/dist/modules/auth/commands/users.js.map +2 -2
  26. package/dist/modules/auth/data/entities.js +4 -0
  27. package/dist/modules/auth/data/entities.js.map +2 -2
  28. package/dist/modules/auth/lib/sessionIntegrity.js +1 -1
  29. package/dist/modules/auth/lib/sessionIntegrity.js.map +2 -2
  30. package/dist/modules/auth/lib/setup-app.js +1 -1
  31. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  32. package/dist/modules/auth/migrations/Migration20260728134212_auth.js +14 -0
  33. package/dist/modules/auth/migrations/Migration20260728134212_auth.js.map +7 -0
  34. package/generated/entities/role/index.ts +1 -0
  35. package/generated/entity-fields-registry.ts +1 -0
  36. package/package.json +7 -7
  37. package/src/helpers/integration/api.ts +13 -0
  38. package/src/modules/audit_logs/api/audit-logs/actions/redo/route.ts +7 -0
  39. package/src/modules/audit_logs/api/audit-logs/actions/undo/route.ts +9 -0
  40. package/src/modules/audit_logs/backend/audit-logs/page.tsx +4 -4
  41. package/src/modules/audit_logs/components/AuditLogsActions.tsx +20 -4
  42. package/src/modules/auth/api/login.ts +9 -2
  43. package/src/modules/auth/api/users/route.ts +8 -4
  44. package/src/modules/auth/backend/users/[id]/edit/page.tsx +17 -1
  45. package/src/modules/auth/backend/users/page.tsx +11 -0
  46. package/src/modules/auth/commands/users.ts +251 -85
  47. package/src/modules/auth/data/entities.ts +3 -0
  48. package/src/modules/auth/i18n/de.json +6 -0
  49. package/src/modules/auth/i18n/en.json +6 -0
  50. package/src/modules/auth/i18n/es.json +6 -0
  51. package/src/modules/auth/i18n/ko.json +6 -0
  52. package/src/modules/auth/i18n/pl.json +6 -0
  53. package/src/modules/auth/lib/sessionIntegrity.ts +1 -1
  54. package/src/modules/auth/lib/setup-app.ts +1 -1
  55. package/src/modules/auth/migrations/.snapshot-open-mercato.json +16 -0
  56. package/src/modules/auth/migrations/Migration20260728134212_auth.ts +14 -0
@@ -13,7 +13,7 @@ import type { CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/l
13
13
  import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
14
14
  import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
15
15
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
16
- import { UniqueConstraintViolationException } from '@mikro-orm/core'
16
+ import { UniqueConstraintViolationException, LockMode } from '@mikro-orm/core'
17
17
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
18
18
  import { User, UserRole, Role, UserAcl, Session, PasswordReset } from '@open-mercato/core/modules/auth/data/entities'
19
19
  import { Organization } from '@open-mercato/core/modules/directory/data/entities'
@@ -122,6 +122,7 @@ const updateSchema = z.object({
122
122
  password: passwordSchema.optional(),
123
123
  organizationId: z.string().uuid().optional(),
124
124
  roles: z.array(z.string()).optional(),
125
+ isConfirmed: z.boolean().optional(),
125
126
  })
126
127
 
127
128
  export const userCrudEvents: CrudEventsConfig = {
@@ -329,9 +330,22 @@ const createUserCommand: CommandHandler<Record<string, unknown>, CreateUserResul
329
330
  const em = (ctx.container.resolve('em') as EntityManager)
330
331
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
331
332
 
333
+ // Evaluate the floor against the user's CURRENT tenant, not the one recorded at
334
+ // creation. A user moved to another tenant after being created would otherwise be
335
+ // checked against the origin tenant — where they no longer hold anything — and the
336
+ // undo would hard-delete the destination tenant's last administrator unchecked.
337
+ // The create snapshot is only a fallback for a row that is already gone.
332
338
  let removed: User | null = null
333
339
  await withAtomicFlush(em, [
334
340
  async () => {
341
+ const current = await findOneWithDecryption(em, User, { id: userId, deletedAt: null }, {}, { tenantId: null, organizationId: null })
342
+ const floorTenantId = current?.tenantId ? String(current.tenantId) : (snapshot?.tenantId ?? null)
343
+
344
+ // Undoing a create hard-deletes the user, so it can strip a tenant's last active
345
+ // admin exactly like `auth.users.delete` can — promote a second admin, delete the
346
+ // first, then undo the promotion's create. Same guard applies.
347
+ await enforceProtectedRoleFloor(em, floorTenantId, userId, { deleting: true }, ctx)
348
+
335
349
  await em.nativeDelete(UserAcl, { user: userId })
336
350
  await em.nativeDelete(UserRole, { user: userId })
337
351
  await em.nativeDelete(Session, { user: userId })
@@ -357,7 +371,7 @@ const createUserCommand: CommandHandler<Record<string, unknown>, CreateUserResul
357
371
  soft: false,
358
372
  })
359
373
  },
360
- ], { transaction: true })
374
+ ], { transaction: true, label: 'auth.users.create.undo' })
361
375
 
362
376
  await emitCrudUndoSideEffects({
363
377
  dataEngine: de,
@@ -522,14 +536,17 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
522
536
  async execute(rawInput, ctx) {
523
537
  const { parsed, custom } = parseWithCustomFields(updateSchema, rawInput)
524
538
  const em = (ctx.container.resolve('em') as EntityManager)
539
+ const actorTenantScope = resolveActorTenantScope(ctx)
540
+ const existing = await findOneWithDecryption(em, User, { id: parsed.id, deletedAt: null }, {}, { tenantId: null, organizationId: null })
541
+ if (!existing) throw new CrudHttpError(404, { error: 'User not found' })
542
+ if (actorTenantScope && existing.tenantId && String(existing.tenantId) !== actorTenantScope) {
543
+ throw new CrudHttpError(404, { error: 'User not found' })
544
+ }
545
+
525
546
  const rolesBefore = Array.isArray(parsed.roles)
526
547
  ? await loadUserRoleNames(em, parsed.id)
527
548
  : null
528
549
 
529
- // Resolve the tenant the user will belong to after this update first, so the email
530
- // duplicate check below can be scoped to it. Email is unique per-tenant, not globally
531
- // (see Migration20260610120000: users_tenant_email_hash_uniq) — a matching email in another
532
- // tenant must not block the update or leak cross-tenant account existence (#2934).
533
550
  let tenantId: string | null | undefined
534
551
  if (parsed.organizationId !== undefined) {
535
552
  const organization = await findOneWithDecryption(
@@ -543,25 +560,11 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
543
560
  tenantId = organization.tenant?.id ? String(organization.tenant.id) : null
544
561
  }
545
562
 
546
- if (parsed.email !== undefined) {
547
- const targetTenantId = tenantId !== undefined
548
- ? tenantId
549
- : await resolveUserTenantId(em, parsed.id)
550
- const duplicate = await findOneWithDecryption(
551
- em,
552
- User,
553
- {
554
- $or: [{ email: parsed.email }, { emailHash: { $in: emailHashLookupValues(parsed.email) } }],
555
- deletedAt: null,
556
- tenantId: targetTenantId,
557
- id: { $ne: parsed.id } as any,
558
- } as FilterQuery<User>,
559
- {},
560
- { tenantId: null, organizationId: null },
561
- )
562
- if (duplicate) await throwDuplicateEmailError()
563
- }
563
+ const userTenantId = existing.tenantId ? String(existing.tenantId) : null
564
+ const targetTenantId = tenantId !== undefined ? tenantId : userTenantId
565
+ const isTenantChanging = targetTenantId !== userTenantId
564
566
 
567
+ // Hash password BEFORE transaction begins to avoid holding locks during CPU-heavy tasks
565
568
  let hashed: string | null = null
566
569
  let emailHash: string | null = null
567
570
  if (parsed.password) {
@@ -572,53 +575,87 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
572
575
  emailHash = computeEmailHash(parsed.email)
573
576
  }
574
577
 
575
- const actorTenantScope = resolveActorTenantScope(ctx)
576
578
  const updateWhere: Record<string, unknown> = { id: parsed.id, deletedAt: null }
577
579
  if (actorTenantScope) updateWhere.tenantId = actorTenantScope
578
580
 
579
581
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
580
- let user: User | null
581
- try {
582
- user = await de.updateOrmEntity({
583
- entity: User,
584
- where: updateWhere as FilterQuery<User>,
585
- apply: (entity) => {
586
- if (parsed.email !== undefined) {
587
- entity.email = parsed.email
588
- entity.emailHash = emailHash
589
- }
590
- if (parsed.name !== undefined) {
591
- entity.name = parsed.name
592
- }
593
- if (parsed.organizationId !== undefined) {
594
- entity.organizationId = parsed.organizationId
595
- entity.tenantId = tenantId ?? null
596
- }
597
- if (hashed) entity.passwordHash = hashed
598
- },
599
- })
600
- } catch (error) {
601
- if (isUniqueViolation(error)) await throwDuplicateEmailError()
602
- throw error
603
- }
604
- if (!user) throw new CrudHttpError(404, { error: 'User not found' })
582
+ let user!: User
605
583
 
606
- if (hashed) {
607
- await em.nativeDelete(Session, { user: parsed.id })
608
- }
584
+ await withAtomicFlush(em, [
585
+ async () => {
586
+ // Floor check must run inside the transaction so that LockMode.PESSIMISTIC_WRITE locks Role rows properly
587
+ await enforceProtectedRoleFloor(em, userTenantId, parsed.id, {
588
+ deactivating: parsed.isConfirmed === false || isTenantChanging,
589
+ newRoles: parsed.roles,
590
+ }, ctx)
591
+
592
+ // Email is unique per-tenant, not globally (see Migration20260610120000:
593
+ // users_tenant_email_hash_uniq) — a matching email in another tenant must not block
594
+ // the update or leak cross-tenant account existence (#2934). `targetTenantId` is the
595
+ // tenant the user will belong to after this update, so the check follows a move.
596
+ if (parsed.email !== undefined) {
597
+ const duplicate = await findOneWithDecryption(
598
+ em,
599
+ User,
600
+ {
601
+ $or: [{ email: parsed.email }, { emailHash: { $in: emailHashLookupValues(parsed.email) } }],
602
+ deletedAt: null,
603
+ tenantId: targetTenantId,
604
+ id: { $ne: parsed.id } as any,
605
+ } as FilterQuery<User>,
606
+ {},
607
+ { tenantId: null, organizationId: null },
608
+ )
609
+ if (duplicate) await throwDuplicateEmailError()
610
+ }
609
611
 
610
- if (Array.isArray(parsed.roles)) {
611
- await syncUserRoles(em, user, parsed.roles, user.tenantId ? String(user.tenantId) : tenantId ?? null)
612
- }
612
+ try {
613
+ const updated = await de.updateOrmEntity({
614
+ entity: User,
615
+ where: updateWhere as FilterQuery<User>,
616
+ apply: (entity) => {
617
+ if (parsed.email !== undefined) {
618
+ entity.email = parsed.email
619
+ entity.emailHash = emailHash
620
+ }
621
+ if (parsed.name !== undefined) {
622
+ entity.name = parsed.name
623
+ }
624
+ if (parsed.organizationId !== undefined) {
625
+ entity.organizationId = parsed.organizationId
626
+ entity.tenantId = tenantId ?? null
627
+ }
628
+ if (parsed.isConfirmed !== undefined) {
629
+ entity.isConfirmed = parsed.isConfirmed
630
+ }
631
+ if (hashed) entity.passwordHash = hashed
632
+ },
633
+ })
634
+ if (updated) user = updated
635
+ } catch (error) {
636
+ if (isUniqueViolation(error)) await throwDuplicateEmailError()
637
+ throw error
638
+ }
639
+ if (!user) throw new CrudHttpError(404, { error: 'User not found' })
613
640
 
614
- await setCustomFieldsIfAny({
615
- dataEngine: de,
616
- entityId: E.auth.user,
617
- recordId: String(user.id),
618
- organizationId: user.organizationId ? String(user.organizationId) : null,
619
- tenantId: user.tenantId ? String(user.tenantId) : tenantId ?? null,
620
- values: custom,
621
- })
641
+ if (hashed || parsed.isConfirmed === false) {
642
+ await em.nativeDelete(Session, { user: parsed.id })
643
+ }
644
+
645
+ if (Array.isArray(parsed.roles)) {
646
+ await syncUserRoles(em, user, parsed.roles, user.tenantId ? String(user.tenantId) : tenantId ?? null)
647
+ }
648
+
649
+ await setCustomFieldsIfAny({
650
+ dataEngine: de,
651
+ entityId: E.auth.user,
652
+ recordId: String(user.id),
653
+ organizationId: user.organizationId ? String(user.organizationId) : null,
654
+ tenantId: user.tenantId ? String(user.tenantId) : tenantId ?? null,
655
+ values: custom,
656
+ })
657
+ }
658
+ ], { transaction: true })
622
659
 
623
660
  const identifiers = {
624
661
  id: String(user.id),
@@ -706,23 +743,42 @@ const updateUserCommand: CommandHandler<Record<string, unknown>, User> = {
706
743
  const userId = before.id
707
744
  const em = (ctx.container.resolve('em') as EntityManager)
708
745
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
709
- const updated = await de.updateOrmEntity({
710
- entity: User,
711
- where: { id: userId, deletedAt: null } as FilterQuery<User>,
712
- apply: (entity) => {
713
- entity.email = before.email
714
- entity.organizationId = before.organizationId ?? null
715
- entity.tenantId = before.tenantId ?? null
716
- entity.passwordHash = before.passwordHash ?? null
717
- entity.name = before.name ?? null
718
- entity.isConfirmed = before.isConfirmed
719
- },
720
- })
721
746
 
722
- if (updated) {
723
- await syncUserRoles(em, updated, before.roles, before.tenantId)
724
- await em.flush()
725
- }
747
+ // Reverting an update can drop the tenant below a protected role's floor just as the
748
+ // forward path can — restoring an empty `before.roles` on what is now the last admin,
749
+ // or restoring `isConfirmed: false`. Undo is reachable from the audit-log UI, so the
750
+ // guard has to run here too, inside a transaction so the row lock is valid.
751
+ const restoredTenantId = before.tenantId ? String(before.tenantId) : null
752
+
753
+ let updated: User | null = null
754
+ await withAtomicFlush(em, [
755
+ async () => {
756
+ const current = await findOneWithDecryption(em, User, { id: userId, deletedAt: null }, {}, { tenantId: null, organizationId: null })
757
+ const currentTenantId = current?.tenantId ? String(current.tenantId) : null
758
+
759
+ await enforceProtectedRoleFloor(em, currentTenantId, userId, {
760
+ deactivating: before.isConfirmed === false || restoredTenantId !== currentTenantId,
761
+ newRoles: before.roles,
762
+ }, ctx)
763
+
764
+ updated = await de.updateOrmEntity({
765
+ entity: User,
766
+ where: { id: userId, deletedAt: null } as FilterQuery<User>,
767
+ apply: (entity) => {
768
+ entity.email = before.email
769
+ entity.organizationId = before.organizationId ?? null
770
+ entity.tenantId = before.tenantId ?? null
771
+ entity.passwordHash = before.passwordHash ?? null
772
+ entity.name = before.name ?? null
773
+ entity.isConfirmed = before.isConfirmed
774
+ },
775
+ })
776
+
777
+ if (updated) {
778
+ await syncUserRoles(em, updated, before.roles, before.tenantId)
779
+ }
780
+ },
781
+ ], { transaction: true, label: 'auth.users.update.undo' })
726
782
 
727
783
  const reset = buildCustomFieldResetMap(before.custom, after?.custom)
728
784
  if (Object.keys(reset).length) {
@@ -781,12 +837,22 @@ const deleteUserCommand: CommandHandler<{ body?: Record<string, unknown>; query?
781
837
  const em = (ctx.container.resolve('em') as EntityManager)
782
838
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
783
839
  const actorTenantScope = resolveActorTenantScope(ctx)
840
+
841
+ const existing = await findOneWithDecryption(em, User, { id, deletedAt: null }, {}, { tenantId: null, organizationId: null })
842
+ if (!existing) throw new CrudHttpError(404, { error: 'User not found' })
843
+ if (actorTenantScope && existing.tenantId && String(existing.tenantId) !== actorTenantScope) {
844
+ throw new CrudHttpError(404, { error: 'User not found' })
845
+ }
846
+
784
847
  const deleteWhere: Record<string, unknown> = { id, deletedAt: null }
785
848
  if (actorTenantScope) deleteWhere.tenantId = actorTenantScope
786
849
 
787
850
  let user!: User
788
851
  await withAtomicFlush(em, [
789
852
  async () => {
853
+ const userTenantId = existing.tenantId ? String(existing.tenantId) : null
854
+ await enforceProtectedRoleFloor(em, userTenantId, id, { deleting: true }, ctx)
855
+
790
856
  await em.nativeDelete(UserAcl, { user: id })
791
857
  await em.nativeDelete(UserRole, { user: id })
792
858
  await em.nativeDelete(Session, { user: id })
@@ -1085,11 +1151,6 @@ function arrayEquals(left: string[] | undefined, right: string[]): boolean {
1085
1151
  return left.every((value, idx) => value === right[idx])
1086
1152
  }
1087
1153
 
1088
- async function resolveUserTenantId(em: EntityManager, id: string): Promise<string | null> {
1089
- const existing = await findOneWithDecryption(em, User, { id, deletedAt: null }, {}, { tenantId: null, organizationId: null })
1090
- return existing?.tenantId ? String(existing.tenantId) : null
1091
- }
1092
-
1093
1154
  async function throwDuplicateEmailError(): Promise<never> {
1094
1155
  const { translate } = await resolveTranslations()
1095
1156
  const message = translate('auth.users.errors.emailExists', 'Email already in use')
@@ -1099,3 +1160,108 @@ async function throwDuplicateEmailError(): Promise<never> {
1099
1160
  details: [{ path: ['email'], message, code: 'duplicate', origin: 'validation' }],
1100
1161
  })
1101
1162
  }
1163
+
1164
+ type ProtectedRoleFloorOptions = {
1165
+ deactivating?: boolean
1166
+ newRoles?: string[]
1167
+ deleting?: boolean
1168
+ }
1169
+
1170
+ /**
1171
+ * True when the requested mutation can lower a role's active holder count. Guards the
1172
+ * floor check so that ordinary edits (display name, password, email) never take the
1173
+ * tenant-wide role lock — `PUT /api/auth/profile` routes every self-service password
1174
+ * change through `auth.users.update`, so an unconditional lock would serialize them.
1175
+ */
1176
+ function couldReduceActiveHolders(options: ProtectedRoleFloorOptions): boolean {
1177
+ return options.deleting === true || options.deactivating === true || options.newRoles !== undefined
1178
+ }
1179
+
1180
+ async function enforceProtectedRoleFloor(
1181
+ em: EntityManager,
1182
+ tenantId: string | null,
1183
+ userId: string,
1184
+ options: ProtectedRoleFloorOptions,
1185
+ ctx?: CommandRuntimeContext,
1186
+ ): Promise<void> {
1187
+ // Internal automation (CLI, migrations, tenant teardown) must never be blocked by the
1188
+ // floor. Superadmins are deliberately NOT exempt — see the spec's Risks section.
1189
+ if (ctx?.systemActor === true) return
1190
+ if (!couldReduceActiveHolders(options)) return
1191
+
1192
+ const normalizedTenantId = normalizeTenantId(tenantId) ?? null
1193
+ if (!normalizedTenantId) return
1194
+
1195
+ // Find all protected roles in this tenant, acquiring a pessimistic write lock in a deterministic primary key order
1196
+ const protectedRoles = await findWithDecryption(em, Role, {
1197
+ tenantId: normalizedTenantId,
1198
+ minActiveHolders: { $gt: 0 },
1199
+ deletedAt: null
1200
+ }, {
1201
+ lockMode: LockMode.PESSIMISTIC_WRITE,
1202
+ orderBy: { id: 'ASC' }
1203
+ }, { tenantId: normalizedTenantId, organizationId: null })
1204
+
1205
+ if (protectedRoles.length === 0) return
1206
+
1207
+ const { translate } = await resolveTranslations()
1208
+
1209
+ for (const role of protectedRoles) {
1210
+ const minFloor = role.minActiveHolders ?? 0
1211
+ if (minFloor <= 0) continue
1212
+
1213
+ // Active links for this role, scoped to the tenant by the nested user filter so the
1214
+ // database — not a post-filter — enforces isolation. Deliberately NOT populated:
1215
+ // `findWithDecryption` runs `decryptEntityGraph` over loaded relations, which would
1216
+ // decrypt every admin's email and name on each check just to read their ids.
1217
+ const activeLinks = await findWithDecryption(em, UserRole, {
1218
+ role: role.id,
1219
+ deletedAt: null,
1220
+ user: {
1221
+ deletedAt: null,
1222
+ isConfirmed: true,
1223
+ tenantId: normalizedTenantId
1224
+ }
1225
+ }, {}, { tenantId: null, organizationId: null })
1226
+
1227
+ // Count distinct user IDs in the tenant holding this role to prevent overcounting due to duplicate links
1228
+ const activeUserIds = Array.from(
1229
+ new Set(
1230
+ activeLinks
1231
+ .map((link) => {
1232
+ const userRef = link.user as unknown as { id?: string } | string | null | undefined
1233
+ const linkedUserId = typeof userRef === 'string' ? userRef : userRef?.id
1234
+ return linkedUserId ? String(linkedUserId) : null
1235
+ })
1236
+ .filter((id): id is string => !!id)
1237
+ )
1238
+ )
1239
+
1240
+ // Is the target user currently one of the active holders?
1241
+ if (activeUserIds.includes(userId)) {
1242
+ // Determine if they will remain an active holder
1243
+ let willStillBeActiveHolder = true
1244
+
1245
+ if (options.deleting || options.deactivating) {
1246
+ willStillBeActiveHolder = false
1247
+ } else if (options.newRoles !== undefined) {
1248
+ // Checking role list
1249
+ const desiredUnique = Array.from(new Set(options.newRoles.map((r) => r.trim().toLowerCase()).filter(Boolean)))
1250
+ const roleNameLower = role.name.toLowerCase()
1251
+ const hasRoleByName = desiredUnique.includes(roleNameLower) || desiredUnique.includes(String(role.id).toLowerCase())
1252
+ if (!hasRoleByName) {
1253
+ willStillBeActiveHolder = false
1254
+ }
1255
+ }
1256
+
1257
+ if (!willStillBeActiveHolder) {
1258
+ const remaining = activeUserIds.length - 1
1259
+ if (remaining < minFloor) {
1260
+ throw new CrudHttpError(400, {
1261
+ error: translate('auth.users.errors.lastHolderOfCriticalRole', 'Cannot remove the last active holder of role "{roleName}"', { roleName: role.name })
1262
+ })
1263
+ }
1264
+ }
1265
+ }
1266
+ }
1267
+ }
@@ -62,6 +62,9 @@ export class Role {
62
62
  @Property({ name: 'tenant_id', type: 'uuid' })
63
63
  tenantId!: string
64
64
 
65
+ @Property({ name: 'min_active_holders', type: 'integer', default: 0 })
66
+ minActiveHolders: number = 0
67
+
65
68
  @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })
66
69
  createdAt: Date = new Date()
67
70
 
@@ -162,6 +162,7 @@
162
162
  "auth.users.consents.loading": "Einwilligungen werden geladen...",
163
163
  "auth.users.consents.withdrawn": "Widerrufen",
164
164
  "auth.users.errors.emailExists": "E-Mail-Adresse wird bereits verwendet",
165
+ "auth.users.errors.lastHolderOfCriticalRole": "Der letzte aktive Inhaber der Rolle \"{roleName}\" kann nicht entfernt werden",
165
166
  "auth.users.flash.created": "Benutzer erstellt",
166
167
  "auth.users.flash.createdEmailFailed": "Benutzer erstellt, aber die Einladungs-E-Mail konnte nicht gesendet werden. Sie können sie über die Benutzerseite erneut senden.",
167
168
  "auth.users.flash.createdWithInvite": "Benutzer erstellt und Einladung gesendet",
@@ -182,6 +183,8 @@
182
183
  "auth.users.form.errors.noId": "Keine Benutzer-ID angegeben",
183
184
  "auth.users.form.errors.notFound": "Benutzer nicht gefunden",
184
185
  "auth.users.form.errors.widgetsAssign": "Dashboard-Widgets konnten dem neuen Benutzer nicht zugewiesen werden",
186
+ "auth.users.form.field.active": "Aktiv",
187
+ "auth.users.form.field.activeHint": "Das Entfernen des Hakens deaktiviert das Konto: Die Anmeldung ist nicht mehr möglich und alle aktiven Sitzungen werden beendet.",
185
188
  "auth.users.form.field.email": "E-Mail",
186
189
  "auth.users.form.field.name": "Anzeigename",
187
190
  "auth.users.form.field.newPassword": "Neues Passwort",
@@ -203,10 +206,13 @@
203
206
  "auth.users.form.title.create": "Benutzer erstellen",
204
207
  "auth.users.form.title.edit": "Benutzer bearbeiten",
205
208
  "auth.users.list.columns.name": "Anzeigename",
209
+ "auth.users.list.columns.status": "Status",
206
210
  "auth.users.list.error.delete": "Benutzer konnte nicht gelöscht werden",
207
211
  "auth.users.list.error.load": "Benutzer konnten nicht geladen werden",
208
212
  "auth.users.list.filters.name": "Anzeigename",
209
213
  "auth.users.list.filters.namePlaceholder": "Nach Anzeigename filtern",
214
+ "auth.users.list.status.active": "Aktiv",
215
+ "auth.users.list.status.deactivated": "Deaktiviert",
210
216
  "auth.users.widgets.errors.load": "Dashboard-Widgets können nicht geladen werden. Sie können sie später auf der Benutzerseite konfigurieren.",
211
217
  "auth.users.widgets.loading": "Widgets werden geladen…",
212
218
  "auth.users.widgets.mode.hint": "Neue Benutzer erben Widgets von ihren zugewiesenen Rollen. Überschreiben Sie die Auswahl, um eine eigene Zusammenstellung festzulegen.",
@@ -162,6 +162,7 @@
162
162
  "auth.users.consents.loading": "Loading consents...",
163
163
  "auth.users.consents.withdrawn": "Withdrawn",
164
164
  "auth.users.errors.emailExists": "Email already in use",
165
+ "auth.users.errors.lastHolderOfCriticalRole": "Cannot remove the last active holder of role \"{roleName}\"",
165
166
  "auth.users.flash.created": "User created",
166
167
  "auth.users.flash.createdEmailFailed": "User created but invitation email could not be sent. You can resend it from the user page.",
167
168
  "auth.users.flash.createdWithInvite": "User created and invitation sent",
@@ -182,6 +183,8 @@
182
183
  "auth.users.form.errors.noId": "No user ID provided",
183
184
  "auth.users.form.errors.notFound": "User not found",
184
185
  "auth.users.form.errors.widgetsAssign": "Failed to assign dashboard widgets to the new user",
186
+ "auth.users.form.field.active": "Active",
187
+ "auth.users.form.field.activeHint": "Clearing this deactivates the account: the user can no longer sign in and every active session is revoked.",
185
188
  "auth.users.form.field.email": "Email",
186
189
  "auth.users.form.field.name": "Display name",
187
190
  "auth.users.form.field.newPassword": "New Password",
@@ -203,10 +206,13 @@
203
206
  "auth.users.form.title.create": "Create User",
204
207
  "auth.users.form.title.edit": "Edit User",
205
208
  "auth.users.list.columns.name": "Display name",
209
+ "auth.users.list.columns.status": "Status",
206
210
  "auth.users.list.error.delete": "Failed to delete user",
207
211
  "auth.users.list.error.load": "Failed to load users",
208
212
  "auth.users.list.filters.name": "Display name",
209
213
  "auth.users.list.filters.namePlaceholder": "Filter by display name",
214
+ "auth.users.list.status.active": "Active",
215
+ "auth.users.list.status.deactivated": "Deactivated",
210
216
  "auth.users.widgets.errors.load": "Unable to load dashboard widgets. You can configure them later from the user page.",
211
217
  "auth.users.widgets.loading": "Loading widgets…",
212
218
  "auth.users.widgets.mode.hint": "New users inherit widgets from their assigned roles. Override to pick a custom set.",
@@ -162,6 +162,7 @@
162
162
  "auth.users.consents.loading": "Cargando consentimientos...",
163
163
  "auth.users.consents.withdrawn": "Retirado",
164
164
  "auth.users.errors.emailExists": "El correo ya está en uso",
165
+ "auth.users.errors.lastHolderOfCriticalRole": "No se puede eliminar al último titular activo del rol \"{roleName}\"",
165
166
  "auth.users.flash.created": "Usuario creado",
166
167
  "auth.users.flash.createdEmailFailed": "Usuario creado pero no se pudo enviar el correo de invitación. Puede reenviarlo desde la página del usuario.",
167
168
  "auth.users.flash.createdWithInvite": "Usuario creado e invitación enviada",
@@ -182,6 +183,8 @@
182
183
  "auth.users.form.errors.noId": "No se proporcionó ID de usuario",
183
184
  "auth.users.form.errors.notFound": "Usuario no encontrado",
184
185
  "auth.users.form.errors.widgetsAssign": "No se pudieron asignar los widgets del panel al nuevo usuario",
186
+ "auth.users.form.field.active": "Activo",
187
+ "auth.users.form.field.activeHint": "Al desmarcarlo se desactiva la cuenta: el usuario ya no puede iniciar sesión y se revocan todas las sesiones activas.",
185
188
  "auth.users.form.field.email": "Correo electrónico",
186
189
  "auth.users.form.field.name": "Nombre visible",
187
190
  "auth.users.form.field.newPassword": "Nueva contraseña",
@@ -203,10 +206,13 @@
203
206
  "auth.users.form.title.create": "Crear usuario",
204
207
  "auth.users.form.title.edit": "Editar usuario",
205
208
  "auth.users.list.columns.name": "Nombre visible",
209
+ "auth.users.list.columns.status": "Estado",
206
210
  "auth.users.list.error.delete": "No se pudo eliminar el usuario",
207
211
  "auth.users.list.error.load": "No se pudieron cargar los usuarios",
208
212
  "auth.users.list.filters.name": "Nombre visible",
209
213
  "auth.users.list.filters.namePlaceholder": "Filtrar por nombre visible",
214
+ "auth.users.list.status.active": "Activo",
215
+ "auth.users.list.status.deactivated": "Desactivado",
210
216
  "auth.users.widgets.errors.load": "No se pudieron cargar los widgets del panel. Puedes configurarlos más tarde desde la página del usuario.",
211
217
  "auth.users.widgets.loading": "Cargando widgets…",
212
218
  "auth.users.widgets.mode.hint": "Los nuevos usuarios heredan los widgets de las funciones asignadas. Sobrescribe para elegir un conjunto personalizado.",
@@ -162,6 +162,7 @@
162
162
  "auth.users.consents.loading": "동의 정보를 불러오는 중...",
163
163
  "auth.users.consents.withdrawn": "철회함",
164
164
  "auth.users.errors.emailExists": "이미 사용 중인 이메일입니다",
165
+ "auth.users.errors.lastHolderOfCriticalRole": "\"{roleName}\" 역할의 마지막 활성 보유자는 제거할 수 없습니다",
165
166
  "auth.users.flash.created": "사용자가 생성되었습니다",
166
167
  "auth.users.flash.createdEmailFailed": "사용자가 생성되었지만 초대 이메일을 보낼 수 없었습니다. 사용자 페이지에서 다시 보낼 수 있습니다.",
167
168
  "auth.users.flash.createdWithInvite": "사용자가 생성되고 초대가 전송되었습니다",
@@ -182,6 +183,8 @@
182
183
  "auth.users.form.errors.noId": "제공된 사용자 ID가 없습니다",
183
184
  "auth.users.form.errors.notFound": "사용자를 찾을 수 없습니다",
184
185
  "auth.users.form.errors.widgetsAssign": "새 사용자에게 대시보드 위젯을 할당하지 못했습니다",
186
+ "auth.users.form.field.active": "활성",
187
+ "auth.users.form.field.activeHint": "선택을 해제하면 계정이 비활성화됩니다. 사용자는 로그인할 수 없으며 활성 세션이 모두 취소됩니다.",
185
188
  "auth.users.form.field.email": "이메일",
186
189
  "auth.users.form.field.name": "표시 이름",
187
190
  "auth.users.form.field.newPassword": "새 비밀번호",
@@ -203,10 +206,13 @@
203
206
  "auth.users.form.title.create": "사용자 생성",
204
207
  "auth.users.form.title.edit": "사용자 편집",
205
208
  "auth.users.list.columns.name": "표시 이름",
209
+ "auth.users.list.columns.status": "상태",
206
210
  "auth.users.list.error.delete": "사용자 삭제에 실패했습니다",
207
211
  "auth.users.list.error.load": "사용자를 불러오지 못했습니다",
208
212
  "auth.users.list.filters.name": "표시 이름",
209
213
  "auth.users.list.filters.namePlaceholder": "표시 이름으로 필터링",
214
+ "auth.users.list.status.active": "활성",
215
+ "auth.users.list.status.deactivated": "비활성",
210
216
  "auth.users.widgets.errors.load": "대시보드 위젯을 불러올 수 없습니다. 나중에 사용자 페이지에서 구성할 수 있습니다.",
211
217
  "auth.users.widgets.loading": "위젯을 불러오는 중…",
212
218
  "auth.users.widgets.mode.hint": "새 사용자는 할당된 역할에서 위젯을 상속받습니다. 커스텀 세트를 선택하려면 재정의하세요.",
@@ -162,6 +162,7 @@
162
162
  "auth.users.consents.loading": "Ładowanie zgód...",
163
163
  "auth.users.consents.withdrawn": "Wycofana",
164
164
  "auth.users.errors.emailExists": "Adres e-mail jest już używany",
165
+ "auth.users.errors.lastHolderOfCriticalRole": "Nie można usunąć ostatniego aktywnego posiadacza roli \"{roleName}\"",
165
166
  "auth.users.flash.created": "Użytkownik utworzony",
166
167
  "auth.users.flash.createdEmailFailed": "Użytkownik utworzony, ale nie udało się wysłać e-maila z zaproszeniem. Możesz wysłać go ponownie ze strony użytkownika.",
167
168
  "auth.users.flash.createdWithInvite": "Użytkownik utworzony i zaproszenie wysłane",
@@ -182,6 +183,8 @@
182
183
  "auth.users.form.errors.noId": "Nie podano identyfikatora użytkownika",
183
184
  "auth.users.form.errors.notFound": "Nie znaleziono użytkownika",
184
185
  "auth.users.form.errors.widgetsAssign": "Nie udało się przypisać widżetów pulpitu do nowego użytkownika",
186
+ "auth.users.form.field.active": "Aktywny",
187
+ "auth.users.form.field.activeHint": "Odznaczenie dezaktywuje konto: użytkownik nie może się zalogować, a wszystkie aktywne sesje zostają unieważnione.",
185
188
  "auth.users.form.field.email": "Email",
186
189
  "auth.users.form.field.name": "Nazwa wyświetlana",
187
190
  "auth.users.form.field.newPassword": "Nowe hasło",
@@ -203,10 +206,13 @@
203
206
  "auth.users.form.title.create": "Utwórz użytkownika",
204
207
  "auth.users.form.title.edit": "Edytuj użytkownika",
205
208
  "auth.users.list.columns.name": "Nazwa wyświetlana",
209
+ "auth.users.list.columns.status": "Status",
206
210
  "auth.users.list.error.delete": "Nie udało się usunąć użytkownika",
207
211
  "auth.users.list.error.load": "Nie udało się wczytać użytkowników",
208
212
  "auth.users.list.filters.name": "Nazwa wyświetlana",
209
213
  "auth.users.list.filters.namePlaceholder": "Filtruj po nazwie wyświetlanej",
214
+ "auth.users.list.status.active": "Aktywny",
215
+ "auth.users.list.status.deactivated": "Dezaktywowany",
210
216
  "auth.users.widgets.errors.load": "Nie udało się załadować widżetów pulpitu. Możesz skonfigurować je później na stronie użytkownika.",
211
217
  "auth.users.widgets.loading": "Ładowanie widżetów…",
212
218
  "auth.users.widgets.mode.hint": "Nowi użytkownicy dziedziczą widżety z przypisanych ról. Zastąp wybór, aby wskazać własny zestaw.",
@@ -93,7 +93,7 @@ export async function resolveCanonicalStaffAuthContext(
93
93
  if (session.expiresAt.getTime() < Date.now()) return null
94
94
  }
95
95
 
96
- if (!user) return null
96
+ if (!user || user.isConfirmed === false) return null
97
97
 
98
98
  const currentTenantId = normalizeScopeId(user.tenantId ?? null)
99
99
  const currentOrganizationId = normalizeScopeId(user.organizationId ?? null)
@@ -34,7 +34,7 @@ async function ensureRolesInContext(
34
34
  for (const name of roleNames) {
35
35
  const existing = await findOneWithDecryption(em, Role, { name, tenantId }, {}, { tenantId, organizationId: null })
36
36
  if (existing) continue
37
- em.persist(em.create(Role, { name, tenantId, createdAt: new Date() }))
37
+ em.persist(em.create(Role, { name, tenantId, minActiveHolders: name === 'admin' ? 1 : 0, createdAt: new Date() }))
38
38
  }
39
39
  }
40
40
 
@@ -56,6 +56,22 @@
56
56
  "enumItems": [],
57
57
  "mappedType": "uuid"
58
58
  },
59
+ "min_active_holders": {
60
+ "name": "min_active_holders",
61
+ "type": "int",
62
+ "unsigned": false,
63
+ "autoincrement": false,
64
+ "primary": false,
65
+ "nullable": false,
66
+ "unique": false,
67
+ "length": null,
68
+ "precision": null,
69
+ "scale": null,
70
+ "default": "0",
71
+ "comment": null,
72
+ "enumItems": [],
73
+ "mappedType": "integer"
74
+ },
59
75
  "created_at": {
60
76
  "name": "created_at",
61
77
  "type": "timestamptz",
@@ -0,0 +1,14 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260728134212_auth extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`alter table "roles" add "min_active_holders" int not null default 0;`);
7
+ this.addSql(`update "roles" set "min_active_holders" = 1 where "name" = 'admin' and "deleted_at" is null;`);
8
+ }
9
+
10
+ override down(): void | Promise<void> {
11
+ this.addSql(`alter table "roles" drop column "min_active_holders";`);
12
+ }
13
+
14
+ }