added posibility to add or exclude private presentations for Public role users

This commit is contained in:
Dmitri 2026-06-26 12:41:39 +02:00
parent 222d08fbca
commit 788342808f
5 changed files with 259 additions and 85 deletions

View File

@ -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;
}

View File

@ -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
*/

View File

@ -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<string | null>(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),
);
};

View File

@ -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)}
>
<Form>
<FormField label='First Name'>
<Field name='firstName' placeholder='First Name' />
</FormField>
{({ setFieldValue }) => (
<Form>
<FormField label='First Name'>
<Field name='firstName' placeholder='First Name' />
</FormField>
<FormField label='Last Name'>
<Field name='lastName' placeholder='Last Name' />
</FormField>
<FormField label='Last Name'>
<Field name='lastName' placeholder='Last Name' />
</FormField>
<FormField label='Phone Number'>
<Field name='phoneNumber' placeholder='Phone Number' />
</FormField>
<FormField label='Phone Number'>
<Field name='phoneNumber' placeholder='Phone Number' />
</FormField>
<FormField label='E-Mail'>
<Field name='email' placeholder='E-Mail' />
</FormField>
<FormField label='E-Mail'>
<Field name='email' placeholder='E-Mail' />
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field name='disabled' id='disabled' component={SwitchField} />
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field
name='disabled'
id='disabled'
component={SwitchField}
/>
</FormField>
<FormField>
<Field
label='Avatar'
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
/>
</FormField>
<FormField>
<Field
label='Avatar'
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
/>
</FormField>
<FormField label='App Role' labelFor='app_role'>
<Field
name='app_role'
id='app_role'
component={SelectField}
options={initialValues.app_role}
itemRef={'roles'}
showField={'name'}
/>
</FormField>
<FormField label='App Role' labelFor='app_role'>
<Field
name='app_role'
id='app_role'
component={SelectField}
options={initialValues.app_role}
itemRef={'roles'}
showField={'name'}
onOptionChange={(option) => {
const label = option?.label || '';
setSelectedRoleLabel(label);
if (label !== 'Public') {
setFieldValue(
'allowed_private_production_project_ids',
[],
);
}
}}
/>
</FormField>
<FormField
label='Custom Permissions'
labelFor='custom_permissions'
>
<Field
name='custom_permissions'
id='custom_permissions'
component={SelectFieldMany}
options={initialValues.custom_permissions}
itemRef={'permissions'}
showField={'name'}
/>
</FormField>
{selectedRoleLabel === 'Public' && (
<FormField
label='Allowed Private Production Presentations'
labelFor='allowed_private_production_project_ids'
>
<Field
name='allowed_private_production_project_ids'
id='allowed_private_production_project_ids'
component={SelectFieldMany}
options={
initialValues.allowed_private_production_project_ids
}
itemRef='runtime-access/private-production-presentations'
showField='label'
/>
</FormField>
)}
<FormField label='Password'>
<Field name='password' placeholder='password' />
</FormField>
<FormField
label='Custom Permissions'
labelFor='custom_permissions'
>
<Field
name='custom_permissions'
id='custom_permissions'
component={SelectFieldMany}
options={initialValues.custom_permissions}
itemRef={'permissions'}
showField={'name'}
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() => router.push('/users/users-list')}
/>
</BaseButtons>
</Form>
<FormField label='Password'>
<Field name='password' placeholder='password' />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />
<BaseButton type='reset' color='info' outline label='Reset' />
<BaseButton
type='reset'
color='danger'
outline
label='Cancel'
onClick={() => router.push('/users/users-list')}
/>
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>

View File

@ -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;
}