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

View File

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

View File

@ -48,5 +48,5 @@ permissions:
public_access:
groups: [spiff_public]
actions: [read, create]
actions: [read, create, update]
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 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()

View File

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

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

View File

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

View File

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

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_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:

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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)) {
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>
)
)}

View File

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

View File

@ -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} />,

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?
</Typography>
<br />
<Button variant="contained" onClick={logoutUser}>
<Button
variant="contained"
onClick={logoutUser}
data-qa="public-sign-out"
>
Sign out
</Button>
</div>

View File

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

View File

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