Merge guest flow into unauth flow (#1233)
* removed some portions of the guest access flow in favor of the unauthed flow w/ burnettk * removed guest access from auth flow in backend w/ burnettk * updated frontend to use new public api for guest tasks * fixed tests and updated get task url script to choose between public and non public urls * removed old guest task support from frontend * return 404 when a task cannot be found w/ burnettk * fixed typo in group list tiles w/ burnettk * added cypress tests for public formg w/ burnettk * display metadata key for urls instead of values w/ burnettk * updated permissions for acceptance testss w/ burnettk * set up permissions for public group if it is in the list and login and logout admin user in ci to ensure permissions are set w/ burnettk --------- Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
parent
b78e81a5dc
commit
709a0c8492
|
@ -2652,6 +2652,18 @@ paths:
|
|||
enum:
|
||||
- synchronous
|
||||
- asynchronous
|
||||
get:
|
||||
tags:
|
||||
- Tasks
|
||||
operationId: spiffworkflow_backend.routes.public_controller.form_show
|
||||
summary: Gets one task that a user wants to complete
|
||||
responses:
|
||||
"200":
|
||||
description: One task
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Task"
|
||||
put:
|
||||
tags:
|
||||
- Tasks
|
||||
|
|
|
@ -8,9 +8,16 @@ users:
|
|||
groups:
|
||||
admin:
|
||||
users: [ciadmin1@spiffworkflow.org]
|
||||
spiff_public:
|
||||
users: []
|
||||
|
||||
permissions:
|
||||
admin:
|
||||
groups: [admin]
|
||||
actions: [create, read, update, delete]
|
||||
uri: /*
|
||||
|
||||
public_access:
|
||||
groups: [spiff_public]
|
||||
actions: [create, read, update]
|
||||
uri: /public/*
|
||||
|
|
|
@ -48,5 +48,5 @@ permissions:
|
|||
|
||||
public_access:
|
||||
groups: [spiff_public]
|
||||
actions: [read, create]
|
||||
actions: [read, create, update]
|
||||
uri: /public/*
|
||||
|
|
|
@ -16,12 +16,10 @@ from spiffworkflow_backend.exceptions.error import InvalidRedirectUrlError
|
|||
from spiffworkflow_backend.exceptions.error import MissingAccessTokenError
|
||||
from spiffworkflow_backend.exceptions.error import TokenExpiredError
|
||||
from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX
|
||||
from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP
|
||||
from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_GROUP
|
||||
from spiffworkflow_backend.models.group import GroupModel
|
||||
from spiffworkflow_backend.models.service_account import ServiceAccountModel
|
||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
|
||||
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
from spiffworkflow_backend.services.authentication_service import AuthenticationService
|
||||
|
@ -130,15 +128,6 @@ def login(
|
|||
)
|
||||
return redirect(redirect_url)
|
||||
|
||||
if process_instance_id and task_guid and TaskModel.task_guid_allows_guest(task_guid, process_instance_id):
|
||||
AuthenticationService.create_guest_token(
|
||||
username=SPIFF_GUEST_USER,
|
||||
group_identifier=SPIFF_GUEST_GROUP,
|
||||
auth_token_properties={"only_guest_task_completion": True, "process_instance_id": process_instance_id},
|
||||
authentication_identifier=authentication_identifier,
|
||||
)
|
||||
return redirect(redirect_url)
|
||||
|
||||
state = AuthenticationService.generate_state(redirect_url, authentication_identifier)
|
||||
login_redirect_url = AuthenticationService().get_login_redirect_url(
|
||||
state.decode("UTF-8"), authentication_identifier=authentication_identifier
|
||||
|
@ -298,27 +287,6 @@ def _clear_auth_tokens_from_thread_local_data() -> None:
|
|||
delattr(tld, "user_has_logged_out")
|
||||
|
||||
|
||||
def _force_logout_user_if_necessary(user_model: UserModel | None, decoded_token: dict) -> bool:
|
||||
"""Logs out a guest user if certain criteria gets met.
|
||||
|
||||
* if the user is a no auth guest and we have auth enabled
|
||||
* if the user is a guest and goes somewhere else that does not allow guests
|
||||
"""
|
||||
if user_model is not None:
|
||||
if (
|
||||
not current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED")
|
||||
and user_model.username == SPIFF_NO_AUTH_USER
|
||||
and user_model.service_id == "spiff_guest_service_id"
|
||||
) or (
|
||||
user_model.username == SPIFF_GUEST_USER
|
||||
and user_model.service_id == "spiff_guest_service_id"
|
||||
and not AuthorizationService.request_allows_guest_access(decoded_token)
|
||||
):
|
||||
AuthenticationService.set_user_has_logged_out()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _find_token_from_request(token: str | None) -> dict[str, str | None]:
|
||||
api_key = None
|
||||
if not token and "Authorization" in request.headers:
|
||||
|
@ -356,10 +324,6 @@ def _get_user_model_from_token(decoded_token: dict) -> UserModel | None:
|
|||
user_model = _get_user_from_decoded_internal_token(decoded_token)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Exception in verify_token getting user from decoded internal token. {e}")
|
||||
|
||||
# if the user is forced logged out then stop processing the token
|
||||
if _force_logout_user_if_necessary(user_model, decoded_token):
|
||||
return None
|
||||
else:
|
||||
user_info = None
|
||||
authentication_identifier = _get_authentication_identifier_from_request()
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
from typing import TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
import flask.wrappers
|
||||
|
@ -21,19 +23,23 @@ from spiffworkflow_backend.background_processing.celery_tasks.process_instance_t
|
|||
)
|
||||
from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator
|
||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError
|
||||
from spiffworkflow_backend.exceptions.error import HumanTaskNotFoundError
|
||||
from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError
|
||||
from spiffworkflow_backend.exceptions.process_entity_not_found_error import ProcessEntityNotFoundError
|
||||
from spiffworkflow_backend.models.db import db
|
||||
from spiffworkflow_backend.models.human_task import HumanTaskModel
|
||||
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
|
||||
from spiffworkflow_backend.models.principal import PrincipalModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
|
||||
from spiffworkflow_backend.models.process_instance_file_data import ProcessInstanceFileDataModel
|
||||
from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
||||
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
|
||||
from spiffworkflow_backend.models.reference_cache import ReferenceSchema
|
||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||
from spiffworkflow_backend.services.authentication_service import AuthenticationService # noqa: F401
|
||||
from spiffworkflow_backend.models.task import TaskModel
|
||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
||||
from spiffworkflow_backend.services.git_service import GitCommandError
|
||||
from spiffworkflow_backend.services.git_service import GitService
|
||||
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||
|
@ -42,12 +48,24 @@ from spiffworkflow_backend.services.process_instance_processor import ProcessIns
|
|||
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService
|
||||
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
|
||||
from spiffworkflow_backend.services.process_model_service import ProcessModelService
|
||||
from spiffworkflow_backend.services.spec_file_service import SpecFileService
|
||||
from spiffworkflow_backend.services.task_service import TaskModelError
|
||||
from spiffworkflow_backend.services.task_service import TaskService
|
||||
|
||||
process_api_blueprint = Blueprint("process_api", __name__)
|
||||
|
||||
|
||||
class TaskDataSelectOption(TypedDict):
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class ReactJsonSchemaSelectOption(TypedDict):
|
||||
type: str
|
||||
title: str
|
||||
enum: list[str]
|
||||
|
||||
|
||||
def permissions_check(body: dict[str, dict[str, list[str]]]) -> flask.wrappers.Response:
|
||||
if "requests_to_check" not in body:
|
||||
raise (
|
||||
|
@ -557,3 +575,211 @@ def _get_spiff_task_from_processor(
|
|||
)
|
||||
)
|
||||
return spiff_task
|
||||
|
||||
|
||||
def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int) -> TaskModel:
|
||||
task_model: TaskModel | None = TaskModel.query.filter_by(guid=task_guid, process_instance_id=process_instance_id).first()
|
||||
if task_model is None:
|
||||
raise ApiError(
|
||||
error_code="task_not_found",
|
||||
message=f"Cannot find a task with guid '{task_guid}' for process instance '{process_instance_id}'",
|
||||
status_code=400,
|
||||
)
|
||||
return task_model
|
||||
|
||||
|
||||
def _get_task_model_for_request(
|
||||
process_instance_id: int,
|
||||
task_guid: str = "next",
|
||||
with_form_data: bool = False,
|
||||
) -> TaskModel:
|
||||
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
||||
|
||||
if process_instance.status == ProcessInstanceStatus.suspended.value:
|
||||
raise ApiError(
|
||||
error_code="error_suspended",
|
||||
message="The process instance is suspended",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
process_model = _get_process_model(
|
||||
process_instance.process_model_identifier,
|
||||
)
|
||||
|
||||
task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id)
|
||||
task_definition = task_model.task_definition
|
||||
|
||||
can_complete = False
|
||||
try:
|
||||
AuthorizationService.assert_user_can_complete_task(process_instance.id, task_model.guid, g.user)
|
||||
can_complete = True
|
||||
except (
|
||||
HumanTaskNotFoundError,
|
||||
UserDoesNotHaveAccessToTaskError,
|
||||
HumanTaskAlreadyCompletedError,
|
||||
):
|
||||
can_complete = False
|
||||
|
||||
task_model.process_model_display_name = process_model.display_name
|
||||
task_model.process_model_identifier = process_model.id
|
||||
task_model.typename = task_definition.typename
|
||||
task_model.can_complete = can_complete
|
||||
task_model.name_for_display = TaskService.get_name_for_display(task_definition)
|
||||
extensions = TaskService.get_extensions_from_task_model(task_model)
|
||||
|
||||
if with_form_data:
|
||||
task_process_identifier = task_model.bpmn_process.bpmn_process_definition.bpmn_identifier
|
||||
process_model_with_form = process_model
|
||||
|
||||
refs = SpecFileService.get_references_for_process(process_model_with_form)
|
||||
all_processes = [i.identifier for i in refs]
|
||||
if task_process_identifier not in all_processes:
|
||||
top_bpmn_process = TaskService.bpmn_process_for_called_activity_or_top_level_process(task_model)
|
||||
bpmn_file_full_path = ProcessInstanceProcessor.bpmn_file_full_path_from_bpmn_process_identifier(
|
||||
top_bpmn_process.bpmn_process_definition.bpmn_identifier
|
||||
)
|
||||
relative_path = os.path.relpath(bpmn_file_full_path, start=FileSystemService.root_path())
|
||||
process_model_relative_path = os.path.dirname(relative_path)
|
||||
process_model_with_form = ProcessModelService.get_process_model_from_relative_path(process_model_relative_path)
|
||||
|
||||
form_schema_file_name = ""
|
||||
form_ui_schema_file_name = ""
|
||||
task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels(process_instance_id, task_model.guid)
|
||||
|
||||
if "properties" in extensions:
|
||||
properties = extensions["properties"]
|
||||
if "formJsonSchemaFilename" in properties:
|
||||
form_schema_file_name = properties["formJsonSchemaFilename"]
|
||||
if "formUiSchemaFilename" in properties:
|
||||
form_ui_schema_file_name = properties["formUiSchemaFilename"]
|
||||
|
||||
task_draft_data = TaskService.task_draft_data_from_task_model(task_model)
|
||||
|
||||
saved_form_data = None
|
||||
if task_draft_data is not None:
|
||||
saved_form_data = task_draft_data.get_saved_form_data()
|
||||
|
||||
task_model.data = task_model.get_data()
|
||||
task_model.saved_form_data = saved_form_data
|
||||
if task_definition.typename == "UserTask":
|
||||
if not form_schema_file_name:
|
||||
raise (
|
||||
ApiError(
|
||||
error_code="missing_form_file",
|
||||
message=f"Cannot find a form file for process_instance_id: {process_instance_id}, task_guid: {task_guid}",
|
||||
status_code=400,
|
||||
)
|
||||
)
|
||||
|
||||
form_dict = _prepare_form_data(
|
||||
form_file=form_schema_file_name,
|
||||
task_model=task_model,
|
||||
process_model=process_model_with_form,
|
||||
revision=process_instance.bpmn_version_control_identifier,
|
||||
)
|
||||
_update_form_schema_with_task_data_as_needed(form_dict, task_model.data)
|
||||
task_model.form_schema = form_dict
|
||||
|
||||
if form_ui_schema_file_name:
|
||||
ui_form_contents = _prepare_form_data(
|
||||
form_file=form_ui_schema_file_name,
|
||||
task_model=task_model,
|
||||
process_model=process_model_with_form,
|
||||
revision=process_instance.bpmn_version_control_identifier,
|
||||
)
|
||||
task_model.form_ui_schema = ui_form_contents
|
||||
else:
|
||||
task_model.form_ui_schema = {}
|
||||
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model.form_ui_schema, task_model.data)
|
||||
|
||||
# it should be safe to add instructions to the task spec here since we are never commiting it back to the db
|
||||
extensions["instructionsForEndUser"] = JinjaService.render_instructions_for_end_user(task_model, extensions)
|
||||
|
||||
task_model.extensions = extensions
|
||||
return task_model
|
||||
|
||||
|
||||
# originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches
|
||||
def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None:
|
||||
for k, value in in_dict.items():
|
||||
if "anyOf" == k:
|
||||
# value will look like the array on the right of "anyOf": ["options_from_task_data_var:awesome_options"]
|
||||
if isinstance(value, list):
|
||||
if len(value) == 1:
|
||||
first_element_in_value_list = value[0]
|
||||
if isinstance(first_element_in_value_list, str):
|
||||
if first_element_in_value_list.startswith("options_from_task_data_var:"):
|
||||
task_data_var = first_element_in_value_list.replace("options_from_task_data_var:", "")
|
||||
|
||||
if task_data_var not in task_data:
|
||||
message = (
|
||||
"Error building form. Attempting to create a selection list with options from"
|
||||
f" variable '{task_data_var}' but it doesn't exist in the Task Data."
|
||||
)
|
||||
raise ApiError(
|
||||
error_code="missing_task_data_var",
|
||||
message=message,
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
select_options_from_task_data = task_data.get(task_data_var)
|
||||
if select_options_from_task_data == []:
|
||||
raise ApiError(
|
||||
error_code="invalid_form_data",
|
||||
message=(
|
||||
"This form depends on variables, but at least one variable was empty. The"
|
||||
f" variable '{task_data_var}' must be a list with at least one element."
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
if isinstance(select_options_from_task_data, str):
|
||||
raise ApiError(
|
||||
error_code="invalid_form_data",
|
||||
message=(
|
||||
"This form depends on enum variables, but at least one variable was a string."
|
||||
f" The variable '{task_data_var}' must be a list with at least one element."
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
if isinstance(select_options_from_task_data, list):
|
||||
if all("value" in d and "label" in d for d in select_options_from_task_data):
|
||||
|
||||
def map_function(
|
||||
task_data_select_option: TaskDataSelectOption,
|
||||
) -> ReactJsonSchemaSelectOption:
|
||||
return {
|
||||
"type": "string",
|
||||
"enum": [task_data_select_option["value"]],
|
||||
"title": task_data_select_option["label"],
|
||||
}
|
||||
|
||||
options_for_react_json_schema_form = list(
|
||||
map(
|
||||
map_function,
|
||||
select_options_from_task_data,
|
||||
)
|
||||
)
|
||||
|
||||
in_dict[k] = options_for_react_json_schema_form
|
||||
elif isinstance(value, dict):
|
||||
_update_form_schema_with_task_data_as_needed(value, task_data)
|
||||
elif isinstance(value, list):
|
||||
for o in value:
|
||||
if isinstance(o, dict):
|
||||
_update_form_schema_with_task_data_as_needed(o, task_data)
|
||||
|
||||
|
||||
def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: dict | None, task_data: dict) -> None:
|
||||
if form_ui_schema is None:
|
||||
return
|
||||
if task_data and "form_ui_hidden_fields" in task_data:
|
||||
hidden_fields = task_data["form_ui_hidden_fields"]
|
||||
for hidden_field in hidden_fields:
|
||||
hidden_field_parts = hidden_field.split(".")
|
||||
relevant_depth_of_ui_schema = form_ui_schema
|
||||
for ii, hidden_field_part in enumerate(hidden_field_parts):
|
||||
if hidden_field_part not in relevant_depth_of_ui_schema:
|
||||
relevant_depth_of_ui_schema[hidden_field_part] = {}
|
||||
relevant_depth_of_ui_schema = relevant_depth_of_ui_schema[hidden_field_part]
|
||||
if len(hidden_field_parts) == ii + 1:
|
||||
relevant_depth_of_ui_schema["ui:widget"] = "hidden"
|
||||
|
|
|
@ -8,11 +8,14 @@ from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # type: ignore
|
|||
from SpiffWorkflow.util.task import TaskState # type: ignore
|
||||
|
||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||
from spiffworkflow_backend.models.db import db
|
||||
from spiffworkflow_backend.models.human_task import HumanTaskModel
|
||||
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
|
||||
from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
||||
from spiffworkflow_backend.models.task import TaskModel
|
||||
from spiffworkflow_backend.models.task_definition import TaskDefinitionModel # noqa: F401
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _get_task_model_for_request
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _prepare_form_data
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared
|
||||
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||
|
@ -52,9 +55,15 @@ def message_form_show(
|
|||
|
||||
process_model = ProcessModelService.get_process_model(message_triggerable_process_model.process_model_identifier)
|
||||
extensions = matching_start_tasks[0].task_spec.extensions
|
||||
response_body = _get_form_and_prepare_data(extensions=extensions, process_model=process_model)
|
||||
form = _get_form_and_prepare_data(extensions=extensions, process_model=process_model)
|
||||
|
||||
return make_response(jsonify(response_body), 200)
|
||||
response_json = {
|
||||
"form": form,
|
||||
"task_guid": None,
|
||||
"process_instance_id": None,
|
||||
"confirmation_message_markdown": None,
|
||||
}
|
||||
return make_response(jsonify(response_json), 200)
|
||||
|
||||
|
||||
def message_form_submit(
|
||||
|
@ -107,13 +116,21 @@ def form_submit(
|
|||
|
||||
next_form_contents = None
|
||||
next_task_guid = None
|
||||
|
||||
next_task_assigned_to_me = None
|
||||
if "next_task_assigned_to_me" in response_item:
|
||||
next_task_assigned_to_me = response_item["next_task_assigned_to_me"]
|
||||
elif "next_task" in response_item:
|
||||
task_model = TaskModel.query.filter_by(guid=str(response_item["next_task"].id)).first()
|
||||
if _assign_task_if_guest(task_model):
|
||||
next_task_assigned_to_me = response_item["next_task"]
|
||||
|
||||
if next_task_assigned_to_me is not None:
|
||||
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first()
|
||||
next_task_guid = next_task_assigned_to_me.id
|
||||
next_task_guid = str(next_task_assigned_to_me.id)
|
||||
process_model = ProcessModelService.get_process_model(process_instance.process_model_identifier)
|
||||
next_form_contents = _get_form_and_prepare_data(
|
||||
process_model=process_model, task_guid=next_task_assigned_to_me.task_guid, process_instance=process_instance
|
||||
process_model=process_model, task_guid=next_task_guid, process_instance=process_instance
|
||||
)
|
||||
|
||||
response_json = {
|
||||
|
@ -125,6 +142,45 @@ def form_submit(
|
|||
return make_response(jsonify(response_json), 200)
|
||||
|
||||
|
||||
def form_show(
|
||||
process_instance_id: int,
|
||||
task_guid: str,
|
||||
) -> flask.wrappers.Response:
|
||||
task_model = _get_task_model_for_request(
|
||||
process_instance_id=process_instance_id,
|
||||
task_guid=task_guid,
|
||||
with_form_data=True,
|
||||
)
|
||||
if task_model is None or not task_model.allows_guest(task_model.process_instance_id):
|
||||
raise (
|
||||
ApiError(
|
||||
error_code="task_not_found",
|
||||
message=f"Could not find completable task for {task_guid} in process_instance {process_instance_id}.",
|
||||
status_code=404,
|
||||
)
|
||||
)
|
||||
|
||||
_assign_task_if_guest(task_model)
|
||||
|
||||
instructions_for_end_user = None
|
||||
if task_model.extensions:
|
||||
instructions_for_end_user = task_model.extensions["instructionsForEndUser"]
|
||||
|
||||
form = {
|
||||
"form_schema": task_model.form_schema,
|
||||
"form_ui_schema": task_model.form_ui_schema,
|
||||
"instructions_for_end_user": instructions_for_end_user,
|
||||
}
|
||||
|
||||
response_json = {
|
||||
"form": form,
|
||||
"task_guid": task_guid,
|
||||
"process_instance_id": process_instance_id,
|
||||
"confirmation_message_markdown": None,
|
||||
}
|
||||
return make_response(jsonify(response_json), 200)
|
||||
|
||||
|
||||
def _get_form_and_prepare_data(
|
||||
process_model: ProcessModelInfo,
|
||||
extensions: dict | None = None,
|
||||
|
@ -170,3 +226,34 @@ def _get_form_and_prepare_data(
|
|||
extension_list["instructionsForEndUser"], task_data=task_data
|
||||
)
|
||||
return form_contents
|
||||
|
||||
|
||||
def _assign_task_if_guest(task_model: TaskModel) -> bool:
|
||||
if not task_model.allows_guest(task_model.process_instance_id):
|
||||
return False
|
||||
|
||||
human_task_user = (
|
||||
HumanTaskUserModel.query.filter_by(user_id=g.user.id)
|
||||
.join(HumanTaskModel, HumanTaskModel.id == HumanTaskUserModel.human_task_id)
|
||||
.filter(HumanTaskModel.task_guid == task_model.guid)
|
||||
.first()
|
||||
)
|
||||
if human_task_user is None:
|
||||
human_task = HumanTaskModel.query.filter_by(
|
||||
task_guid=task_model.guid, process_instance_id=task_model.process_instance_id
|
||||
).first()
|
||||
if human_task is None:
|
||||
raise (
|
||||
ApiError(
|
||||
error_code="completable_task_not_found",
|
||||
message=(
|
||||
f"Could not find completable task for {task_model.guid} in process_instance"
|
||||
f" {task_model.process_instance_id}."
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
)
|
||||
human_task_user = HumanTaskUserModel(user_id=g.user.id, human_task=human_task)
|
||||
db.session.add(human_task_user)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import json
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from typing import TypedDict
|
||||
|
||||
import flask.wrappers
|
||||
import sentry_sdk
|
||||
|
@ -27,15 +25,12 @@ from sqlalchemy.orm.util import AliasedClass
|
|||
from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator
|
||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError
|
||||
from spiffworkflow_backend.exceptions.error import HumanTaskNotFoundError
|
||||
from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError
|
||||
from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
|
||||
from spiffworkflow_backend.models.db import db
|
||||
from spiffworkflow_backend.models.group import GroupModel
|
||||
from spiffworkflow_backend.models.human_task import HumanTaskModel
|
||||
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
|
||||
from spiffworkflow_backend.models.json_data import JsonDataDict # noqa: F401
|
||||
from spiffworkflow_backend.models.json_data import JsonDataModel # noqa: F401
|
||||
from spiffworkflow_backend.models.json_data import JsonDataModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
|
||||
|
@ -52,33 +47,22 @@ from spiffworkflow_backend.routes.process_api_blueprint import _find_principal_o
|
|||
from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_for_me_or_raise
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _prepare_form_data
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _get_task_model_for_request
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _get_task_model_from_guid_or_raise
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _munge_form_ui_schema_based_on_hidden_fields_in_task_data
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _update_form_schema_with_task_data_as_needed
|
||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
|
||||
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
||||
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
|
||||
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError
|
||||
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService
|
||||
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
|
||||
from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService
|
||||
from spiffworkflow_backend.services.process_model_service import ProcessModelService
|
||||
from spiffworkflow_backend.services.spec_file_service import SpecFileService
|
||||
from spiffworkflow_backend.services.task_service import TaskService
|
||||
|
||||
|
||||
class TaskDataSelectOption(TypedDict):
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class ReactJsonSchemaSelectOption(TypedDict):
|
||||
type: str
|
||||
title: str
|
||||
enum: list[str]
|
||||
|
||||
|
||||
def task_allows_guest(
|
||||
process_instance_id: int,
|
||||
task_guid: str,
|
||||
|
@ -436,110 +420,11 @@ def task_show(
|
|||
task_guid: str = "next",
|
||||
with_form_data: bool = False,
|
||||
) -> flask.wrappers.Response:
|
||||
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
||||
|
||||
if process_instance.status == ProcessInstanceStatus.suspended.value:
|
||||
raise ApiError(
|
||||
error_code="error_suspended",
|
||||
message="The process instance is suspended",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
process_model = _get_process_model(
|
||||
process_instance.process_model_identifier,
|
||||
task_model = _get_task_model_for_request(
|
||||
process_instance_id=process_instance_id,
|
||||
task_guid=task_guid,
|
||||
with_form_data=with_form_data,
|
||||
)
|
||||
|
||||
task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id)
|
||||
task_definition = task_model.task_definition
|
||||
|
||||
can_complete = False
|
||||
try:
|
||||
AuthorizationService.assert_user_can_complete_task(process_instance.id, task_model.guid, g.user)
|
||||
can_complete = True
|
||||
except (
|
||||
HumanTaskNotFoundError,
|
||||
UserDoesNotHaveAccessToTaskError,
|
||||
HumanTaskAlreadyCompletedError,
|
||||
):
|
||||
can_complete = False
|
||||
|
||||
task_model.process_model_display_name = process_model.display_name
|
||||
task_model.process_model_identifier = process_model.id
|
||||
task_model.typename = task_definition.typename
|
||||
task_model.can_complete = can_complete
|
||||
task_model.name_for_display = TaskService.get_name_for_display(task_definition)
|
||||
extensions = TaskService.get_extensions_from_task_model(task_model)
|
||||
|
||||
if with_form_data:
|
||||
task_process_identifier = task_model.bpmn_process.bpmn_process_definition.bpmn_identifier
|
||||
process_model_with_form = process_model
|
||||
|
||||
refs = SpecFileService.get_references_for_process(process_model_with_form)
|
||||
all_processes = [i.identifier for i in refs]
|
||||
if task_process_identifier not in all_processes:
|
||||
top_bpmn_process = TaskService.bpmn_process_for_called_activity_or_top_level_process(task_model)
|
||||
bpmn_file_full_path = ProcessInstanceProcessor.bpmn_file_full_path_from_bpmn_process_identifier(
|
||||
top_bpmn_process.bpmn_process_definition.bpmn_identifier
|
||||
)
|
||||
relative_path = os.path.relpath(bpmn_file_full_path, start=FileSystemService.root_path())
|
||||
process_model_relative_path = os.path.dirname(relative_path)
|
||||
process_model_with_form = ProcessModelService.get_process_model_from_relative_path(process_model_relative_path)
|
||||
|
||||
form_schema_file_name = ""
|
||||
form_ui_schema_file_name = ""
|
||||
task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels(process_instance_id, task_model.guid)
|
||||
|
||||
if "properties" in extensions:
|
||||
properties = extensions["properties"]
|
||||
if "formJsonSchemaFilename" in properties:
|
||||
form_schema_file_name = properties["formJsonSchemaFilename"]
|
||||
if "formUiSchemaFilename" in properties:
|
||||
form_ui_schema_file_name = properties["formUiSchemaFilename"]
|
||||
|
||||
task_draft_data = TaskService.task_draft_data_from_task_model(task_model)
|
||||
|
||||
saved_form_data = None
|
||||
if task_draft_data is not None:
|
||||
saved_form_data = task_draft_data.get_saved_form_data()
|
||||
|
||||
task_model.data = task_model.get_data()
|
||||
task_model.saved_form_data = saved_form_data
|
||||
if task_definition.typename == "UserTask":
|
||||
if not form_schema_file_name:
|
||||
raise (
|
||||
ApiError(
|
||||
error_code="missing_form_file",
|
||||
message=f"Cannot find a form file for process_instance_id: {process_instance_id}, task_guid: {task_guid}",
|
||||
status_code=400,
|
||||
)
|
||||
)
|
||||
|
||||
form_dict = _prepare_form_data(
|
||||
form_file=form_schema_file_name,
|
||||
task_model=task_model,
|
||||
process_model=process_model_with_form,
|
||||
revision=process_instance.bpmn_version_control_identifier,
|
||||
)
|
||||
_update_form_schema_with_task_data_as_needed(form_dict, task_model.data)
|
||||
task_model.form_schema = form_dict
|
||||
|
||||
if form_ui_schema_file_name:
|
||||
ui_form_contents = _prepare_form_data(
|
||||
form_file=form_ui_schema_file_name,
|
||||
task_model=task_model,
|
||||
process_model=process_model_with_form,
|
||||
revision=process_instance.bpmn_version_control_identifier,
|
||||
)
|
||||
task_model.form_ui_schema = ui_form_contents
|
||||
else:
|
||||
task_model.form_ui_schema = {}
|
||||
_munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model.form_ui_schema, task_model.data)
|
||||
|
||||
# it should be safe to add instructions to the task spec here since we are never commiting it back to the db
|
||||
extensions["instructionsForEndUser"] = JinjaService.render_instructions_for_end_user(task_model, extensions)
|
||||
|
||||
task_model.extensions = extensions
|
||||
|
||||
return make_response(jsonify(task_model), 200)
|
||||
|
||||
|
||||
|
@ -963,76 +848,6 @@ def _get_tasks(
|
|||
return make_response(jsonify(response_json), 200)
|
||||
|
||||
|
||||
# originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches
|
||||
def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None:
|
||||
for k, value in in_dict.items():
|
||||
if "anyOf" == k:
|
||||
# value will look like the array on the right of "anyOf": ["options_from_task_data_var:awesome_options"]
|
||||
if isinstance(value, list):
|
||||
if len(value) == 1:
|
||||
first_element_in_value_list = value[0]
|
||||
if isinstance(first_element_in_value_list, str):
|
||||
if first_element_in_value_list.startswith("options_from_task_data_var:"):
|
||||
task_data_var = first_element_in_value_list.replace("options_from_task_data_var:", "")
|
||||
|
||||
if task_data_var not in task_data:
|
||||
message = (
|
||||
"Error building form. Attempting to create a selection list with options from"
|
||||
f" variable '{task_data_var}' but it doesn't exist in the Task Data."
|
||||
)
|
||||
raise ApiError(
|
||||
error_code="missing_task_data_var",
|
||||
message=message,
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
select_options_from_task_data = task_data.get(task_data_var)
|
||||
if select_options_from_task_data == []:
|
||||
raise ApiError(
|
||||
error_code="invalid_form_data",
|
||||
message=(
|
||||
"This form depends on variables, but at least one variable was empty. The"
|
||||
f" variable '{task_data_var}' must be a list with at least one element."
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
if isinstance(select_options_from_task_data, str):
|
||||
raise ApiError(
|
||||
error_code="invalid_form_data",
|
||||
message=(
|
||||
"This form depends on enum variables, but at least one variable was a string."
|
||||
f" The variable '{task_data_var}' must be a list with at least one element."
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
if isinstance(select_options_from_task_data, list):
|
||||
if all("value" in d and "label" in d for d in select_options_from_task_data):
|
||||
|
||||
def map_function(
|
||||
task_data_select_option: TaskDataSelectOption,
|
||||
) -> ReactJsonSchemaSelectOption:
|
||||
return {
|
||||
"type": "string",
|
||||
"enum": [task_data_select_option["value"]],
|
||||
"title": task_data_select_option["label"],
|
||||
}
|
||||
|
||||
options_for_react_json_schema_form = list(
|
||||
map(
|
||||
map_function,
|
||||
select_options_from_task_data,
|
||||
)
|
||||
)
|
||||
|
||||
in_dict[k] = options_for_react_json_schema_form
|
||||
elif isinstance(value, dict):
|
||||
_update_form_schema_with_task_data_as_needed(value, task_data)
|
||||
elif isinstance(value, list):
|
||||
for o in value:
|
||||
if isinstance(o, dict):
|
||||
_update_form_schema_with_task_data_as_needed(o, task_data)
|
||||
|
||||
|
||||
def _get_potential_owner_usernames(assigned_user: AliasedClass) -> Any:
|
||||
potential_owner_usernames_from_group_concat_or_similar = func.group_concat(assigned_user.username.distinct()).label(
|
||||
"potential_owner_usernames"
|
||||
|
@ -1045,30 +860,3 @@ def _get_potential_owner_usernames(assigned_user: AliasedClass) -> Any:
|
|||
)
|
||||
|
||||
return potential_owner_usernames_from_group_concat_or_similar
|
||||
|
||||
|
||||
def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: dict | None, task_data: dict) -> None:
|
||||
if form_ui_schema is None:
|
||||
return
|
||||
if task_data and "form_ui_hidden_fields" in task_data:
|
||||
hidden_fields = task_data["form_ui_hidden_fields"]
|
||||
for hidden_field in hidden_fields:
|
||||
hidden_field_parts = hidden_field.split(".")
|
||||
relevant_depth_of_ui_schema = form_ui_schema
|
||||
for ii, hidden_field_part in enumerate(hidden_field_parts):
|
||||
if hidden_field_part not in relevant_depth_of_ui_schema:
|
||||
relevant_depth_of_ui_schema[hidden_field_part] = {}
|
||||
relevant_depth_of_ui_schema = relevant_depth_of_ui_schema[hidden_field_part]
|
||||
if len(hidden_field_parts) == ii + 1:
|
||||
relevant_depth_of_ui_schema["ui:widget"] = "hidden"
|
||||
|
||||
|
||||
def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int) -> TaskModel:
|
||||
task_model: TaskModel | None = TaskModel.query.filter_by(guid=task_guid, process_instance_id=process_instance_id).first()
|
||||
if task_model is None:
|
||||
raise ApiError(
|
||||
error_code="task_not_found",
|
||||
message=f"Cannot find a task with guid '{task_guid}' for process instance '{process_instance_id}'",
|
||||
status_code=400,
|
||||
)
|
||||
return task_model
|
||||
|
|
|
@ -39,7 +39,14 @@ class GetUrlForTaskWithBpmnIdentifier(Script):
|
|||
" therefore it does not have a url to retrieve"
|
||||
)
|
||||
|
||||
public = True
|
||||
public_segment = ""
|
||||
if "public" in kwargs:
|
||||
public = kwargs["public"]
|
||||
if public is True:
|
||||
public_segment = "/public"
|
||||
|
||||
guid = str(desired_spiff_task.id)
|
||||
fe_url = current_app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"]
|
||||
url = f"{fe_url}/tasks/{script_attributes_context.process_instance_id}/{guid}"
|
||||
url = f"{fe_url}{public_segment}/tasks/{script_attributes_context.process_instance_id}/{guid}"
|
||||
return url
|
||||
|
|
|
@ -31,7 +31,6 @@ from spiffworkflow_backend.models.human_task import HumanTaskModel
|
|||
from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel
|
||||
from spiffworkflow_backend.models.permission_target import PermissionTargetModel
|
||||
from spiffworkflow_backend.models.principal import PrincipalModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||
from spiffworkflow_backend.models.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE
|
||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
|
||||
|
@ -102,6 +101,7 @@ AUTHENTICATION_EXCLUSION_LIST = [
|
|||
|
||||
# these are api calls that are allowed to generate a public jwt when called
|
||||
PUBLIC_AUTHENTICATION_EXCLUSION_LIST = [
|
||||
"spiffworkflow_backend.routes.public_controller.form_show",
|
||||
"spiffworkflow_backend.routes.public_controller.message_form_show",
|
||||
"spiffworkflow_backend.routes.public_controller.message_form_submit",
|
||||
]
|
||||
|
@ -332,9 +332,6 @@ class AuthorizationService:
|
|||
if cls.request_is_excluded_from_permission_check():
|
||||
return None
|
||||
|
||||
if cls.request_allows_guest_access(decoded_token):
|
||||
return None
|
||||
|
||||
cls.check_permission_for_request()
|
||||
|
||||
@classmethod
|
||||
|
@ -345,31 +342,6 @@ class AuthorizationService:
|
|||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def request_allows_guest_access(cls, decoded_token: dict | None) -> bool:
|
||||
if cls.request_is_excluded_from_permission_check():
|
||||
return True
|
||||
|
||||
api_view_function = current_app.view_functions[request.endpoint]
|
||||
if api_view_function.__name__ in ["task_show", "task_submit", "task_save_draft"]:
|
||||
process_instance_id = int(request.path.split("/")[3])
|
||||
task_guid = request.path.split("/")[4]
|
||||
if TaskModel.task_guid_allows_guest(task_guid, process_instance_id):
|
||||
return True
|
||||
|
||||
if (
|
||||
decoded_token is not None
|
||||
and "process_instance_id" in decoded_token
|
||||
and "only_guest_task_completion" in decoded_token
|
||||
and decoded_token["only_guest_task_completion"] is True
|
||||
and api_view_function.__name__ == "typeahead"
|
||||
and api_view_function.__module__ == "spiffworkflow_backend.routes.connector_proxy_controller"
|
||||
):
|
||||
process_instance = ProcessInstanceModel.query.filter_by(id=decoded_token["process_instance_id"]).first()
|
||||
if process_instance is not None and not process_instance.has_terminal_status():
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def assert_user_can_complete_task(
|
||||
process_instance_id: int,
|
||||
|
@ -817,6 +789,8 @@ class AuthorizationService:
|
|||
for group in group_permissions:
|
||||
group_identifier = group["name"]
|
||||
UserService.find_or_create_group(group_identifier)
|
||||
if group_identifier == current_app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"]:
|
||||
unique_user_group_identifiers.add(group_identifier)
|
||||
if not group_permissions_only:
|
||||
for username in group["users"]:
|
||||
if user_model and username != user_model.username:
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
<bpmn:extensionElements>
|
||||
<spiffworkflow:allowGuest>true</spiffworkflow:allowGuest>
|
||||
<spiffworkflow:guestConfirmation>You have completed the task.</spiffworkflow:guestConfirmation>
|
||||
<spiffworkflow:instructionsForEndUser>We have instructions.</spiffworkflow:instructionsForEndUser>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>Flow_14w7df0</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_02dvhev</bpmn:outgoing>
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
|
||||
<bpmn:process id="Process_2jd03k0" isExecutable="true">
|
||||
<bpmn:startEvent id="StartEvent_1">
|
||||
<bpmn:outgoing>Flow_1sk03xw</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:sequenceFlow id="Flow_1sk03xw" sourceRef="StartEvent_1" targetRef="script_task" />
|
||||
<bpmn:endEvent id="Event_1skunad">
|
||||
<bpmn:incoming>Flow_1xw30wr</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="Flow_1ckjs49" sourceRef="script_task" targetRef="manual_task" />
|
||||
<bpmn:scriptTask id="script_task">
|
||||
<bpmn:incoming>Flow_1sk03xw</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_1ckjs49</bpmn:outgoing>
|
||||
<bpmn:script>url = get_url_for_task_with_bpmn_identifier("manual_task", public=False)</bpmn:script>
|
||||
</bpmn:scriptTask>
|
||||
<bpmn:manualTask id="manual_task">
|
||||
<bpmn:incoming>Flow_1ckjs49</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_1xw30wr</bpmn:outgoing>
|
||||
</bpmn:manualTask>
|
||||
<bpmn:sequenceFlow id="Flow_1xw30wr" sourceRef="manual_task" targetRef="Event_1skunad" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_2jd03k0">
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="159" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_15lnnd2_di" bpmnElement="script_task">
|
||||
<dc:Bounds x="270" y="137" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Event_1skunad_di" bpmnElement="Event_1skunad">
|
||||
<dc:Bounds x="552" y="159" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Activity_0ozc1ka_di" bpmnElement="manual_task">
|
||||
<dc:Bounds x="410" y="137" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="Flow_1sk03xw_di" bpmnElement="Flow_1sk03xw">
|
||||
<di:waypoint x="215" y="177" />
|
||||
<di:waypoint x="270" y="177" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_1ckjs49_di" bpmnElement="Flow_1ckjs49">
|
||||
<di:waypoint x="370" y="177" />
|
||||
<di:waypoint x="410" y="177" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Flow_1xw30wr_di" bpmnElement="Flow_1xw30wr">
|
||||
<di:waypoint x="510" y="177" />
|
||||
<di:waypoint x="552" y="177" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
|
@ -1,9 +1,11 @@
|
|||
import json
|
||||
import re
|
||||
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
|
||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||
from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict
|
||||
|
||||
|
@ -36,9 +38,14 @@ class TestPublicController(BaseTest):
|
|||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert response.json is not None
|
||||
assert "form_schema" in response.json
|
||||
assert "form_ui_schema" in response.json
|
||||
assert response.json["form_schema"]["title"] == "Form for message start event"
|
||||
assert "form" in response.json
|
||||
assert "confirmation_message_markdown" in response.json
|
||||
assert "task_guid" in response.json
|
||||
assert "form_schema" in response.json["form"]
|
||||
assert "form_ui_schema" in response.json["form"]
|
||||
assert response.json["form"]["form_schema"]["title"] == "Form for message start event"
|
||||
assert response.json["confirmation_message_markdown"] is None
|
||||
assert response.json["task_guid"] is None
|
||||
|
||||
def test_can_submit_to_public_message_submit(
|
||||
self,
|
||||
|
@ -161,3 +168,102 @@ class TestPublicController(BaseTest):
|
|||
|
||||
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first()
|
||||
assert process_instance.status == ProcessInstanceStatus.user_input_required.value
|
||||
|
||||
def test_can_complete_complete_a_guest_task(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
) -> None:
|
||||
admin_user = self.find_or_create_user("admin")
|
||||
group_info: list[GroupPermissionsDict] = [
|
||||
{
|
||||
"users": [],
|
||||
"name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"],
|
||||
"permissions": [{"actions": ["create", "read", "update"], "uri": "/public/*"}],
|
||||
},
|
||||
{
|
||||
"users": [admin_user.username],
|
||||
"name": "admin",
|
||||
"permissions": [{"actions": ["all"], "uri": "/*"}],
|
||||
},
|
||||
]
|
||||
AuthorizationService.refresh_permissions(group_info)
|
||||
|
||||
process_group_id = "my_process_group"
|
||||
process_model_id = "test-allow-guest"
|
||||
bpmn_file_location = "test-allow-guest"
|
||||
process_model = self.create_group_and_model_with_bpmn(
|
||||
client,
|
||||
admin_user,
|
||||
process_group_id=process_group_id,
|
||||
process_model_id=process_model_id,
|
||||
bpmn_file_location=bpmn_file_location,
|
||||
)
|
||||
|
||||
admin_headers = self.logged_in_headers(admin_user)
|
||||
response = self.create_process_instance_from_process_model_id_with_api(client, process_model.id, admin_headers)
|
||||
assert response.json is not None
|
||||
process_instance_id = response.json["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model.id)}/{process_instance_id}/run",
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
task_model = TaskModel.query.filter_by(process_instance_id=process_instance_id, state="READY").first()
|
||||
assert task_model is not None
|
||||
first_task_guid = task_model.guid
|
||||
|
||||
response = client.get(
|
||||
f"/v1.0/public/tasks/{process_instance_id}/{first_task_guid}",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json is not None
|
||||
assert response.json["form"] == {"form_schema": None, "form_ui_schema": None, "instructions_for_end_user": ""}
|
||||
assert response.json["confirmation_message_markdown"] is None
|
||||
assert response.json["task_guid"] == first_task_guid
|
||||
assert response.json["process_instance_id"] == process_instance_id
|
||||
|
||||
headers_dict = dict(response.headers)
|
||||
assert "Set-Cookie" in headers_dict
|
||||
cookie = headers_dict["Set-Cookie"]
|
||||
cookie_split = cookie.split(";")
|
||||
access_token = [cookie for cookie in cookie_split if cookie.startswith("access_token=")][0]
|
||||
assert access_token is not None
|
||||
re_result = re.match(r"^access_token=[\w_\.-]+$", access_token)
|
||||
assert re_result is not None
|
||||
user_header = {"Authorization": "Bearer " + access_token.split("=")[1]}
|
||||
|
||||
response = client.put(
|
||||
f"/v1.0/public/tasks/{process_instance_id}/{first_task_guid}?execution_mode=synchronous",
|
||||
data="{}",
|
||||
content_type="application/json",
|
||||
headers=user_header,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json is not None
|
||||
assert response.json["form"] == {"instructions_for_end_user": "We have instructions."}
|
||||
assert response.json["confirmation_message_markdown"] is None
|
||||
assert response.json["task_guid"] is not None
|
||||
assert response.json["task_guid"] != first_task_guid
|
||||
assert response.json["process_instance_id"] == process_instance_id
|
||||
|
||||
second_task_guid = response.json["task_guid"]
|
||||
response = client.put(
|
||||
f"/v1.0/public/tasks/{process_instance_id}/{second_task_guid}?execution_mode=synchronous",
|
||||
data="{}",
|
||||
content_type="application/json",
|
||||
headers=user_header,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json is not None
|
||||
assert response.json["form"] is None
|
||||
assert response.json["confirmation_message_markdown"] == "You have completed the task."
|
||||
assert response.json["task_guid"] is None
|
||||
assert response.json["process_instance_id"] == process_instance_id
|
||||
|
||||
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first()
|
||||
assert process_instance is not None
|
||||
assert process_instance.status == ProcessInstanceStatus.complete.value
|
||||
|
|
|
@ -441,86 +441,6 @@ class TestTasksController(BaseTest):
|
|||
assert response.json["saved_form_data"] is None
|
||||
assert response.json["data"]["HEY"] == draft_data["HEY"]
|
||||
|
||||
def test_can_complete_complete_a_guest_task(
|
||||
self,
|
||||
app: Flask,
|
||||
client: FlaskClient,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
with_super_admin_user: UserModel,
|
||||
) -> None:
|
||||
process_group_id = "my_process_group"
|
||||
process_model_id = "test-allow-guest"
|
||||
bpmn_file_location = "test-allow-guest"
|
||||
process_model = self.create_group_and_model_with_bpmn(
|
||||
client,
|
||||
with_super_admin_user,
|
||||
process_group_id=process_group_id,
|
||||
process_model_id=process_model_id,
|
||||
bpmn_file_location=bpmn_file_location,
|
||||
)
|
||||
|
||||
headers = self.logged_in_headers(with_super_admin_user)
|
||||
response = self.create_process_instance_from_process_model_id_with_api(client, process_model.id, headers)
|
||||
assert response.json is not None
|
||||
process_instance_id = response.json["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model.id)}/{process_instance_id}/run",
|
||||
headers=self.logged_in_headers(with_super_admin_user),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
task_model = TaskModel.query.filter_by(process_instance_id=process_instance_id, state="READY").first()
|
||||
assert task_model is not None
|
||||
task_guid = task_model.guid
|
||||
|
||||
# log in a guest user to complete the tasks
|
||||
redirect_url = f"{app.config['SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND']}/test-redirect-dne"
|
||||
response = client.get(
|
||||
f"/v1.0/login?process_instance_id={process_instance_id}&task_guid={task_guid}&redirect_url={redirect_url}&authentication_identifier=DOES_NOT_MATTER",
|
||||
)
|
||||
assert response.status_code == 302
|
||||
assert response.location == redirect_url
|
||||
headers_dict = dict(response.headers)
|
||||
assert "Set-Cookie" in headers_dict
|
||||
cookie = headers_dict["Set-Cookie"]
|
||||
access_token = cookie.split(";")[0].split("=")[1]
|
||||
assert access_token is not None
|
||||
|
||||
# ensure guest user can get and complete both guest manual tasks
|
||||
response = client.get(
|
||||
f"/v1.0/tasks/{process_instance_id}/{task_guid}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response = client.put(
|
||||
f"/v1.0/tasks/{process_instance_id}/{task_guid}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json is not None
|
||||
task_guid = response.json["id"]
|
||||
assert task_guid is not None
|
||||
response = client.put(
|
||||
f"/v1.0/tasks/{process_instance_id}/{task_guid}",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json is not None
|
||||
assert "guest_confirmation" in response.json
|
||||
|
||||
# ensure user gets logged out when they try to go anywhere else
|
||||
response = client.get(
|
||||
"/v1.0/tasks",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
headers_dict = dict(response.headers)
|
||||
assert "Set-Cookie" in headers_dict
|
||||
cookie = headers_dict["Set-Cookie"]
|
||||
access_token = cookie.split(";")[0].split("=")[1]
|
||||
assert access_token == ""
|
||||
|
||||
def test_task_instance_list(
|
||||
self,
|
||||
app: Flask,
|
||||
|
|
|
@ -21,6 +21,40 @@ class TestGetUrlForTaskWithBpmnIdentifier(BaseTest):
|
|||
process_model = load_test_spec(
|
||||
process_model_id="misc/test-get-url-for-task-with-bpmn-identifier",
|
||||
process_model_source_directory="test-get-url-for-task-with-bpmn-identifier",
|
||||
bpmn_file_name="test-get-url-for-task-with-bpmn-identifier.bpmn",
|
||||
)
|
||||
process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user)
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
processor.do_engine_steps(save=True)
|
||||
|
||||
assert len(process_instance.active_human_tasks) == 1
|
||||
human_task = process_instance.active_human_tasks[0]
|
||||
assert len(human_task.potential_owners) == 1
|
||||
assert human_task.potential_owners[0] == initiator_user
|
||||
|
||||
spiff_task = processor.__class__.get_task_by_bpmn_identifier(human_task.task_name, processor.bpmn_process_instance)
|
||||
ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task)
|
||||
assert process_instance.status == ProcessInstanceStatus.complete.value
|
||||
assert spiff_task is not None
|
||||
assert "url" in spiff_task.data
|
||||
|
||||
fe_url = app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"]
|
||||
expected_url = f"{fe_url}/public/tasks/{process_instance.id}/{str(spiff_task.id)}"
|
||||
assert spiff_task.data["url"] == expected_url
|
||||
|
||||
def test_get_url_for_task_can_get_non_public_url(
|
||||
self,
|
||||
app: Flask,
|
||||
with_db_and_bpmn_file_cleanup: None,
|
||||
) -> None:
|
||||
initiator_user = self.find_or_create_user("initiator_user")
|
||||
assert initiator_user.principal is not None
|
||||
AuthorizationService.import_permissions_from_yaml_file()
|
||||
|
||||
process_model = load_test_spec(
|
||||
process_model_id="misc/test-get-url-for-task-with-bpmn-identifier",
|
||||
process_model_source_directory="test-get-url-for-task-with-bpmn-identifier",
|
||||
bpmn_file_name="test-get-url-for-task-with-bpmn-identifier-non-public.bpmn",
|
||||
)
|
||||
process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user)
|
||||
processor = ProcessInstanceProcessor(process_instance)
|
||||
|
|
|
@ -27,6 +27,7 @@ yarn-error.log*
|
|||
|
||||
cypress/videos
|
||||
cypress/screenshots
|
||||
cypress/downloads
|
||||
|
||||
# i keep accidentally committing these
|
||||
/test*.json
|
||||
|
|
|
@ -43,19 +43,6 @@ describe('tasks', () => {
|
|||
submitInputIntoFormField('get_form_num_two', '#root_form_num_2', 3);
|
||||
|
||||
cy.contains('Task: get_form_num_three');
|
||||
// TODO: remove this if we decide to completely kill form navigation
|
||||
// cy.getBySel('form-nav-form2').click();
|
||||
// checkFormFieldIsReadOnly(
|
||||
// 'get_form_num_two',
|
||||
// '#root_form_num_2'
|
||||
// );
|
||||
// cy.getBySel('form-nav-form1').click();
|
||||
// checkFormFieldIsReadOnly(
|
||||
// 'get_form_num_one',
|
||||
// '#root_form_num_1'
|
||||
// );
|
||||
//
|
||||
// cy.getBySel('form-nav-form3').click();
|
||||
submitInputIntoFormField('get_form_num_three', '#root_form_num_3', 4);
|
||||
|
||||
cy.contains('Task: get_form_num_four');
|
||||
|
@ -98,17 +85,48 @@ describe('tasks', () => {
|
|||
cy.contains('Process Instance Id: ');
|
||||
cy.get('.process-instance-status').contains('complete');
|
||||
});
|
||||
|
||||
// we no longer have a tasks table so these are actually covered in the process_instances test
|
||||
// it('can paginate items', () => {
|
||||
// // make sure we have some tasks
|
||||
// kickOffModelWithForm();
|
||||
// kickOffModelWithForm();
|
||||
// kickOffModelWithForm();
|
||||
// kickOffModelWithForm();
|
||||
// kickOffModelWithForm();
|
||||
//
|
||||
// cy.navigateToHome();
|
||||
// cy.basicPaginationTest('process-instance-show-link-id');
|
||||
// });
|
||||
});
|
||||
|
||||
describe('public_tasks', () => {
|
||||
it('can start process from message form', () => {
|
||||
// login and log out to ensure permissions are set correctly
|
||||
cy.login();
|
||||
cy.logout();
|
||||
|
||||
cy.visit('public/misc:bounty_start_multiple_forms');
|
||||
cy.get('#root_firstName').type('MyFirstName');
|
||||
cy.contains('Submit').click();
|
||||
cy.get('#root_lastName').type('MyLastName');
|
||||
cy.contains('Submit').click();
|
||||
cy.contains('We hear you. Your name is MyFirstName MyLastName.');
|
||||
});
|
||||
|
||||
it('can complete a guest task', () => {
|
||||
cy.login();
|
||||
const groupDisplayName = 'Shared Resources';
|
||||
const modelDisplayName = 'task-with-guest-form';
|
||||
cy.navigateToProcessModel(groupDisplayName, modelDisplayName);
|
||||
cy.runPrimaryBpmnFile(false, false, false);
|
||||
|
||||
cy.get('[data-qa="metadata-value-first_task_url"] a')
|
||||
.invoke('attr', 'href')
|
||||
.then((hrefValue) => {
|
||||
cy.logout();
|
||||
cy.visit(hrefValue);
|
||||
// form 1
|
||||
cy.contains('Submit').click();
|
||||
// form 2
|
||||
cy.contains('Submit').click();
|
||||
cy.contains('You are done. Yay!');
|
||||
cy.visit(hrefValue);
|
||||
cy.contains('Error retrieving content.');
|
||||
cy.getBySel('public-home-link').click();
|
||||
cy.getBySel('public-sign-out').click();
|
||||
if (Cypress.env('SPIFFWORKFLOW_FRONTEND_AUTH_WITH_KEYCLOAK') === true) {
|
||||
cy.contains('Sign in to your account');
|
||||
} else {
|
||||
cy.get('#spiff-login-button').should('exist');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -107,7 +107,11 @@ Cypress.Commands.add('createModel', (groupId, modelId, modelDisplayName) => {
|
|||
// Intended to be run from the process model show page
|
||||
Cypress.Commands.add(
|
||||
'runPrimaryBpmnFile',
|
||||
(expectAutoRedirectToHumanTask = false, returnToProcessModelShow = true) => {
|
||||
(
|
||||
expectAutoRedirectToHumanTask = false,
|
||||
returnToProcessModelShow = true,
|
||||
processInstanceExpectedToBeComplete = true
|
||||
) => {
|
||||
cy.getBySel('start-process-instance').click();
|
||||
if (expectAutoRedirectToHumanTask) {
|
||||
// the url changes immediately, so also make sure we get some content from the next page, "Task:",
|
||||
|
@ -117,7 +121,9 @@ Cypress.Commands.add(
|
|||
} else {
|
||||
cy.url().should('include', `/process-instances`);
|
||||
cy.contains('Process Instance Id');
|
||||
cy.contains('complete');
|
||||
if (processInstanceExpectedToBeComplete) {
|
||||
cy.contains('complete');
|
||||
}
|
||||
if (returnToProcessModelShow) {
|
||||
cy.getBySel('process-model-breadcrumb-link').click();
|
||||
cy.getBySel('process-model-show-permissions-loaded').should('exist');
|
||||
|
|
|
@ -24,7 +24,7 @@ export default function App() {
|
|||
<div className="cds--white">
|
||||
<APIErrorProvider>
|
||||
<AbilityContext.Provider value={ability}>
|
||||
<Outlet />;
|
||||
<Outlet />
|
||||
</AbilityContext.Provider>
|
||||
</APIErrorProvider>
|
||||
</div>
|
||||
|
|
|
@ -312,7 +312,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
|
|||
);
|
||||
}
|
||||
|
||||
if (activeKey && ability && !UserService.onlyGuestTaskCompletion()) {
|
||||
if (activeKey && ability) {
|
||||
return (
|
||||
<HeaderContainer
|
||||
render={({ isSideNavExpanded, onClickSideNavExpand }: any) => (
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
slugifyString,
|
||||
underscorizeString,
|
||||
recursivelyChangeNullAndUndefined,
|
||||
isURL,
|
||||
} from './helpers';
|
||||
|
||||
test('it can slugify a string', () => {
|
||||
|
@ -95,3 +96,24 @@ test('it can replace null values in object with undefined', () => {
|
|||
expect(result.contacts.awesome).toEqual(false);
|
||||
expect(result.contacts.info).toEqual('');
|
||||
});
|
||||
|
||||
test('it can identify urls', () => {
|
||||
const urls = [
|
||||
'http://localhost:7001/public/tasks/94/61efeb05-7278-4de8-979d-b4580cfc0233',
|
||||
'http://localhost/public/tasks/94/61efeb05-7278-4de8-979d-b4580cfc0233',
|
||||
'https://www.google.com',
|
||||
];
|
||||
urls.forEach((url: string) => {
|
||||
const result = isURL(url);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
const badUrls = [
|
||||
'localhost:7001/public/tasks/94/61efeb05-7278-4de8-979d-b4580cfc0233',
|
||||
'localhost/public/tasks/94/61efeb05-7278-4de8-979d-b4580cfc0233',
|
||||
'www.google.com',
|
||||
];
|
||||
badUrls.forEach((url: string) => {
|
||||
const result = isURL(url);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -299,7 +299,7 @@ export const parseTaskShowUrl = (url: string) => {
|
|||
export const isURL = (str: string) => {
|
||||
const urlRegex =
|
||||
// eslint-disable-next-line max-len
|
||||
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i;
|
||||
/^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))?)(?::\d{2,5})?(?:[/?#]\S*)?$/i;
|
||||
return urlRegex.test(str);
|
||||
};
|
||||
|
||||
|
|
|
@ -509,7 +509,7 @@ export interface PublicTaskForm {
|
|||
form_ui_schema: any;
|
||||
instructions_for_end_user?: string;
|
||||
}
|
||||
export interface PublicTaskSubmitResponse {
|
||||
export interface PublicTask {
|
||||
form: PublicTaskForm;
|
||||
task_guid: string;
|
||||
process_instance_id: number;
|
||||
|
|
|
@ -5,7 +5,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||
import { AuthenticationOption } from '../interfaces';
|
||||
import HttpService from '../services/HttpService';
|
||||
import UserService from '../services/UserService';
|
||||
import { parseTaskShowUrl } from '../helpers';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
|
@ -13,9 +12,6 @@ export default function Login() {
|
|||
const [authenticationOptions, setAuthenticationOptions] = useState<
|
||||
AuthenticationOption[] | null
|
||||
>(null);
|
||||
const [allowsGuestLogin, setAllowsGuestLogin] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const originalUrl = searchParams.get('original_url');
|
||||
const getOriginalUrl = useCallback(() => {
|
||||
|
@ -30,16 +26,6 @@ export default function Login() {
|
|||
path: '/authentication-options',
|
||||
successCallback: setAuthenticationOptions,
|
||||
});
|
||||
const pathSegments = parseTaskShowUrl(getOriginalUrl() || '');
|
||||
if (pathSegments) {
|
||||
HttpService.makeCallToBackend({
|
||||
path: `/tasks/${pathSegments[1]}/${pathSegments[2]}/allows-guest`,
|
||||
successCallback: (result: any) =>
|
||||
setAllowsGuestLogin(result.allows_guest),
|
||||
});
|
||||
} else {
|
||||
setAllowsGuestLogin(false);
|
||||
}
|
||||
}, [getOriginalUrl]);
|
||||
|
||||
const authenticationOptionButtons = () => {
|
||||
|
@ -112,8 +98,8 @@ export default function Login() {
|
|||
);
|
||||
}
|
||||
|
||||
if (authenticationOptions !== null && allowsGuestLogin !== null) {
|
||||
if (allowsGuestLogin || authenticationOptions.length === 1) {
|
||||
if (authenticationOptions !== null) {
|
||||
if (authenticationOptions.length === 1) {
|
||||
UserService.doLogin(authenticationOptions[0], getOriginalUrl());
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -444,11 +444,11 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
|
|||
});
|
||||
};
|
||||
|
||||
const formatMetadataValue = (value: string) => {
|
||||
const formatMetadataValue = (key: string, value: string) => {
|
||||
if (isURL(value)) {
|
||||
return (
|
||||
<a href={value} target="_blank" rel="noopener noreferrer">
|
||||
{value}
|
||||
{key} link
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@ -560,7 +560,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
|
|||
<dt title={processInstanceMetadata.key}>
|
||||
{truncateString(processInstanceMetadata.key, 50)}:
|
||||
</dt>
|
||||
<dd>{formatMetadataValue(processInstanceMetadata.value)}</dd>
|
||||
<dd data-qa={`metadata-value-${processInstanceMetadata.key}`}>
|
||||
{formatMetadataValue(
|
||||
processInstanceMetadata.key,
|
||||
processInstanceMetadata.value
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
)
|
||||
)}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Content } from '@carbon/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import MessageStartEventForm from './public/MessageStartEventForm';
|
||||
import PublicForm from './public/PublicForm';
|
||||
import SignOut from './public/SignOut';
|
||||
|
||||
export default function PublicRoutes() {
|
||||
|
@ -8,10 +8,11 @@ export default function PublicRoutes() {
|
|||
<Content className="main-site-body-centered">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/:modified_message_name"
|
||||
element={<MessageStartEventForm />}
|
||||
path="/tasks/:process_instance_id/:task_guid"
|
||||
element={<PublicForm />}
|
||||
/>
|
||||
<Route path="/sign_out" element={<SignOut />} />
|
||||
<Route path="/:modified_message_name" element={<PublicForm />} />
|
||||
<Route path="/sign-out" element={<SignOut />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
);
|
||||
|
|
|
@ -24,8 +24,6 @@ import {
|
|||
import CustomForm from '../components/CustomForm';
|
||||
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
|
||||
import InstructionsForEndUser from '../components/InstructionsForEndUser';
|
||||
import UserService from '../services/UserService';
|
||||
import MarkdownRenderer from '../components/MarkdownRenderer';
|
||||
|
||||
export default function TaskShow() {
|
||||
// get a basic task which doesn't get the form data so we can load
|
||||
|
@ -46,10 +44,6 @@ export default function TaskShow() {
|
|||
const navigate = useNavigate();
|
||||
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
|
||||
|
||||
const [guestConfirmationText, setGuestConfirmationText] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const [taskData, setTaskData] = useState<any>(null);
|
||||
const [autosaveOnFormChanges, setAutosaveOnFormChanges] =
|
||||
useState<boolean>(true);
|
||||
|
@ -62,15 +56,11 @@ export default function TaskShow() {
|
|||
// always work for them so use that since it will work in all cases
|
||||
const navigateToInterstitial = useCallback(
|
||||
(myTask: BasicTask) => {
|
||||
if (UserService.onlyGuestTaskCompletion()) {
|
||||
setGuestConfirmationText('Thank you!');
|
||||
} else {
|
||||
navigate(
|
||||
`/process-instances/for-me/${modifyProcessIdentifierForPathParam(
|
||||
myTask.process_model_identifier
|
||||
)}/${myTask.process_instance_id}/interstitial`
|
||||
);
|
||||
}
|
||||
navigate(
|
||||
`/process-instances/for-me/${modifyProcessIdentifierForPathParam(
|
||||
myTask.process_model_identifier
|
||||
)}/${myTask.process_instance_id}/interstitial`
|
||||
);
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
|
@ -190,12 +180,6 @@ export default function TaskShow() {
|
|||
removeError();
|
||||
if (result.ok) {
|
||||
navigate(`/tasks`);
|
||||
} else if ('guest_confirmation' in result) {
|
||||
if (result.guest_confirmation) {
|
||||
setGuestConfirmationText(result.guest_confirmation);
|
||||
} else {
|
||||
setGuestConfirmationText('Form submitted successfully');
|
||||
}
|
||||
} else if (result.process_instance_id) {
|
||||
if (result.can_complete) {
|
||||
navigate(`/tasks/${result.process_instance_id}/${result.id}`);
|
||||
|
@ -361,10 +345,7 @@ export default function TaskShow() {
|
|||
if (taskWithTaskData.state === 'READY') {
|
||||
const submitButtonText = getSubmitButtonText(formUiSchema);
|
||||
let closeButton = null;
|
||||
if (
|
||||
taskWithTaskData.typename === 'UserTask' &&
|
||||
!UserService.onlyGuestTaskCompletion()
|
||||
) {
|
||||
if (taskWithTaskData.typename === 'UserTask') {
|
||||
closeButton = (
|
||||
<Button
|
||||
id="close-button"
|
||||
|
@ -455,34 +436,22 @@ export default function TaskShow() {
|
|||
statusString = ` ${basicTask.state}`;
|
||||
}
|
||||
|
||||
if (
|
||||
!('allowGuest' in basicTask.extensions) ||
|
||||
basicTask.extensions.allowGuest !== 'true'
|
||||
) {
|
||||
pageElements.push({
|
||||
key: 'process-breadcrumb',
|
||||
component: <ProcessBreadcrumb hotCrumbs={hotCrumbs} />,
|
||||
});
|
||||
pageElements.push({
|
||||
key: 'task-name',
|
||||
component: (
|
||||
<h3>
|
||||
Task: {basicTask.name_for_display} (
|
||||
{basicTask.process_model_display_name}){statusString}
|
||||
</h3>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (guestConfirmationText) {
|
||||
pageElements.push({
|
||||
key: 'guest-confirmation-text',
|
||||
key: 'process-breadcrumb',
|
||||
component: <ProcessBreadcrumb hotCrumbs={hotCrumbs} />,
|
||||
});
|
||||
pageElements.push({
|
||||
key: 'task-name',
|
||||
component: (
|
||||
<MarkdownRenderer linkTarget="_blank" source={guestConfirmationText} />
|
||||
<h3>
|
||||
Task: {basicTask.name_for_display} (
|
||||
{basicTask.process_model_display_name}){statusString}
|
||||
</h3>
|
||||
),
|
||||
});
|
||||
} else if (basicTask && taskData) {
|
||||
}
|
||||
|
||||
if (basicTask && taskData) {
|
||||
pageElements.push({
|
||||
key: 'instructions-for-end-user',
|
||||
component: <InstructionsForEndUser task={taskWithTaskData} />,
|
||||
|
|
|
@ -1,127 +0,0 @@
|
|||
import { Column, Grid, Loading } from '@carbon/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import HttpService from '../../services/HttpService';
|
||||
import CustomForm from '../../components/CustomForm';
|
||||
import Page404 from '../Page404';
|
||||
import { recursivelyChangeNullAndUndefined } from '../../helpers';
|
||||
import useAPIError from '../../hooks/UseApiError';
|
||||
import { PublicTaskForm, PublicTaskSubmitResponse } from '../../interfaces';
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer';
|
||||
import InstructionsForEndUser from '../../components/InstructionsForEndUser';
|
||||
|
||||
export default function MessageStartEventForm() {
|
||||
const params = useParams();
|
||||
const [formContents, setFormContents] = useState<PublicTaskForm | null>(null);
|
||||
const [taskData, setTaskData] = useState<any>(null);
|
||||
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
|
||||
const { addError, removeError } = useAPIError();
|
||||
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [taskSubmitResponse, setTaskSubmitResponse] =
|
||||
useState<PublicTaskSubmitResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
HttpService.makeCallToBackend({
|
||||
path: `/public/messages/form/${params.modified_message_name}`,
|
||||
successCallback: (result: PublicTaskForm) => setFormContents(result),
|
||||
failureCallback: (error: any) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
}, [params.modified_message_name]);
|
||||
|
||||
const processSubmitResult = (result: PublicTaskSubmitResponse) => {
|
||||
removeError();
|
||||
setFormButtonsDisabled(false);
|
||||
setTaskSubmitResponse(result);
|
||||
if (result.form) {
|
||||
setFormContents(result.form);
|
||||
} else if (result.confirmation_message_markdown) {
|
||||
setConfirmationMessage(result.confirmation_message_markdown);
|
||||
} else {
|
||||
setConfirmationMessage('Thank you!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (formObject: any, _event: any) => {
|
||||
if (formButtonsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSubmit = formObject?.formData;
|
||||
|
||||
setFormButtonsDisabled(true);
|
||||
removeError();
|
||||
|
||||
// removing form contents will force the loading icon to appear.
|
||||
// we could also set a state for it at some point but this seemed
|
||||
// like a way to reduce states.
|
||||
setFormContents(null);
|
||||
|
||||
recursivelyChangeNullAndUndefined(dataToSubmit, null);
|
||||
let path = `/public/messages/submit/${params.modified_message_name}`;
|
||||
let httpMethod = 'POST';
|
||||
if (
|
||||
taskSubmitResponse?.task_guid &&
|
||||
taskSubmitResponse?.process_instance_id
|
||||
) {
|
||||
path = `/public/tasks/${taskSubmitResponse.process_instance_id}/${taskSubmitResponse.task_guid}`;
|
||||
httpMethod = 'PUT';
|
||||
}
|
||||
|
||||
HttpService.makeCallToBackend({
|
||||
path: `${path}?execution_mode=synchronous`,
|
||||
successCallback: processSubmitResult,
|
||||
failureCallback: (error: any) => {
|
||||
addError(error);
|
||||
},
|
||||
httpMethod,
|
||||
postBody: dataToSubmit,
|
||||
});
|
||||
};
|
||||
|
||||
if (confirmationMessage) {
|
||||
return (
|
||||
<MarkdownRenderer linkTarget="_blank" source={confirmationMessage} />
|
||||
);
|
||||
}
|
||||
if (formContents) {
|
||||
if (formContents.form_schema) {
|
||||
return (
|
||||
<div className="fixed-width-container">
|
||||
<InstructionsForEndUser
|
||||
defaultMessage={formContents.instructions_for_end_user}
|
||||
/>
|
||||
<Grid fullWidth condensed className="megacondensed">
|
||||
<Column sm={4} md={5} lg={8}>
|
||||
<CustomForm
|
||||
id="form-to-submit"
|
||||
disabled={formButtonsDisabled}
|
||||
formData={taskData}
|
||||
onChange={(obj: any) => {
|
||||
setTaskData(obj.formData);
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
schema={formContents.form_schema}
|
||||
uiSchema={formContents.form_ui_schema}
|
||||
restrictedWidth
|
||||
reactJsonSchemaForm="mui"
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Page404 />;
|
||||
}
|
||||
const style = { margin: '50px 0 50px 50px' };
|
||||
return (
|
||||
<Loading
|
||||
description="Active loading indicator"
|
||||
withOverlay={false}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,185 @@
|
|||
import { Column, Grid, Loading } from '@carbon/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import HttpService from '../../services/HttpService';
|
||||
import CustomForm from '../../components/CustomForm';
|
||||
import { recursivelyChangeNullAndUndefined } from '../../helpers';
|
||||
import { ErrorForDisplay, PublicTask } from '../../interfaces';
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer';
|
||||
import InstructionsForEndUser from '../../components/InstructionsForEndUser';
|
||||
import {
|
||||
ErrorDisplayStateless,
|
||||
errorForDisplayFromString,
|
||||
} from '../../components/ErrorDisplay';
|
||||
import Page404 from '../Page404';
|
||||
|
||||
export default function PublicForm() {
|
||||
const params = useParams();
|
||||
const [taskData, setTaskData] = useState<any>(null);
|
||||
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
|
||||
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [publicTask, setPublicTask] = useState<PublicTask | null>(null);
|
||||
const [currentPageError, setCurrentPageError] =
|
||||
useState<ErrorForDisplay | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const taskGuid = params.task_guid;
|
||||
const processInstanceId = params.process_instance_id;
|
||||
let url = `/public/messages/form/${params.modified_message_name}`;
|
||||
if (taskGuid) {
|
||||
url = `/public/tasks/${processInstanceId}/${taskGuid}`;
|
||||
}
|
||||
HttpService.makeCallToBackend({
|
||||
path: url,
|
||||
successCallback: (result: PublicTask) => setPublicTask(result),
|
||||
failureCallback: (error: any) => {
|
||||
if (
|
||||
error.error_code === 'message_triggerable_process_model_not_found'
|
||||
) {
|
||||
setCurrentPageError(error);
|
||||
} else {
|
||||
setCurrentPageError(
|
||||
errorForDisplayFromString('Error retrieving content.')
|
||||
);
|
||||
}
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
}, [
|
||||
params.modified_message_name,
|
||||
params.process_instance_id,
|
||||
params.task_guid,
|
||||
]);
|
||||
|
||||
const processSubmitResult = (result: PublicTask) => {
|
||||
setCurrentPageError(null);
|
||||
setFormButtonsDisabled(false);
|
||||
setPublicTask(result);
|
||||
if (result.confirmation_message_markdown) {
|
||||
setConfirmationMessage(result.confirmation_message_markdown);
|
||||
} else if (!result.form) {
|
||||
setConfirmationMessage('Thank you!');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (formObject: any, _event: any) => {
|
||||
if (formButtonsDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataToSubmit = formObject?.formData;
|
||||
delete dataToSubmit.isManualTask;
|
||||
|
||||
setFormButtonsDisabled(true);
|
||||
setCurrentPageError(null);
|
||||
|
||||
// removing this will force the loading icon to appear.
|
||||
// we could also set a state for it at some point but this seemed
|
||||
// like a way to reduce states.
|
||||
setPublicTask(null);
|
||||
|
||||
recursivelyChangeNullAndUndefined(dataToSubmit, null);
|
||||
let path = `/public/messages/submit/${params.modified_message_name}`;
|
||||
let httpMethod = 'POST';
|
||||
if (publicTask?.task_guid && publicTask?.process_instance_id) {
|
||||
path = `/public/tasks/${publicTask.process_instance_id}/${publicTask.task_guid}`;
|
||||
httpMethod = 'PUT';
|
||||
}
|
||||
|
||||
HttpService.makeCallToBackend({
|
||||
path: `${path}?execution_mode=synchronous`,
|
||||
successCallback: processSubmitResult,
|
||||
failureCallback: (error: any) => {
|
||||
setCurrentPageError(error);
|
||||
},
|
||||
httpMethod,
|
||||
postBody: dataToSubmit,
|
||||
});
|
||||
};
|
||||
|
||||
const innerComponents = () => {
|
||||
if (currentPageError) {
|
||||
if (
|
||||
currentPageError.error_code ===
|
||||
'message_triggerable_process_model_not_found'
|
||||
) {
|
||||
return <Page404 />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{ErrorDisplayStateless(currentPageError)}
|
||||
<p>
|
||||
Go to{' '}
|
||||
<a href="/" data-qa="public-home-link">
|
||||
Home
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (confirmationMessage) {
|
||||
return (
|
||||
<MarkdownRenderer linkTarget="_blank" source={confirmationMessage} />
|
||||
);
|
||||
}
|
||||
if (publicTask) {
|
||||
let jsonSchema = publicTask.form.form_schema;
|
||||
let formUiSchema = publicTask.form.form_ui_schema;
|
||||
if (!jsonSchema) {
|
||||
jsonSchema = {
|
||||
type: 'object',
|
||||
required: [],
|
||||
properties: {
|
||||
isManualTask: {
|
||||
type: 'boolean',
|
||||
title: 'Is ManualTask',
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
formUiSchema = {
|
||||
isManualTask: {
|
||||
'ui:widget': 'hidden',
|
||||
},
|
||||
};
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<InstructionsForEndUser
|
||||
defaultMessage={publicTask.form.instructions_for_end_user}
|
||||
/>
|
||||
<Grid fullWidth condensed className="megacondensed">
|
||||
<Column sm={4} md={5} lg={8}>
|
||||
<CustomForm
|
||||
id="form-to-submit"
|
||||
disabled={formButtonsDisabled}
|
||||
formData={taskData}
|
||||
onChange={(obj: any) => {
|
||||
setTaskData(obj.formData);
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
schema={jsonSchema}
|
||||
uiSchema={formUiSchema}
|
||||
restrictedWidth
|
||||
reactJsonSchemaForm="mui"
|
||||
/>
|
||||
</Column>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const style = { margin: '50px 0 50px 50px' };
|
||||
return (
|
||||
<Loading
|
||||
description="Active loading indicator"
|
||||
withOverlay={false}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return <div className="fixed-width-container">{innerComponents()}</div>;
|
||||
}
|
|
@ -18,7 +18,11 @@ export default function SignOut() {
|
|||
different user?
|
||||
</Typography>
|
||||
<br />
|
||||
<Button variant="contained" onClick={logoutUser}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={logoutUser}
|
||||
data-qa="public-sign-out"
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
@ -97,7 +97,7 @@ backendCallProps) => {
|
|||
if (onUnauthorized) {
|
||||
onUnauthorized(result);
|
||||
} else if (UserService.isPublicUser()) {
|
||||
window.location.href = '/public/sign_out';
|
||||
window.location.href = '/public/sign-out';
|
||||
} else {
|
||||
// Hopefully we can make this service a hook and use the error message context directly
|
||||
// eslint-disable-next-line no-alert
|
||||
|
|
|
@ -122,15 +122,6 @@ const authenticationDisabled = () => {
|
|||
return false;
|
||||
};
|
||||
|
||||
const onlyGuestTaskCompletion = () => {
|
||||
const idToken = getIdToken();
|
||||
if (idToken) {
|
||||
const idObject = jwt(idToken);
|
||||
return (idObject as any).only_guest_task_completion;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return prefered username
|
||||
* Somehow if using Google as the OpenID provider, the field `preferred_username` is not returned
|
||||
|
@ -182,7 +173,6 @@ const UserService = {
|
|||
isLoggedIn,
|
||||
isPublicUser,
|
||||
loginIfNeeded,
|
||||
onlyGuestTaskCompletion,
|
||||
};
|
||||
|
||||
export default UserService;
|
||||
|
|
Loading…
Reference in New Issue