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:
jasquat 2024-03-19 18:52:27 +00:00 committed by GitHub
parent b78e81a5dc
commit 709a0c8492
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 858 additions and 621 deletions

View File

@ -2652,6 +2652,18 @@ paths:
enum: enum:
- synchronous - synchronous
- asynchronous - 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: put:
tags: tags:
- Tasks - Tasks

View File

@ -8,9 +8,16 @@ users:
groups: groups:
admin: admin:
users: [ciadmin1@spiffworkflow.org] users: [ciadmin1@spiffworkflow.org]
spiff_public:
users: []
permissions: permissions:
admin: admin:
groups: [admin] groups: [admin]
actions: [create, read, update, delete] actions: [create, read, update, delete]
uri: /* uri: /*
public_access:
groups: [spiff_public]
actions: [create, read, update]
uri: /public/*

View File

@ -48,5 +48,5 @@ permissions:
public_access: public_access:
groups: [spiff_public] groups: [spiff_public]
actions: [read, create] actions: [read, create, update]
uri: /public/* uri: /public/*

View File

@ -16,12 +16,10 @@ from spiffworkflow_backend.exceptions.error import InvalidRedirectUrlError
from spiffworkflow_backend.exceptions.error import MissingAccessTokenError from spiffworkflow_backend.exceptions.error import MissingAccessTokenError
from spiffworkflow_backend.exceptions.error import TokenExpiredError from spiffworkflow_backend.exceptions.error import TokenExpiredError
from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX 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 SPIFF_NO_AUTH_GROUP
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.service_account import ServiceAccountModel from spiffworkflow_backend.models.service_account import ServiceAccountModel
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 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 SPIFF_NO_AUTH_USER
from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authentication_service import AuthenticationService from spiffworkflow_backend.services.authentication_service import AuthenticationService
@ -130,15 +128,6 @@ def login(
) )
return redirect(redirect_url) 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) state = AuthenticationService.generate_state(redirect_url, authentication_identifier)
login_redirect_url = AuthenticationService().get_login_redirect_url( login_redirect_url = AuthenticationService().get_login_redirect_url(
state.decode("UTF-8"), authentication_identifier=authentication_identifier 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") 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]: def _find_token_from_request(token: str | None) -> dict[str, str | None]:
api_key = None api_key = None
if not token and "Authorization" in request.headers: 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) user_model = _get_user_from_decoded_internal_token(decoded_token)
except Exception as e: except Exception as e:
current_app.logger.error(f"Exception in verify_token getting user from decoded internal token. {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: else:
user_info = None user_info = None
authentication_identifier = _get_authentication_identifier_from_request() authentication_identifier = _get_authentication_identifier_from_request()

View File

@ -1,6 +1,8 @@
import json import json
import os
import uuid import uuid
from typing import Any from typing import Any
from typing import TypedDict
from uuid import UUID from uuid import UUID
import flask.wrappers 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.data_migrations.process_instance_migrator import ProcessInstanceMigrator
from spiffworkflow_backend.exceptions.api_error import ApiError 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.exceptions.process_entity_not_found_error import ProcessEntityNotFoundError
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.human_task import HumanTaskModel
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
from spiffworkflow_backend.models.principal import PrincipalModel from spiffworkflow_backend.models.principal import PrincipalModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel 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_instance_file_data import ProcessInstanceFileDataModel
from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
from spiffworkflow_backend.models.reference_cache import ReferenceSchema from spiffworkflow_backend.models.reference_cache import ReferenceSchema
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.task import TaskModel
from spiffworkflow_backend.services.authentication_service import AuthenticationService # noqa: F401
from spiffworkflow_backend.services.authorization_service import AuthorizationService 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 GitCommandError
from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.git_service import GitService
from spiffworkflow_backend.services.jinja_service import JinjaService 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_queue_service import ProcessInstanceQueueService
from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService
from spiffworkflow_backend.services.process_model_service import ProcessModelService 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 TaskModelError
from spiffworkflow_backend.services.task_service import TaskService from spiffworkflow_backend.services.task_service import TaskService
process_api_blueprint = Blueprint("process_api", __name__) 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: def permissions_check(body: dict[str, dict[str, list[str]]]) -> flask.wrappers.Response:
if "requests_to_check" not in body: if "requests_to_check" not in body:
raise ( raise (
@ -557,3 +575,211 @@ def _get_spiff_task_from_processor(
) )
) )
return spiff_task 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"

View File

@ -8,11 +8,14 @@ from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # type: ignore
from SpiffWorkflow.util.task import TaskState # type: ignore from SpiffWorkflow.util.task import TaskState # type: ignore
from spiffworkflow_backend.exceptions.api_error import ApiError 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 ProcessInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.task import TaskModel 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 _prepare_form_data
from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared
from spiffworkflow_backend.services.jinja_service import JinjaService 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) process_model = ProcessModelService.get_process_model(message_triggerable_process_model.process_model_identifier)
extensions = matching_start_tasks[0].task_spec.extensions 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( def message_form_submit(
@ -107,13 +116,21 @@ def form_submit(
next_form_contents = None next_form_contents = None
next_task_guid = None next_task_guid = None
next_task_assigned_to_me = None
if "next_task_assigned_to_me" in response_item: if "next_task_assigned_to_me" in response_item:
next_task_assigned_to_me = response_item["next_task_assigned_to_me"] 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() 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) process_model = ProcessModelService.get_process_model(process_instance.process_model_identifier)
next_form_contents = _get_form_and_prepare_data( 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 = { response_json = {
@ -125,6 +142,45 @@ def form_submit(
return make_response(jsonify(response_json), 200) 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( def _get_form_and_prepare_data(
process_model: ProcessModelInfo, process_model: ProcessModelInfo,
extensions: dict | None = None, extensions: dict | None = None,
@ -170,3 +226,34 @@ def _get_form_and_prepare_data(
extension_list["instructionsForEndUser"], task_data=task_data extension_list["instructionsForEndUser"], task_data=task_data
) )
return form_contents 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

View File

@ -1,9 +1,7 @@
import json import json
import os
from collections import OrderedDict from collections import OrderedDict
from collections.abc import Generator from collections.abc import Generator
from typing import Any from typing import Any
from typing import TypedDict
import flask.wrappers import flask.wrappers
import sentry_sdk 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.data_migrations.process_instance_migrator import ProcessInstanceMigrator
from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError 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 SpiffworkflowBaseDBModel
from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.human_task import HumanTaskModel
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel 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
from spiffworkflow_backend.models.json_data import JsonDataModel # noqa: F401
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus 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_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 _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 _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 _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.authorization_service import AuthorizationService
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService 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.jinja_service import JinjaService
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor 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 ProcessInstanceIsAlreadyLockedError
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService 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_service import ProcessInstanceService
from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService 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 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( def task_allows_guest(
process_instance_id: int, process_instance_id: int,
task_guid: str, task_guid: str,
@ -436,110 +420,11 @@ def task_show(
task_guid: str = "next", task_guid: str = "next",
with_form_data: bool = False, with_form_data: bool = False,
) -> flask.wrappers.Response: ) -> flask.wrappers.Response:
process_instance = _find_process_instance_by_id_or_raise(process_instance_id) task_model = _get_task_model_for_request(
process_instance_id=process_instance_id,
if process_instance.status == ProcessInstanceStatus.suspended.value: task_guid=task_guid,
raise ApiError( with_form_data=with_form_data,
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 make_response(jsonify(task_model), 200) return make_response(jsonify(task_model), 200)
@ -963,76 +848,6 @@ def _get_tasks(
return make_response(jsonify(response_json), 200) 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: 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_from_group_concat_or_similar = func.group_concat(assigned_user.username.distinct()).label(
"potential_owner_usernames" "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 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

View File

@ -39,7 +39,14 @@ class GetUrlForTaskWithBpmnIdentifier(Script):
" therefore it does not have a url to retrieve" " 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) guid = str(desired_spiff_task.id)
fe_url = current_app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"] 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 return url

View File

@ -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_assignment import PermissionAssignmentModel
from spiffworkflow_backend.models.permission_target import PermissionTargetModel from spiffworkflow_backend.models.permission_target import PermissionTargetModel
from spiffworkflow_backend.models.principal import PrincipalModel 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.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE
from spiffworkflow_backend.models.task import TaskModel # noqa: F401 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_GUEST_USER
@ -102,6 +101,7 @@ AUTHENTICATION_EXCLUSION_LIST = [
# these are api calls that are allowed to generate a public jwt when called # these are api calls that are allowed to generate a public jwt when called
PUBLIC_AUTHENTICATION_EXCLUSION_LIST = [ 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_show",
"spiffworkflow_backend.routes.public_controller.message_form_submit", "spiffworkflow_backend.routes.public_controller.message_form_submit",
] ]
@ -332,9 +332,6 @@ class AuthorizationService:
if cls.request_is_excluded_from_permission_check(): if cls.request_is_excluded_from_permission_check():
return None return None
if cls.request_allows_guest_access(decoded_token):
return None
cls.check_permission_for_request() cls.check_permission_for_request()
@classmethod @classmethod
@ -345,31 +342,6 @@ class AuthorizationService:
return True return True
return False 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 @staticmethod
def assert_user_can_complete_task( def assert_user_can_complete_task(
process_instance_id: int, process_instance_id: int,
@ -817,6 +789,8 @@ class AuthorizationService:
for group in group_permissions: for group in group_permissions:
group_identifier = group["name"] group_identifier = group["name"]
UserService.find_or_create_group(group_identifier) 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: if not group_permissions_only:
for username in group["users"]: for username in group["users"]:
if user_model and username != user_model.username: if user_model and username != user_model.username:

View File

@ -14,6 +14,7 @@
<bpmn:extensionElements> <bpmn:extensionElements>
<spiffworkflow:allowGuest>true</spiffworkflow:allowGuest> <spiffworkflow:allowGuest>true</spiffworkflow:allowGuest>
<spiffworkflow:guestConfirmation>You have completed the task.</spiffworkflow:guestConfirmation> <spiffworkflow:guestConfirmation>You have completed the task.</spiffworkflow:guestConfirmation>
<spiffworkflow:instructionsForEndUser>We have instructions.</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements> </bpmn:extensionElements>
<bpmn:incoming>Flow_14w7df0</bpmn:incoming> <bpmn:incoming>Flow_14w7df0</bpmn:incoming>
<bpmn:outgoing>Flow_02dvhev</bpmn:outgoing> <bpmn:outgoing>Flow_02dvhev</bpmn:outgoing>

View File

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

View File

@ -1,9 +1,11 @@
import json import json
import re
from flask import Flask from flask import Flask
from flask.testing import FlaskClient from flask.testing import FlaskClient
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus 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 AuthorizationService
from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict
@ -36,9 +38,14 @@ class TestPublicController(BaseTest):
response = client.get(url) response = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response.json is not None assert response.json is not None
assert "form_schema" in response.json assert "form" in response.json
assert "form_ui_schema" in response.json assert "confirmation_message_markdown" in response.json
assert response.json["form_schema"]["title"] == "Form for message start event" 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( def test_can_submit_to_public_message_submit(
self, self,
@ -161,3 +168,102 @@ class TestPublicController(BaseTest):
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first() process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first()
assert process_instance.status == ProcessInstanceStatus.user_input_required.value 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

View File

@ -441,86 +441,6 @@ class TestTasksController(BaseTest):
assert response.json["saved_form_data"] is None assert response.json["saved_form_data"] is None
assert response.json["data"]["HEY"] == draft_data["HEY"] 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( def test_task_instance_list(
self, self,
app: Flask, app: Flask,

View File

@ -21,6 +21,40 @@ class TestGetUrlForTaskWithBpmnIdentifier(BaseTest):
process_model = load_test_spec( process_model = load_test_spec(
process_model_id="misc/test-get-url-for-task-with-bpmn-identifier", process_model_id="misc/test-get-url-for-task-with-bpmn-identifier",
process_model_source_directory="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) process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user)
processor = ProcessInstanceProcessor(process_instance) processor = ProcessInstanceProcessor(process_instance)

View File

@ -27,6 +27,7 @@ yarn-error.log*
cypress/videos cypress/videos
cypress/screenshots cypress/screenshots
cypress/downloads
# i keep accidentally committing these # i keep accidentally committing these
/test*.json /test*.json

View File

@ -43,19 +43,6 @@ describe('tasks', () => {
submitInputIntoFormField('get_form_num_two', '#root_form_num_2', 3); submitInputIntoFormField('get_form_num_two', '#root_form_num_2', 3);
cy.contains('Task: get_form_num_three'); 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); submitInputIntoFormField('get_form_num_three', '#root_form_num_3', 4);
cy.contains('Task: get_form_num_four'); cy.contains('Task: get_form_num_four');
@ -98,17 +85,48 @@ describe('tasks', () => {
cy.contains('Process Instance Id: '); cy.contains('Process Instance Id: ');
cy.get('.process-instance-status').contains('complete'); 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', () => { describe('public_tasks', () => {
// // make sure we have some tasks it('can start process from message form', () => {
// kickOffModelWithForm(); // login and log out to ensure permissions are set correctly
// kickOffModelWithForm(); cy.login();
// kickOffModelWithForm(); cy.logout();
// kickOffModelWithForm();
// kickOffModelWithForm(); cy.visit('public/misc:bounty_start_multiple_forms');
// cy.get('#root_firstName').type('MyFirstName');
// cy.navigateToHome(); cy.contains('Submit').click();
// cy.basicPaginationTest('process-instance-show-link-id'); 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');
}
});
});
}); });

View File

@ -107,7 +107,11 @@ Cypress.Commands.add('createModel', (groupId, modelId, modelDisplayName) => {
// Intended to be run from the process model show page // Intended to be run from the process model show page
Cypress.Commands.add( Cypress.Commands.add(
'runPrimaryBpmnFile', 'runPrimaryBpmnFile',
(expectAutoRedirectToHumanTask = false, returnToProcessModelShow = true) => { (
expectAutoRedirectToHumanTask = false,
returnToProcessModelShow = true,
processInstanceExpectedToBeComplete = true
) => {
cy.getBySel('start-process-instance').click(); cy.getBySel('start-process-instance').click();
if (expectAutoRedirectToHumanTask) { if (expectAutoRedirectToHumanTask) {
// the url changes immediately, so also make sure we get some content from the next page, "Task:", // 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 { } else {
cy.url().should('include', `/process-instances`); cy.url().should('include', `/process-instances`);
cy.contains('Process Instance Id'); cy.contains('Process Instance Id');
cy.contains('complete'); if (processInstanceExpectedToBeComplete) {
cy.contains('complete');
}
if (returnToProcessModelShow) { if (returnToProcessModelShow) {
cy.getBySel('process-model-breadcrumb-link').click(); cy.getBySel('process-model-breadcrumb-link').click();
cy.getBySel('process-model-show-permissions-loaded').should('exist'); cy.getBySel('process-model-show-permissions-loaded').should('exist');

View File

@ -24,7 +24,7 @@ export default function App() {
<div className="cds--white"> <div className="cds--white">
<APIErrorProvider> <APIErrorProvider>
<AbilityContext.Provider value={ability}> <AbilityContext.Provider value={ability}>
<Outlet />; <Outlet />
</AbilityContext.Provider> </AbilityContext.Provider>
</APIErrorProvider> </APIErrorProvider>
</div> </div>

View File

@ -312,7 +312,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) {
); );
} }
if (activeKey && ability && !UserService.onlyGuestTaskCompletion()) { if (activeKey && ability) {
return ( return (
<HeaderContainer <HeaderContainer
render={({ isSideNavExpanded, onClickSideNavExpand }: any) => ( render={({ isSideNavExpanded, onClickSideNavExpand }: any) => (

View File

@ -3,6 +3,7 @@ import {
slugifyString, slugifyString,
underscorizeString, underscorizeString,
recursivelyChangeNullAndUndefined, recursivelyChangeNullAndUndefined,
isURL,
} from './helpers'; } from './helpers';
test('it can slugify a string', () => { 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.awesome).toEqual(false);
expect(result.contacts.info).toEqual(''); 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);
});
});

View File

@ -299,7 +299,7 @@ export const parseTaskShowUrl = (url: string) => {
export const isURL = (str: string) => { export const isURL = (str: string) => {
const urlRegex = const urlRegex =
// eslint-disable-next-line max-len // 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); return urlRegex.test(str);
}; };

View File

@ -509,7 +509,7 @@ export interface PublicTaskForm {
form_ui_schema: any; form_ui_schema: any;
instructions_for_end_user?: string; instructions_for_end_user?: string;
} }
export interface PublicTaskSubmitResponse { export interface PublicTask {
form: PublicTaskForm; form: PublicTaskForm;
task_guid: string; task_guid: string;
process_instance_id: number; process_instance_id: number;

View File

@ -5,7 +5,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { AuthenticationOption } from '../interfaces'; import { AuthenticationOption } from '../interfaces';
import HttpService from '../services/HttpService'; import HttpService from '../services/HttpService';
import UserService from '../services/UserService'; import UserService from '../services/UserService';
import { parseTaskShowUrl } from '../helpers';
export default function Login() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -13,9 +12,6 @@ export default function Login() {
const [authenticationOptions, setAuthenticationOptions] = useState< const [authenticationOptions, setAuthenticationOptions] = useState<
AuthenticationOption[] | null AuthenticationOption[] | null
>(null); >(null);
const [allowsGuestLogin, setAllowsGuestLogin] = useState<boolean | null>(
null
);
const originalUrl = searchParams.get('original_url'); const originalUrl = searchParams.get('original_url');
const getOriginalUrl = useCallback(() => { const getOriginalUrl = useCallback(() => {
@ -30,16 +26,6 @@ export default function Login() {
path: '/authentication-options', path: '/authentication-options',
successCallback: setAuthenticationOptions, 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]); }, [getOriginalUrl]);
const authenticationOptionButtons = () => { const authenticationOptionButtons = () => {
@ -112,8 +98,8 @@ export default function Login() {
); );
} }
if (authenticationOptions !== null && allowsGuestLogin !== null) { if (authenticationOptions !== null) {
if (allowsGuestLogin || authenticationOptions.length === 1) { if (authenticationOptions.length === 1) {
UserService.doLogin(authenticationOptions[0], getOriginalUrl()); UserService.doLogin(authenticationOptions[0], getOriginalUrl());
return null; return null;
} }

View File

@ -444,11 +444,11 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
}); });
}; };
const formatMetadataValue = (value: string) => { const formatMetadataValue = (key: string, value: string) => {
if (isURL(value)) { if (isURL(value)) {
return ( return (
<a href={value} target="_blank" rel="noopener noreferrer"> <a href={value} target="_blank" rel="noopener noreferrer">
{value} {key} link
</a> </a>
); );
} }
@ -560,7 +560,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
<dt title={processInstanceMetadata.key}> <dt title={processInstanceMetadata.key}>
{truncateString(processInstanceMetadata.key, 50)}: {truncateString(processInstanceMetadata.key, 50)}:
</dt> </dt>
<dd>{formatMetadataValue(processInstanceMetadata.value)}</dd> <dd data-qa={`metadata-value-${processInstanceMetadata.key}`}>
{formatMetadataValue(
processInstanceMetadata.key,
processInstanceMetadata.value
)}
</dd>
</dl> </dl>
) )
)} )}

View File

@ -1,6 +1,6 @@
import { Content } from '@carbon/react'; import { Content } from '@carbon/react';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import MessageStartEventForm from './public/MessageStartEventForm'; import PublicForm from './public/PublicForm';
import SignOut from './public/SignOut'; import SignOut from './public/SignOut';
export default function PublicRoutes() { export default function PublicRoutes() {
@ -8,10 +8,11 @@ export default function PublicRoutes() {
<Content className="main-site-body-centered"> <Content className="main-site-body-centered">
<Routes> <Routes>
<Route <Route
path="/:modified_message_name" path="/tasks/:process_instance_id/:task_guid"
element={<MessageStartEventForm />} element={<PublicForm />}
/> />
<Route path="/sign_out" element={<SignOut />} /> <Route path="/:modified_message_name" element={<PublicForm />} />
<Route path="/sign-out" element={<SignOut />} />
</Routes> </Routes>
</Content> </Content>
); );

View File

@ -24,8 +24,6 @@ import {
import CustomForm from '../components/CustomForm'; import CustomForm from '../components/CustomForm';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import InstructionsForEndUser from '../components/InstructionsForEndUser'; import InstructionsForEndUser from '../components/InstructionsForEndUser';
import UserService from '../services/UserService';
import MarkdownRenderer from '../components/MarkdownRenderer';
export default function TaskShow() { export default function TaskShow() {
// get a basic task which doesn't get the form data so we can load // 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 navigate = useNavigate();
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false); const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
const [guestConfirmationText, setGuestConfirmationText] = useState<
string | null
>(null);
const [taskData, setTaskData] = useState<any>(null); const [taskData, setTaskData] = useState<any>(null);
const [autosaveOnFormChanges, setAutosaveOnFormChanges] = const [autosaveOnFormChanges, setAutosaveOnFormChanges] =
useState<boolean>(true); 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 // always work for them so use that since it will work in all cases
const navigateToInterstitial = useCallback( const navigateToInterstitial = useCallback(
(myTask: BasicTask) => { (myTask: BasicTask) => {
if (UserService.onlyGuestTaskCompletion()) { navigate(
setGuestConfirmationText('Thank you!'); `/process-instances/for-me/${modifyProcessIdentifierForPathParam(
} else { myTask.process_model_identifier
navigate( )}/${myTask.process_instance_id}/interstitial`
`/process-instances/for-me/${modifyProcessIdentifierForPathParam( );
myTask.process_model_identifier
)}/${myTask.process_instance_id}/interstitial`
);
}
}, },
[navigate] [navigate]
); );
@ -190,12 +180,6 @@ export default function TaskShow() {
removeError(); removeError();
if (result.ok) { if (result.ok) {
navigate(`/tasks`); 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) { } else if (result.process_instance_id) {
if (result.can_complete) { if (result.can_complete) {
navigate(`/tasks/${result.process_instance_id}/${result.id}`); navigate(`/tasks/${result.process_instance_id}/${result.id}`);
@ -361,10 +345,7 @@ export default function TaskShow() {
if (taskWithTaskData.state === 'READY') { if (taskWithTaskData.state === 'READY') {
const submitButtonText = getSubmitButtonText(formUiSchema); const submitButtonText = getSubmitButtonText(formUiSchema);
let closeButton = null; let closeButton = null;
if ( if (taskWithTaskData.typename === 'UserTask') {
taskWithTaskData.typename === 'UserTask' &&
!UserService.onlyGuestTaskCompletion()
) {
closeButton = ( closeButton = (
<Button <Button
id="close-button" id="close-button"
@ -455,34 +436,22 @@ export default function TaskShow() {
statusString = ` ${basicTask.state}`; 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({ pageElements.push({
key: 'guest-confirmation-text', key: 'process-breadcrumb',
component: <ProcessBreadcrumb hotCrumbs={hotCrumbs} />,
});
pageElements.push({
key: 'task-name',
component: ( 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({ pageElements.push({
key: 'instructions-for-end-user', key: 'instructions-for-end-user',
component: <InstructionsForEndUser task={taskWithTaskData} />, component: <InstructionsForEndUser task={taskWithTaskData} />,

View File

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

View File

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

View File

@ -18,7 +18,11 @@ export default function SignOut() {
different user? different user?
</Typography> </Typography>
<br /> <br />
<Button variant="contained" onClick={logoutUser}> <Button
variant="contained"
onClick={logoutUser}
data-qa="public-sign-out"
>
Sign out Sign out
</Button> </Button>
</div> </div>

View File

@ -97,7 +97,7 @@ backendCallProps) => {
if (onUnauthorized) { if (onUnauthorized) {
onUnauthorized(result); onUnauthorized(result);
} else if (UserService.isPublicUser()) { } else if (UserService.isPublicUser()) {
window.location.href = '/public/sign_out'; window.location.href = '/public/sign-out';
} else { } else {
// Hopefully we can make this service a hook and use the error message context directly // Hopefully we can make this service a hook and use the error message context directly
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert

View File

@ -122,15 +122,6 @@ const authenticationDisabled = () => {
return false; 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 * Return prefered username
* Somehow if using Google as the OpenID provider, the field `preferred_username` is not returned * Somehow if using Google as the OpenID provider, the field `preferred_username` is not returned
@ -182,7 +173,6 @@ const UserService = {
isLoggedIn, isLoggedIn,
isPublicUser, isPublicUser,
loginIfNeeded, loginIfNeeded,
onlyGuestTaskCompletion,
}; };
export default UserService; export default UserService;