diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 074f448..d401559 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -173,6 +173,20 @@ module.exports = class UsersDBApi { const users = await db.users.findByPk(id, { transaction }); + if (data?.app_role && typeof data.app_role === 'object') { + data.app_role = data.app_role.id || data.app_role.value || null; + } + + if (Array.isArray(data?.custom_permissions)) { + data.custom_permissions = data.custom_permissions + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object') return item.id || item.value; + return null; + }) + .filter(Boolean); + } + if (!data?.app_role) { data.app_role = users?.app_role?.id; } @@ -329,6 +343,37 @@ module.exports = class UsersDBApi { output.app_role_permissions = output.app_role.permissions || []; } + const productionPresentationAccess = + await db.production_presentation_access.findAll({ + where: { userId: output.id }, + include: [ + { + association: 'project', + attributes: ['id', 'name', 'slug'], + where: { production_presentation_visibility: 'private' }, + required: true, + }, + ], + transaction, + }); + + output.allowed_private_production_project_ids = + productionPresentationAccess + .map((row) => { + const plain = + typeof row.get === 'function' ? row.get({ plain: true }) : row; + const project = plain.project; + if (!project?.id) return null; + + return { + id: project.id, + label: `${project.name} (${project.slug})`, + name: project.name, + slug: project.slug, + }; + }) + .filter(Boolean); + return output; } diff --git a/backend/src/services/users.js b/backend/src/services/users.js index a02e86a..bc52c0d 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -27,6 +27,13 @@ class UsersService extends BaseUsersService { .filter(Boolean); } + static normalizeRoleId(value) { + if (!value) return null; + if (typeof value === 'string') return value; + if (typeof value === 'object') return value.id || value.value || null; + return null; + } + static async createProductionPresentationAccessForPublicUser({ user, data, @@ -44,9 +51,10 @@ class UsersService extends BaseUsersService { data.allowed_private_production_project_ids, ); - if (!selectedProjectIds.length || !data.app_role) return; + const roleId = this.normalizeRoleId(data.app_role); + if (!selectedProjectIds.length || !roleId) return; - const role = await db.roles.findByPk(data.app_role, { transaction }); + const role = await db.roles.findByPk(roleId, { transaction }); if (role?.name !== 'Public') return; const privateProjects = await db.projects.findAll({ @@ -76,6 +84,27 @@ class UsersService extends BaseUsersService { ); } + static async updateProductionPresentationAccessForPublicUser({ + user, + data, + currentUser, + transaction, + }) { + if (!user?.id) return; + + await db.production_presentation_access.destroy({ + where: { userId: user.id }, + transaction, + }); + + await this.createProductionPresentationAccessForPublicUser({ + user, + data, + currentUser, + transaction, + }); + } + /** * Create user with email validation and optional invitation */ @@ -135,6 +164,43 @@ class UsersService extends BaseUsersService { } } + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + const existingUser = await UsersDBApi.findBy({ id }, { transaction }); + + if (!existingUser) { + throw new ValidationError('UsersNotFound'); + } + + const user = await UsersDBApi.update(id, data, { + currentUser, + transaction, + }); + + if ( + Object.prototype.hasOwnProperty.call( + data, + 'allowed_private_production_project_ids', + ) + ) { + await this.updateProductionPresentationAccessForPublicUser({ + user, + data, + currentUser, + transaction, + }); + } + + await transaction.commit(); + return user; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + /** * Remove user with self-deletion and permission checks */ diff --git a/frontend/src/components/SelectFieldMany.tsx b/frontend/src/components/SelectFieldMany.tsx index 4c46d00..7d83f8f 100644 --- a/frontend/src/components/SelectFieldMany.tsx +++ b/frontend/src/components/SelectFieldMany.tsx @@ -1,9 +1,10 @@ -import React, { useEffect, useId, useState } from 'react'; +import React, { useEffect, useId, useRef, useState } from 'react'; import { AsyncPaginate } from 'react-select-async-paginate'; import axios from 'axios'; const areStringArraysEqual = (left = [], right = []) => - left.length === right.length && left.every((value, index) => value === right[index]); + left.length === right.length && + left.every((value, index) => value === right[index]); const areSelectOptionsEqual = (left = [], right = []) => left.length === right.length && @@ -21,6 +22,7 @@ export const SelectFieldMany = ({ showField, }) => { const [value, setValue] = useState([]); + const appliedOptionsSignatureRef = useRef(null); const PAGE_SIZE = 100; useEffect(() => { @@ -47,16 +49,26 @@ export const SelectFieldMany = ({ label: el[showField], })); const selectedIds = options.map((el) => el.id); - - setValue((currentValue) => - areSelectOptionsEqual(currentValue, selectedOptions) - ? currentValue - : selectedOptions, + const optionsSignature = selectedIds.join('|'); + const shouldApplyInitialOptions = + appliedOptionsSignatureRef.current !== optionsSignature; + const fieldValueMatchesInitialOptions = areStringArraysEqual( + field.value, + selectedIds, ); - if (!areStringArraysEqual(field.value, selectedIds)) { + if (shouldApplyInitialOptions) { + appliedOptionsSignatureRef.current = optionsSignature; form.setFieldValue(field.name, selectedIds, false); } + + if (shouldApplyInitialOptions || fieldValueMatchesInitialOptions) { + setValue((currentValue) => + areSelectOptionsEqual(currentValue, selectedOptions) + ? currentValue + : selectedOptions, + ); + } } }, [field.name, field.value, form, options, showField]); @@ -65,11 +77,12 @@ export const SelectFieldMany = ({ label: data.label, }); - const handleChange = (data: Array<{ value: string; label: string }>) => { - setValue(data); + const handleChange = (data: Array<{ value: string; label: string }> | null) => { + const selectedOptions = data || []; + setValue(selectedOptions); form.setFieldValue( field.name, - data.map((el) => el?.value || null), + selectedOptions.map((el) => el?.value || null), ); }; diff --git a/frontend/src/pages/users/users-edit.tsx b/frontend/src/pages/users/users-edit.tsx index 8490992..2d96884 100644 --- a/frontend/src/pages/users/users-edit.tsx +++ b/frontend/src/pages/users/users-edit.tsx @@ -32,6 +32,10 @@ const initVals = { avatar: [] as Array<{ id: string; publicUrl: string }>, app_role: null as { id: string; name: string } | null, custom_permissions: [] as Array<{ id: string; name: string }>, + allowed_private_production_project_ids: [] as Array<{ + id: string; + label: string; + }>, password: '', }; @@ -44,10 +48,20 @@ const EditUsersPage = () => { fetchAction: fetch, initialValues: initVals, }); + const [selectedRoleLabel, setSelectedRoleLabel] = React.useState(''); + + React.useEffect(() => { + setSelectedRoleLabel(initialValues.app_role?.name || ''); + }, [initialValues.app_role?.name]); const handleSubmit = async (data: typeof initVals) => { if (id) { - await dispatch(update({ id, data })); + const payload = { ...data }; + if (selectedRoleLabel !== 'Public') { + payload.allowed_private_production_project_ids = []; + } + + await dispatch(update({ id, data: payload })); await router.push('/users/users-list'); } }; @@ -71,85 +85,119 @@ const EditUsersPage = () => { initialValues={initialValues} onSubmit={(values) => handleSubmit(values)} > -
- - - + {({ setFieldValue }) => ( + + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + { + const label = option?.label || ''; + setSelectedRoleLabel(label); + if (label !== 'Public') { + setFieldValue( + 'allowed_private_production_project_ids', + [], + ); + } + }} + /> + - - - + {selectedRoleLabel === 'Public' && ( + + + + )} - - - + + + - - - - - router.push('/users/users-list')} - /> - - + + + + + + + + + router.push('/users/users-list')} + /> + + + )} diff --git a/frontend/src/types/entities.ts b/frontend/src/types/entities.ts index a451b8e..ce9947a 100644 --- a/frontend/src/types/entities.ts +++ b/frontend/src/types/entities.ts @@ -25,7 +25,9 @@ export interface User extends BaseEntity { app_role?: Role | null; custom_permissions?: PermissionEntity[]; allowedPrivateProductionSlugs?: string[]; - allowed_private_production_project_ids?: string[]; + allowed_private_production_project_ids?: Array< + string | { id: string; label?: string; name?: string; slug?: string } + >; password?: string; }